diff options
author | Florian Dold <florian.dold@gmail.com> | 2017-05-28 23:15:41 +0200 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2017-05-28 23:15:41 +0200 |
commit | b6e774585d32017e5f1ceeeb2b2e2a5e350354d3 (patch) | |
tree | 080cb5afe3b48c0428abd2d7de1ff7fe34d9b9b1 /src/webex | |
parent | 38a74188d759444d7e1abac856f78ae710e2a4c5 (diff) |
move webex specific things in their own directory
Diffstat (limited to 'src/webex')
32 files changed, 6532 insertions, 0 deletions
diff --git a/src/webex/background.html b/src/webex/background.html new file mode 100644 index 000000000..0535dd5f3 --- /dev/null +++ b/src/webex/background.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <script src="../../dist/background-bundle.js"></script> + <title>(wallet bg page)</title> +</head> +<body> + <img id="taler-logo" src="/img/icon.png"> +</body> +</html> diff --git a/src/webex/background.ts b/src/webex/background.ts new file mode 100644 index 000000000..3c63f323e --- /dev/null +++ b/src/webex/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/src/webex/chromeBadge.ts b/src/webex/chromeBadge.ts new file mode 100644 index 000000000..13add9b3f --- /dev/null +++ b/src/webex/chromeBadge.ts @@ -0,0 +1,225 @@ +/* + 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 { + Badge, +} from "../wallet"; + + +/** + * Polyfill for requestAnimationFrame, which + * doesn't work from a background page. + */ +function rAF(cb: (ts: number) => 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 implements Badge { + 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: boolean = 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: boolean = false; + + /** + * Current rotation angle, ranges from 0 to rotationAngleMax. + */ + private rotationAngle: number = 0; + + /** + * While animating, how wide is the current gap in the circle? + * Ranges from 0 to openMax. + */ + private gapWidth: number = 0; + + /** + * 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; + this.ctx = this.canvas.getContext("2d")!; + this.draw(); + } + + /** + * Draw the badge based on the current state. + */ + private draw() { + 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); + + // 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() { + if (this.animationRunning) { + return; + } + this.animationRunning = true; + let start: number|undefined; + const step = (timestamp: number) => { + 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); + } + + startBusy() { + if (this.isBusy) { + return; + } + this.isBusy = true; + this.animate(); + } + + stopBusy() { + this.isBusy = false; + } +} diff --git a/src/webex/components.ts b/src/webex/components.ts new file mode 100644 index 000000000..1f5d18731 --- /dev/null +++ b/src/webex/components.ts @@ -0,0 +1,63 @@ +/* + 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/> + */ + + +/** + * General helper React components. + */ + + +/** + * Imports. + */ +import * as React from "react"; + +/** + * Wrapper around state that will cause updates to the + * containing component. + */ +export interface StateHolder<T> { + (): T; + (newState: T): void; +} + +/** + * Component that doesn't hold its state in one object, + * but has multiple state holders. + */ +export abstract class ImplicitStateComponent<PropType> extends React.Component<PropType, any> { + private _implicit = {needsUpdate: false, didMount: false}; + componentDidMount() { + this._implicit.didMount = true; + if (this._implicit.needsUpdate) { + this.setState({} as any); + } + } + makeState<StateType>(initial: StateType): StateHolder<StateType> { + let state: StateType = initial; + return (s?: StateType): StateType => { + if (s !== undefined) { + state = s; + if (this._implicit.didMount) { + this.setState({} as any); + } else { + this._implicit.needsUpdate = true; + } + } + return state; + }; + } +} diff --git a/src/webex/notify.ts b/src/webex/notify.ts new file mode 100644 index 000000000..733367a59 --- /dev/null +++ b/src/webex/notify.ts @@ -0,0 +1,571 @@ +/* + 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/> + */ + +// tslint:disable:no-unused-expression + +/** + * Module that is injected into (all!) pages to allow them + * to interact with the GNU Taler wallet via DOM Events. + */ + + +/** + * Imports. + */ +import URI = require("urijs"); + +declare var cloneInto: any; + +const PROTOCOL_VERSION = 1; + +let logVerbose: boolean = false; +try { + logVerbose = !!localStorage.getItem("taler-log-verbose"); +} catch (e) { + // can't read from local storage +} + +if (document.documentElement.getAttribute("data-taler-nojs")) { + document.dispatchEvent(new Event("taler-probe-result")); +} + + +function subst(url: string, H_contract: string) { + url = url.replace("${H_contract}", H_contract); + url = url.replace("${$}", "$"); + return url; +} + +interface Handler { + type: string; + listener: (e: CustomEvent) => void|Promise<void>; +} +const handlers: Handler[] = []; + +function hashContract(contract: string): Promise<string> { + const walletHashContractMsg = { + detail: {contract}, + type: "hash-contract", + }; + return new Promise<string>((resolve, reject) => { + chrome.runtime.sendMessage(walletHashContractMsg, (resp: any) => { + if (!resp.hash) { + console.log("error", resp); + reject(Error("hashing failed")); + } + resolve(resp.hash); + }); + }); +} + +function queryPayment(url: string): Promise<any> { + const walletMsg = { + detail: { url }, + type: "query-payment", + }; + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(walletMsg, (resp: any) => { + resolve(resp); + }); + }); +} + +function putHistory(historyEntry: any): Promise<void> { + const walletMsg = { + detail: { + historyEntry, + }, + type: "put-history-entry", + }; + return new Promise<void>((resolve, reject) => { + chrome.runtime.sendMessage(walletMsg, (resp: any) => { + resolve(); + }); + }); +} + +function saveOffer(offer: any): Promise<number> { + const walletMsg = { + detail: { + offer: { + H_contract: offer.hash, + contract: offer.data, + merchant_sig: offer.sig, + offer_time: new Date().getTime() / 1000, + }, + type: "save-offer", + }, + }; + return new Promise<number>((resolve, reject) => { + chrome.runtime.sendMessage(walletMsg, (resp: any) => { + if (resp && resp.error) { + reject(resp); + } else { + resolve(resp); + } + }); + }); +} + + +let sheet: CSSStyleSheet|null; + +function initStyle() { + logVerbose && console.log("taking over styles"); + const name = "taler-presence-stylesheet"; + const content = "/* Taler stylesheet controlled by JS */"; + let style = document.getElementById(name) as HTMLStyleElement|null; + if (!style) { + style = document.createElement("style"); + // Needed by WebKit + style.appendChild(document.createTextNode(content)); + style.id = name; + document.head.appendChild(style); + sheet = style.sheet as CSSStyleSheet; + } else { + // We've taken over the stylesheet now, + // make it clear by clearing all the rules in it + // and making it obvious in the DOM. + if (style.tagName.toLowerCase() === "style") { + style.innerText = content; + } + if (!style.sheet) { + throw Error("taler-presence-stylesheet should be a style sheet (<link> or <style>)"); + } + sheet = style.sheet as CSSStyleSheet; + while (sheet.cssRules.length > 0) { + sheet.deleteRule(0); + } + } +} + + +function setStyles(installed: boolean) { + if (!sheet || !sheet.cssRules) { + return; + } + while (sheet.cssRules.length > 0) { + sheet.deleteRule(0); + } + if (installed) { + sheet.insertRule(".taler-installed-hide { display: none; }", 0); + sheet.insertRule(".taler-probed-hide { display: none; }", 0); + } else { + sheet.insertRule(".taler-installed-show { display: none; }", 0); + } +} + + +function handlePaymentResponse(walletResp: any) { + /** + * Handle a failed payment. + * + * Try to notify the wallet first, before we show a potentially + * synchronous error message (such as an alert) or leave the page. + */ + function handleFailedPayment(r: XMLHttpRequest) { + let timeoutHandle: number|null = null; + function err() { + // FIXME: proper error reporting! + console.log("pay-failed", {status: r.status, response: r.responseText}); + } + function onTimeout() { + timeoutHandle = null; + err(); + } + talerPaymentFailed(walletResp.H_contract).then(() => { + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + err(); + }); + timeoutHandle = window.setTimeout(onTimeout, 200); + } + + + logVerbose && console.log("handling taler-notify-payment: ", walletResp); + // Payment timeout in ms. + let timeout_ms = 1000; + // Current request. + let r: XMLHttpRequest|null; + let timeoutHandle: number|null = null; + function sendPay() { + r = new XMLHttpRequest(); + r.open("post", walletResp.contract.pay_url); + r.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + r.send(JSON.stringify(walletResp.payReq)); + r.onload = () => { + if (!r) { + return; + } + switch (r.status) { + case 200: + const merchantResp = JSON.parse(r.responseText); + logVerbose && console.log("got success from pay_url"); + talerPaymentSucceeded({H_contract: walletResp.H_contract, merchantSig: merchantResp.sig}).then(() => { + const nextUrl = walletResp.contract.fulfillment_url; + logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl); + window.location.href = nextUrl; + window.location.reload(true); + }); + break; + default: + handleFailedPayment(r); + break; + } + r = null; + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle!); + timeoutHandle = null; + } + }; + function retry() { + if (r) { + r.abort(); + r = null; + } + timeout_ms = Math.min(timeout_ms * 2, 10 * 1000); + logVerbose && console.log("sendPay timed out, retrying in ", timeout_ms, "ms"); + sendPay(); + } + timeoutHandle = window.setTimeout(retry, timeout_ms); + } + sendPay(); +} + + +function init() { + chrome.runtime.sendMessage({type: "get-tab-cookie"}, (resp) => { + if (chrome.runtime.lastError) { + logVerbose && console.log("extension not yet ready"); + window.setTimeout(init, 200); + return; + } + if (document.documentElement.getAttribute("data-taler-nojs")) { + const onload = () => { + initStyle(); + setStyles(true); + }; + if (document.readyState === "complete") { + onload(); + } else { + document.addEventListener("DOMContentLoaded", onload); + } + } + registerHandlers(); + // Hack to know when the extension is unloaded + const port = chrome.runtime.connect(); + + port.onDisconnect.addListener(() => { + logVerbose && console.log("chrome runtime disconnected, removing handlers"); + if (document.documentElement.getAttribute("data-taler-nojs")) { + setStyles(false); + } + for (const handler of handlers) { + document.removeEventListener(handler.type, handler.listener); + } + }); + + if (resp && resp.type === "pay") { + logVerbose && console.log("doing taler.pay with", resp.payDetail); + talerPay(resp.payDetail).then(handlePaymentResponse); + document.documentElement.style.visibility = "hidden"; + } + }); +} + +type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void; + +function generateNonce(): Promise<string> { + const walletMsg = { + type: "generate-nonce", + }; + return new Promise<string>((resolve, reject) => { + chrome.runtime.sendMessage(walletMsg, (resp: any) => { + resolve(resp); + }); + }); +} + +function downloadContract(url: string, nonce: string): Promise<any> { + const parsed_url = new URI(url); + url = parsed_url.setQuery({nonce}).href(); + // FIXME: include and check nonce! + return new Promise((resolve, reject) => { + const contract_request = new XMLHttpRequest(); + console.log("downloading contract from '" + url + "'"); + contract_request.open("GET", url, true); + contract_request.onload = (e) => { + if (contract_request.readyState === 4) { + if (contract_request.status === 200) { + console.log("response text:", + contract_request.responseText); + const contract_wrapper = JSON.parse(contract_request.responseText); + if (!contract_wrapper) { + console.error("response text was invalid json"); + const detail = { + body: contract_request.responseText, + hint: "invalid json", + status: contract_request.status, + }; + reject(detail); + return; + } + resolve(contract_wrapper); + } else { + const detail = { + body: contract_request.responseText, + hint: "contract download failed", + status: contract_request.status, + }; + reject(detail); + return; + } + } + }; + contract_request.onerror = (e) => { + const detail = { + body: contract_request.responseText, + hint: "contract download failed", + status: contract_request.status, + }; + reject(detail); + return; + }; + contract_request.send(); + }); +} + +async function processProposal(proposal: any) { + if (!proposal.data) { + console.error("field proposal.data field missing"); + return; + } + + if (!proposal.hash) { + console.error("proposal.hash field missing"); + return; + } + + const contractHash = await hashContract(proposal.data); + + if (contractHash !== proposal.hash) { + console.error("merchant-supplied contract hash is wrong"); + return; + } + + let merchantName = "(unknown)"; + try { + merchantName = proposal.data.merchant.name; + } catch (e) { + // bad contract / name not included + } + + const historyEntry = { + detail: { + contractHash, + merchantName, + }, + subjectId: `contract-${contractHash}`, + timestamp: (new Date()).getTime(), + type: "offer-contract", + }; + await putHistory(historyEntry); + const offerId = await saveOffer(proposal); + + const uri = new URI(chrome.extension.getURL( + "/src/pages/confirm-contract.html")); + const params = { + offerId: offerId.toString(), + }; + const target = uri.query(params).href(); + document.location.replace(target); +} + +function talerPay(msg: any): Promise<any> { + return new Promise(async(resolve, reject) => { + // current URL without fragment + const url = new URI(document.location.href).fragment("").href(); + const res = await queryPayment(url); + logVerbose && console.log("taler-pay: got response", res); + if (res && res.payReq) { + resolve(res); + return; + } + if (msg.contract_url) { + const nonce = await generateNonce(); + const proposal = await downloadContract(msg.contract_url, nonce); + if (proposal.data.nonce !== nonce) { + console.error("stale contract"); + return; + } + await processProposal(proposal); + return; + } + + if (msg.offer_url) { + document.location.href = msg.offer_url; + return; + } + + console.log("can't proceed with payment, no way to get contract specified"); + }); +} + +function talerPaymentFailed(H_contract: string) { + return new Promise(async(resolve, reject) => { + const walletMsg = { + detail: { + contractHash: H_contract, + }, + type: "payment-failed", + }; + chrome.runtime.sendMessage(walletMsg, (resp) => { + resolve(); + }); + }); +} + +function talerPaymentSucceeded(msg: any) { + return new Promise((resolve, reject) => { + if (!msg.H_contract) { + console.error("H_contract missing in taler-payment-succeeded"); + return; + } + if (!msg.merchantSig) { + console.error("merchantSig missing in taler-payment-succeeded"); + return; + } + logVerbose && console.log("got taler-payment-succeeded"); + const walletMsg = { + detail: { + contractHash: msg.H_contract, + merchantSig: msg.merchantSig, + }, + type: "payment-succeeded", + }; + chrome.runtime.sendMessage(walletMsg, (resp) => { + resolve(); + }); + }); +} + + +function registerHandlers() { + /** + * Add a handler for a DOM event, which automatically + * handles adding sequence numbers to responses. + */ + function addHandler(type: string, handler: HandlerFn) { + const handlerWrap = (e: CustomEvent) => { + if (e.type !== type) { + throw Error(`invariant violated`); + } + let callId: number|undefined; + if (e.detail && e.detail.callId !== undefined) { + callId = e.detail.callId; + } + const responder = (msg?: any) => { + const fullMsg = Object.assign({}, msg, {callId}); + let opts = { detail: fullMsg }; + if ("function" === typeof cloneInto) { + opts = cloneInto(opts, document.defaultView); + } + const evt = new CustomEvent(type + "-result", opts); + document.dispatchEvent(evt); + }; + handler(e.detail, responder); + }; + document.addEventListener(type, handlerWrap); + handlers.push({type, listener: handlerWrap}); + } + + + addHandler("taler-query-id", (msg: any, sendResponse: any) => { + // FIXME: maybe include this info in taoer-probe? + sendResponse({id: chrome.runtime.id}); + }); + + addHandler("taler-probe", (msg: any, sendResponse: any) => { + sendResponse(); + }); + + addHandler("taler-create-reserve", (msg: any) => { + const params = { + amount: JSON.stringify(msg.amount), + bank_url: document.location.href, + callback_url: new URI(msg.callback_url) .absoluteTo(document.location.href), + suggested_exchange_url: msg.suggested_exchange_url, + wt_types: JSON.stringify(msg.wt_types), + }; + const uri = new URI(chrome.extension.getURL("/src/pages/confirm-create-reserve.html")); + const redirectUrl = uri.query(params).href(); + window.location.href = redirectUrl; + }); + + addHandler("taler-add-auditor", (msg: any) => { + const params = { + req: JSON.stringify(msg), + }; + const uri = new URI(chrome.extension.getURL("/src/pages/add-auditor.html")); + const redirectUrl = uri.query(params).href(); + window.location.href = redirectUrl; + }); + + addHandler("taler-confirm-reserve", (msg: any, sendResponse: any) => { + const walletMsg = { + detail: { + reservePub: msg.reserve_pub, + }, + type: "confirm-reserve", + }; + chrome.runtime.sendMessage(walletMsg, (resp) => { + sendResponse(); + }); + }); + + + addHandler("taler-confirm-contract", async(msg: any) => { + if (!msg.contract_wrapper) { + console.error("contract wrapper missing"); + return; + } + + const proposal = msg.contract_wrapper; + + processProposal(proposal); + }); + + addHandler("taler-pay", async(msg: any, sendResponse: any) => { + const resp = await talerPay(msg); + sendResponse(resp); + }); + + addHandler("taler-payment-failed", async(msg: any, sendResponse: any) => { + await talerPaymentFailed(msg.H_contract); + sendResponse(); + }); + + addHandler("taler-payment-succeeded", async(msg: any, sendResponse: any) => { + await talerPaymentSucceeded(msg); + sendResponse(); + }); +} + +logVerbose && console.log("loading Taler content script"); +init(); + diff --git a/src/webex/pages/add-auditor.html b/src/webex/pages/add-auditor.html new file mode 100644 index 000000000..b7a9d041d --- /dev/null +++ b/src/webex/pages/add-auditor.html @@ -0,0 +1,34 @@ +<!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="/dist/page-common-bundle.js"></script> + <script src="/dist/add-auditor-bundle.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> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/add-auditor.tsx b/src/webex/pages/add-auditor.tsx new file mode 100644 index 000000000..c1a9f997f --- /dev/null +++ b/src/webex/pages/add-auditor.tsx @@ -0,0 +1,126 @@ +/* + 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 + */ + + +import { getTalerStampDate } from "../../helpers"; +import { + ExchangeRecord, + DenominationRecord, + AuditorRecord, + CurrencyRecord, + ReserveRecord, + CoinRecord, + PreCoinRecord, + Denomination +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getCurrencies, + updateCurrency, +} from "../wxApi"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +interface ConfirmAuditorProps { + url: string; + currency: string; + auditorPub: string; + expirationStamp: number; +} + +class ConfirmAuditor extends ImplicitStateComponent<ConfirmAuditorProps> { + addDone: StateHolder<boolean> = this.makeState(false); + constructor() { + super(); + } + + async add() { + let currencies = await getCurrencies(); + let currency: CurrencyRecord|undefined = undefined; + + for (let c of currencies) { + if (c.name == this.props.currency) { + currency = c; + } + } + + if (!currency) { + currency = { name: this.props.currency, auditors: [], fractionalDigits: 2, exchanges: [] }; + } + + let newAuditor = { auditorPub: this.props.auditorPub, baseUrl: this.props.url, expirationStamp: this.props.expirationStamp }; + + let auditorFound = false; + for (let idx in currency.auditors) { + let a = currency.auditors[idx]; + if (a.baseUrl == this.props.url) { + auditorFound = true; + // Update auditor if already found by URL. + currency.auditors[idx] = newAuditor; + } + } + + if (!auditorFound) { + currency.auditors.push(newAuditor); + } + + await updateCurrency(currency); + + this.addDone(true); + } + + back() { + window.history.back(); + } + + render(): JSX.Element { + return ( + <div id="main"> + <p>Do you want to let <strong>{this.props.auditorPub}</strong> audit the currency "{this.props.currency}"?</p> + {this.addDone() ? + (<div>Auditor was added! You can also <a href={chrome.extension.getURL("/src/pages/auditors.html")}>view and edit</a> auditors.</div>) + : + (<div> + <button onClick={() => this.add()} className="pure-button pure-button-primary">Yes</button> + <button onClick={() => this.back()} className="pure-button">No</button> + </div>) + } + </div> + ); + } +} + +export function main() { + const walletPageUrl = new URI(document.location.href); + const query: any = JSON.parse((URI.parseQuery(walletPageUrl.query()) as any)["req"]); + const url = query.url; + const currency: string = query.currency; + const auditorPub: string = query.auditorPub; + const expirationStamp = Number.parseInt(query.expirationStamp); + const args = { url, currency, auditorPub, expirationStamp }; + ReactDOM.render(<ConfirmAuditor {...args} />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/pages/auditors.html b/src/webex/pages/auditors.html new file mode 100644 index 000000000..cbfc3b4b5 --- /dev/null +++ b/src/webex/pages/auditors.html @@ -0,0 +1,36 @@ +<!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/page-common-bundle.js"></script> + <script src="/dist/auditors-bundle.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> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/auditors.tsx b/src/webex/pages/auditors.tsx new file mode 100644 index 000000000..dac3c2be9 --- /dev/null +++ b/src/webex/pages/auditors.tsx @@ -0,0 +1,147 @@ +/* + 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 + */ + + +import { getTalerStampDate } from "../../helpers"; +import { + ExchangeRecord, + ExchangeForCurrencyRecord, + DenominationRecord, + AuditorRecord, + CurrencyRecord, + ReserveRecord, + CoinRecord, + PreCoinRecord, + Denomination +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getCurrencies, + updateCurrency, +} from "../wxApi"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface CurrencyListState { + currencies?: CurrencyRecord[]; +} + +class CurrencyList extends React.Component<any, CurrencyListState> { + constructor() { + super(); + let port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + this.state = {} as any; + } + + async update() { + let currencies = await getCurrencies(); + console.log("currencies: ", currencies); + this.setState({ currencies }); + } + + async confirmRemoveAuditor(c: CurrencyRecord, a: AuditorRecord) { + if (window.confirm(`Do you really want to remove auditor ${a.baseUrl} for currency ${c.name}?`)) { + c.auditors = c.auditors.filter((x) => x.auditorPub != a.auditorPub); + await updateCurrency(c); + } + } + + async confirmRemoveExchange(c: CurrencyRecord, e: ExchangeForCurrencyRecord) { + if (window.confirm(`Do you really want to remove exchange ${e.baseUrl} for currency ${c.name}?`)) { + c.exchanges = c.exchanges.filter((x) => x.baseUrl != e.baseUrl); + await updateCurrency(c); + } + } + + renderAuditors(c: CurrencyRecord): any { + if (c.auditors.length == 0) { + return <p>No trusted auditors for this currency.</p> + } + return ( + <div> + <p>Trusted Auditors:</p> + <ul> + {c.auditors.map(a => ( + <li>{a.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveAuditor(c, a)}>Remove</button> + <ul> + <li>valid until {new Date(a.expirationStamp).toString()}</li> + <li>public key {a.auditorPub}</li> + </ul> + </li> + ))} + </ul> + </div> + ); + } + + renderExchanges(c: CurrencyRecord): any { + if (c.exchanges.length == 0) { + return <p>No trusted exchanges for this currency.</p> + } + return ( + <div> + <p>Trusted Exchanges:</p> + <ul> + {c.exchanges.map(e => ( + <li>{e.baseUrl} <button className="pure-button button-destructive" onClick={() => this.confirmRemoveExchange(c, e)}>Remove</button> + </li> + ))} + </ul> + </div> + ); + } + + render(): JSX.Element { + let currencies = this.state.currencies; + if (!currencies) { + return <span>...</span>; + } + return ( + <div id="main"> + {currencies.map(c => ( + <div> + <h1>Currency {c.name}</h1> + <p>Displayed with {c.fractionalDigits} fractional digits.</p> + <h2>Auditors</h2> + <div>{this.renderAuditors(c)}</div> + <h2>Exchanges</h2> + <div>{this.renderExchanges(c)}</div> + </div> + ))} + </div> + ); + } +} + +export function main() { + ReactDOM.render(<CurrencyList />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/pages/confirm-contract.html b/src/webex/pages/confirm-contract.html new file mode 100644 index 000000000..6713b2e2c --- /dev/null +++ b/src/webex/pages/confirm-contract.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Confirm Reserve Creation</title> + + <link rel="stylesheet" type="text/css" href="/src/style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/confirm-contract-bundle.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; + } + </style> +</head> + +<body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <article id="contract" class="fade"></article> + </section> +</body> + +</html> diff --git a/src/webex/pages/confirm-contract.tsx b/src/webex/pages/confirm-contract.tsx new file mode 100644 index 000000000..011df27a1 --- /dev/null +++ b/src/webex/pages/confirm-contract.tsx @@ -0,0 +1,242 @@ +/* + 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 { Contract, AmountJson, ExchangeRecord } from "../../types"; +import { OfferRecord } from "../../wallet"; + +import { renderContract } from "../renderHtml"; +import { getExchanges } from "../wxApi"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + + +interface DetailState { + collapsed: boolean; +} + +interface DetailProps { + contract: Contract + collapsed: boolean + exchanges: null|ExchangeRecord[]; +} + + +class Details extends React.Component<DetailProps, DetailState> { + constructor(props: DetailProps) { + super(props); + console.log("new Details component created"); + this.state = { + collapsed: props.collapsed, + }; + + console.log("initial state:", this.state); + } + + render() { + if (this.state.collapsed) { + return ( + <div> + <button className="linky" + onClick={() => { this.setState({collapsed: false} as any)}}> + <i18n.Translate wrap="span"> + show more details + </i18n.Translate> + </button> + </div> + ); + } else { + return ( + <div> + <button className="linky" + onClick={() => this.setState({collapsed: true} as any)}> + show less details + </button> + <div> + {i18n.str`Accepted exchanges:`} + <ul> + {this.props.contract.exchanges.map( + e => <li>{`${e.url}: ${e.master_pub}`}</li>)} + </ul> + {i18n.str`Exchanges in the wallet:`} + <ul> + {(this.props.exchanges || []).map( + (e: ExchangeRecord) => + <li>{`${e.baseUrl}: ${e.masterPublicKey}`}</li>)} + </ul> + </div> + </div>); + } + } +} + +interface ContractPromptProps { + offerId: number; +} + +interface ContractPromptState { + offer: OfferRecord|null; + error: string|null; + payDisabled: boolean; + exchanges: null|ExchangeRecord[]; +} + +class ContractPrompt extends React.Component<ContractPromptProps, ContractPromptState> { + constructor() { + super(); + this.state = { + offer: null, + error: null, + payDisabled: true, + exchanges: null + } + } + + componentWillMount() { + this.update(); + } + + componentWillUnmount() { + // FIXME: abort running ops + } + + async update() { + let offer = await this.getOffer(); + this.setState({offer} as any); + this.checkPayment(); + let exchanges = await getExchanges(); + this.setState({exchanges} as any); + } + + getOffer(): Promise<OfferRecord> { + return new Promise<OfferRecord>((resolve, reject) => { + let msg = { + type: 'get-offer', + detail: { + offerId: this.props.offerId + } + }; + chrome.runtime.sendMessage(msg, (resp) => { + resolve(resp); + }); + }) + } + + checkPayment() { + let msg = { + type: 'check-pay', + detail: { + offer: this.state.offer + } + }; + chrome.runtime.sendMessage(msg, (resp) => { + if (resp.error) { + console.log("check-pay error", JSON.stringify(resp)); + switch (resp.error) { + case "coins-insufficient": + let msgInsufficient = i18n.str`You have insufficient funds of the requested currency in your wallet.`; + let msgNoMatch = i18n.str`You do not have any funds from an exchange that is accepted by this merchant. None of the exchanges accepted by the merchant is known to your wallet.`; + if (this.state.exchanges && this.state.offer) { + let acceptedExchangePubs = this.state.offer.contract.exchanges.map((e) => e.master_pub); + let ex = this.state.exchanges.find((e) => acceptedExchangePubs.indexOf(e.masterPublicKey) >= 0); + if (ex) { + this.setState({error: msgInsufficient}); + } else { + this.setState({error: msgNoMatch}); + } + } else { + this.setState({error: msgInsufficient}); + } + break; + default: + this.setState({error: `Error: ${resp.error}`}); + break; + } + this.setState({payDisabled: true}); + } else { + this.setState({payDisabled: false, error: null}); + } + this.setState({} as any); + window.setTimeout(() => this.checkPayment(), 500); + }); + } + + doPayment() { + let d = {offer: this.state.offer}; + chrome.runtime.sendMessage({type: 'confirm-pay', detail: d}, (resp) => { + if (resp.error) { + console.log("confirm-pay error", JSON.stringify(resp)); + switch (resp.error) { + case "coins-insufficient": + this.setState({error: "You do not have enough coins of the requested currency."}); + break; + default: + this.setState({error: `Error: ${resp.error}`}); + break; + } + return; + } + let c = d.offer!.contract; + console.log("contract", c); + document.location.href = c.fulfillment_url; + }); + } + + + render() { + if (!this.state.offer) { + return <span>...</span>; + } + let c = this.state.offer.contract; + return ( + <div> + <div> + {renderContract(c)} + </div> + <button onClick={() => this.doPayment()} + disabled={this.state.payDisabled} + className="accept"> + Confirm payment + </button> + <div> + {(this.state.error ? <p className="errorbox">{this.state.error}</p> : <p />)} + </div> + <Details exchanges={this.state.exchanges} contract={c} collapsed={!this.state.error}/> + </div> + ); + } +} + + +document.addEventListener("DOMContentLoaded", () => { + let url = new URI(document.location.href); + let query: any = URI.parseQuery(url.query()); + let offerId = JSON.parse(query.offerId); + + ReactDOM.render(<ContractPrompt offerId={offerId}/>, document.getElementById( + "contract")!); +}); diff --git a/src/webex/pages/confirm-create-reserve.html b/src/webex/pages/confirm-create-reserve.html new file mode 100644 index 000000000..16ab12a30 --- /dev/null +++ b/src/webex/pages/confirm-create-reserve.html @@ -0,0 +1,52 @@ +<!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="/src/style/wallet.css"> + <link rel="stylesheet" type="text/css" href="/src/style/pure.css"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/confirm-create-reserve-bundle.js"></script> + + <style> + body { + font-size: 100%; + overflow-y: scroll; + } + .button-success { + background: rgb(28, 184, 65); /* this is a green */ + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + } + .button-secondary { + background: rgb(66, 184, 221); /* this is a light blue */ + color: white; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + } + a.opener { + color: black; + } + .opener-open::before { + content: "\25bc" + } + .opener-collapsed::before { + content: "\25b6 " + } + </style> + +</head> + +<body> + <section id="main"> + <h1>GNU Taler Wallet</h1> + <div class="fade" id="exchange-selection"></div> + </section> +</body> + +</html> diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx new file mode 100644 index 000000000..6ece92e21 --- /dev/null +++ b/src/webex/pages/confirm-create-reserve.tsx @@ -0,0 +1,641 @@ +/* + 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 {amountToPretty, canonicalizeBaseUrl} from "../../helpers"; +import { + AmountJson, CreateReserveResponse, + ReserveCreationInfo, Amounts, + Denomination, DenominationRecord, CurrencyRecord +} from "../../types"; +import * as i18n from "../../i18n"; + +import {getReserveCreationInfo, getCurrency, getExchangeInfo} from "../wxApi"; +import {ImplicitStateComponent, StateHolder} from "../components"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); +import * as moment from "moment"; + + +function delay<T>(delayMs: number, value: T): Promise<T> { + return new Promise<T>((resolve, reject) => { + setTimeout(() => resolve(value), delayMs); + }); +} + +class EventTrigger { + triggerResolve: any; + triggerPromise: Promise<boolean>; + + constructor() { + this.reset(); + } + + private reset() { + this.triggerPromise = new Promise<boolean>((resolve, reject) => { + this.triggerResolve = resolve; + }); + } + + trigger() { + this.triggerResolve(false); + this.reset(); + } + + async wait(delayMs: number): Promise<boolean> { + return await Promise.race([this.triggerPromise, delay(delayMs, true)]); + } +} + + +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +class Collapsible extends React.Component<CollapsibleProps, CollapsibleState> { + constructor(props: CollapsibleProps) { + super(props); + this.state = { collapsed: props.initiallyCollapsed }; + } + render() { + const doOpen = (e: any) => { + this.setState({collapsed: false}) + e.preventDefault() + }; + const doClose = (e: any) => { + 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 renderAuditorDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); + } + if (rci.exchangeInfo.auditors.length == 0) { + return ( + <p> + The exchange is not audited by any auditors. + </p> + ); + } + return ( + <div> + {rci.exchangeInfo.auditors.map(a => ( + <h3>Auditor {a.url}</h3> + ))} + </div> + ); +} + +function renderReserveCreationDetails(rci: ReserveCreationInfo|null) { + if (!rci) { + return ( + <p> + Details will be displayed when a valid exchange provider URL is entered. + </p> + ); + } + + let denoms = rci.selectedDenoms; + + let countByPub: {[s: string]: number} = {}; + let uniq: DenominationRecord[] = []; + + denoms.forEach((x: DenominationRecord) => { + let c = countByPub[x.denomPub] || 0; + if (c == 0) { + uniq.push(x); + } + c += 1; + countByPub[x.denomPub] = c; + }); + + function row(denom: DenominationRecord) { + return ( + <tr> + <td>{countByPub[denom.denomPub] + "x"}</td> + <td>{amountToPretty(denom.value)}</td> + <td>{amountToPretty(denom.feeWithdraw)}</td> + <td>{amountToPretty(denom.feeRefresh)}</td> + <td>{amountToPretty(denom.feeDeposit)}</td> + </tr> + ); + } + + function wireFee(s: string) { + return [ + <thead> + <tr> + <th colSpan={3}>Wire Method {s}</th> + </tr> + <tr> + <th>Applies Until</th> + <th>Wire Fee</th> + <th>Closing Fee</th> + </tr> + </thead>, + <tbody> + {rci!.wireFees.feesForType[s].map(f => ( + <tr> + <td>{moment.unix(f.endStamp).format("llll")}</td> + <td>{amountToPretty(f.wireFee)}</td> + <td>{amountToPretty(f.closingFee)}</td> + </tr> + ))} + </tbody> + ]; + } + + let withdrawFeeStr = amountToPretty(rci.withdrawFee); + let overheadStr = amountToPretty(rci.overhead); + + return ( + <div> + <h3>Overview</h3> + <p>{i18n.str`Withdrawal fees: ${withdrawFeeStr}`}</p> + <p>{i18n.str`Rounding loss: ${overheadStr}`}</p> + <p>{i18n.str`Earliest expiration (for deposit): ${moment.unix(rci.earliestDepositExpiration).fromNow()}`}</p> + <h3>Coin Fees</h3> + <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> + {uniq.map(row)} + </tbody> + </table> + <h3>Wire Fees</h3> + <table className="pure-table"> + {Object.keys(rci.wireFees.feesForType).map(wireFee)} + </table> + </div> + ); +} + + +function getSuggestedExchange(currency: string): Promise<string> { + // TODO: make this request go to the wallet backend + // Right now, this is a stub. + const defaultExchange: {[s: string]: string} = { + "KUDOS": "https://exchange.demo.taler.net", + "PUDOS": "https://exchange.test.taler.net", + }; + + let exchange = defaultExchange[currency]; + + if (!exchange) { + exchange = "" + } + + return Promise.resolve(exchange); +} + + +function WithdrawFee(props: {reserveCreationInfo: ReserveCreationInfo|null}): JSX.Element { + if (props.reserveCreationInfo) { + let {overhead, withdrawFee} = props.reserveCreationInfo; + let totalCost = Amounts.add(overhead, withdrawFee).amount; + return <p>{i18n.str`Withdraw fees:`} {amountToPretty(totalCost)}</p>; + } + return <p />; +} + + +interface ExchangeSelectionProps { + suggestedExchangeUrl: string; + amount: AmountJson; + callback_url: string; + wt_types: string[]; + currencyRecord: CurrencyRecord|null; +} + +interface ManualSelectionProps { + onSelect(url: string): void; + initialUrl: string; +} + +class ManualSelection extends ImplicitStateComponent<ManualSelectionProps> { + url: StateHolder<string> = this.makeState(""); + errorMessage: StateHolder<string|null> = this.makeState(null); + isOkay: StateHolder<boolean> = this.makeState(false); + updateEvent = new EventTrigger(); + constructor(p: ManualSelectionProps) { + super(p); + this.url(p.initialUrl); + this.update(); + } + render() { + return ( + <div className="pure-g pure-form pure-form-stacked"> + <div className="pure-u-1"> + <label>URL</label> + <input className="url" type="text" spellCheck={false} + value={this.url()} + key="exchange-url-input" + onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)} /> + </div> + <div className="pure-u-1"> + <button className="pure-button button-success" + disabled={!this.isOkay()} + onClick={() => this.props.onSelect(this.url())}> + {i18n.str`Select`} + </button> + {this.errorMessage()} + </div> + </div> + ); + } + + async update() { + this.errorMessage(null); + this.isOkay(false); + if (!this.url()) { + return; + } + let parsedUrl = new URI(this.url()!); + if (parsedUrl.is("relative")) { + this.errorMessage(i18n.str`Error: URL may not be relative`); + this.isOkay(false); + return; + } + try { + let url = canonicalizeBaseUrl(this.url()!); + let r = await getExchangeInfo(url) + console.log("getExchangeInfo returned") + this.isOkay(true); + } catch (e) { + console.log("got error", e); + if (e.hasOwnProperty("httpStatus")) { + this.errorMessage(`Error: request failed with status ${e.httpStatus}`); + } else if (e.hasOwnProperty("errorResponse")) { + let resp = e.errorResponse; + this.errorMessage(`Error: ${resp.error} (${resp.hint})`); + } else { + this.errorMessage("invalid exchange URL"); + } + } + } + + async onUrlChanged(s: string) { + this.url(s); + this.errorMessage(null); + this.isOkay(false); + this.updateEvent.trigger(); + let waited = await this.updateEvent.wait(200); + if (waited) { + // Run the actual update if nobody else preempted us. + this.update(); + } + } +} + + +class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> { + statusString: StateHolder<string|null> = this.makeState(null); + reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState( + null); + url: StateHolder<string|null> = this.makeState(null); + + selectingExchange: StateHolder<boolean> = this.makeState(false); + + constructor(props: ExchangeSelectionProps) { + super(props); + let prefilledExchangesUrls = []; + if (props.currencyRecord) { + let exchanges = props.currencyRecord.exchanges.map((x) => x.baseUrl); + prefilledExchangesUrls.push(...exchanges); + } + if (props.suggestedExchangeUrl) { + prefilledExchangesUrls.push(props.suggestedExchangeUrl); + } + if (prefilledExchangesUrls.length != 0) { + this.url(prefilledExchangesUrls[0]); + this.forceReserveUpdate(); + } else { + this.selectingExchange(true); + } + } + + renderFeeStatus() { + let rci = this.reserveCreationInfo(); + if (rci) { + let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount; + let trustMessage; + if (rci.isTrusted) { + trustMessage = ( + <i18n.Translate wrap="p"> + The exchange is trusted by the wallet. + </i18n.Translate> + ); + } else if (rci.isAudited) { + trustMessage = ( + <i18n.Translate wrap="p"> + The exchange is audited by a trusted auditor. + </i18n.Translate> + ); + } else { + trustMessage = ( + <i18n.Translate wrap="p"> + Warning: The exchange is neither directly trusted nor audited by a trusted auditor. + If you withdraw from this exchange, it will be trusted in the future. + </i18n.Translate> + ); + } + return ( + <div> + <i18n.Translate wrap="p"> + Using exchange provider <strong>{this.url()}</strong>. + The exchange provider will charge + {" "} + <span>{amountToPretty(totalCost)}</span> + {" "} + in fees. + </i18n.Translate> + {trustMessage} + </div> + ); + } + if (this.url() && !this.statusString()) { + let shortName = new URI(this.url()!).host(); + return ( + <i18n.Translate wrap="p"> + Waiting for a response from + {" "} + <em>{shortName}</em> + </i18n.Translate> + ); + } + if (this.statusString()) { + return ( + <p> + <strong style={{color: "red"}}>{i18n.str`A problem occured, see below. ${this.statusString()}`}</strong> + </p> + ); + } + return ( + <p> + {i18n.str`Information about fees will be available when an exchange provider is selected.`} + </p> + ); + } + + renderConfirm() { + return ( + <div> + {this.renderFeeStatus()} + <button className="pure-button button-success" + disabled={this.reserveCreationInfo() == null} + onClick={() => this.confirmReserve()}> + {i18n.str`Accept fees and withdraw`} + </button> + { " " } + <button className="pure-button button-secondary" + onClick={() => this.selectingExchange(true)}> + {i18n.str`Change Exchange Provider`} + </button> + <br/> + <Collapsible initiallyCollapsed={true} title="Fee and Spending Details"> + {renderReserveCreationDetails(this.reserveCreationInfo())} + </Collapsible> + <Collapsible initiallyCollapsed={true} title="Auditor Details"> + {renderAuditorDetails(this.reserveCreationInfo())} + </Collapsible> + </div> + ); + } + + select(url: string) { + this.reserveCreationInfo(null); + this.url(url); + this.selectingExchange(false); + this.forceReserveUpdate(); + } + + renderSelect() { + let exchanges = (this.props.currencyRecord && this.props.currencyRecord.exchanges) || []; + console.log(exchanges); + return ( + <div> + Please select an exchange. You can review the details before after your selection. + + {this.props.suggestedExchangeUrl && ( + <div> + <h2>Bank Suggestion</h2> + <button className="pure-button button-success" onClick={() => this.select(this.props.suggestedExchangeUrl)}> + Select <strong>{this.props.suggestedExchangeUrl}</strong> + </button> + </div> + )} + + {exchanges.length > 0 && ( + <div> + <h2>Known Exchanges</h2> + {exchanges.map(e => ( + <button className="pure-button button-success" onClick={() => this.select(e.baseUrl)}> + Select <strong>{e.baseUrl}</strong> + </button> + ))} + </div> + )} + + <h2>Manual Selection</h2> + <ManualSelection initialUrl={this.url() || ""} onSelect={(url: string) => this.select(url)} /> + </div> + ); + } + + render(): JSX.Element { + return ( + <div> + <i18n.Translate wrap="p"> + {"You are about to withdraw "} + <strong>{amountToPretty(this.props.amount)}</strong> + {" from your bank account into your wallet."} + </i18n.Translate> + {this.selectingExchange() ? this.renderSelect() : this.renderConfirm()} + </div> + ); + } + + + confirmReserve() { + this.confirmReserveImpl(this.reserveCreationInfo()!, + this.url()!, + this.props.amount, + this.props.callback_url); + } + + /** + * Do an update of the reserve creation info, without any debouncing. + */ + async forceReserveUpdate() { + this.reserveCreationInfo(null); + try { + let url = canonicalizeBaseUrl(this.url()!); + let r = await getReserveCreationInfo(url, + this.props.amount); + console.log("get exchange info resolved"); + this.reserveCreationInfo(r); + console.dir(r); + } catch (e) { + console.log("get exchange info rejected", e); + if (e.hasOwnProperty("httpStatus")) { + this.statusString(`Error: request failed with status ${e.httpStatus}`); + } else if (e.hasOwnProperty("errorResponse")) { + let resp = e.errorResponse; + this.statusString(`Error: ${resp.error} (${resp.hint})`); + } + } + } + + confirmReserveImpl(rci: ReserveCreationInfo, + exchange: string, + amount: AmountJson, + callback_url: string) { + const d = {exchange: canonicalizeBaseUrl(exchange), amount}; + const cb = (rawResp: any) => { + if (!rawResp) { + throw Error("empty response"); + } + // FIXME: filter out types that bank/exchange don't have in common + let wireDetails = rci.wireInfo; + let filteredWireDetails: any = {}; + for (let wireType in wireDetails) { + if (this.props.wt_types.findIndex((x) => x.toLowerCase() == wireType.toLowerCase()) < 0) { + continue; + } + let obj = Object.assign({}, wireDetails[wireType]); + // The bank doesn't need to know about fees + delete obj.fees; + // Consequently the bank can't verify signatures anyway, so + // we delete this extra data, to make the request URL shorter. + delete obj.salt; + delete obj.sig; + filteredWireDetails[wireType] = obj; + } + if (!rawResp.error) { + const resp = CreateReserveResponse.checked(rawResp); + let q: {[name: string]: string|number} = { + wire_details: JSON.stringify(filteredWireDetails), + exchange: resp.exchange, + reserve_pub: resp.reservePub, + amount_value: amount.value, + amount_fraction: amount.fraction, + amount_currency: amount.currency, + }; + let url = new URI(callback_url).addQuery(q); + if (!url.is("absolute")) { + throw Error("callback url is not absolute"); + } + console.log("going to", url.href()); + document.location.href = url.href(); + } else { + this.statusString( + i18n.str`Oops, something went wrong. The wallet responded with error status (${rawResp.error}).`); + } + }; + chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb); + } + + renderStatus(): any { + if (this.statusString()) { + return <p><strong style={{color: "red"}}>{this.statusString()}</strong></p>; + } else if (!this.reserveCreationInfo()) { + return <p>{i18n.str`Checking URL, please wait ...`}</p>; + } + return ""; + } +} + +export async function main() { + try { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + let amount; + try { + amount = AmountJson.checked(JSON.parse(query.amount)); + } catch (e) { + throw Error(i18n.str`Can't parse amount: ${e.message}`); + } + const callback_url = query.callback_url; + const bank_url = query.bank_url; + let wt_types; + try { + wt_types = JSON.parse(query.wt_types); + } catch (e) { + throw Error(i18n.str`Can't parse wire_types: ${e.message}`); + } + + let suggestedExchangeUrl = query.suggested_exchange_url; + let currencyRecord = await getCurrency(amount.currency); + + let args = { + wt_types, + suggestedExchangeUrl, + callback_url, + amount, + currencyRecord, + }; + + ReactDOM.render(<ExchangeSelection {...args} />, document.getElementById( + "exchange-selection")!); + + } catch (e) { + // TODO: provide more context information, maybe factor it out into a + // TODO:generic error reporting function or component. + document.body.innerText = i18n.str`Fatal error: "${e.message}".`; + console.error(`got error "${e.message}"`, e); + } +} + +document.addEventListener("DOMContentLoaded", () => { + main(); +}); diff --git a/src/webex/pages/error.html b/src/webex/pages/error.html new file mode 100644 index 000000000..51a8fd73a --- /dev/null +++ b/src/webex/pages/error.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Error Occured</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/error-bundle.js"></script> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx new file mode 100644 index 000000000..f278bd224 --- /dev/null +++ b/src/webex/pages/error.tsx @@ -0,0 +1,63 @@ +/* + 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 {ImplicitStateComponent, StateHolder} from "../components"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +"use strict"; + +interface ErrorProps { + message: string; +} + +class ErrorView extends React.Component<ErrorProps, void> { + render(): JSX.Element { + return ( + <div> + An error occurred: {this.props.message} + </div> + ); + } +} + +export async function main() { + try { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + + const message: string = query.message || "unknown error"; + + ReactDOM.render(<ErrorView message={message} />, document.getElementById( + "container")!); + + } catch (e) { + // TODO: provide more context information, maybe factor it out into a + // TODO:generic error reporting function or component. + document.body.innerText = `Fatal error: "${e.message}".`; + console.error(`got error "${e.message}"`, e); + } +} diff --git a/src/webex/pages/help/empty-wallet.html b/src/webex/pages/help/empty-wallet.html new file mode 100644 index 000000000..dd29d9689 --- /dev/null +++ b/src/webex/pages/help/empty-wallet.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>GNU Taler Help - Empty Wallet</title> + <link rel="icon" href="/img/icon.png"> + <meta name="description" content=""> + <link rel="stylesheet" type="text/css" href="/src/style/wallet.css"> + </head> + <body> + <div class="container" id="main"> + <div class="row"> + <div class="col-lg-12"> + <h2 lang="en">Your wallet is empty!</h2> + <p lang="en">You have succeeded with installing the Taler wallet. However, before + you can buy articles using the Taler wallet, you must withdraw electronic coins. + This is typically done by visiting your bank's online banking Web site. There, + you instruct your bank to transfer the funds to a Taler exchange operator. In + return, your wallet will be allowed to withdraw electronic coins.</p> + <p lang="en">At this stage, we are not aware of any regular exchange operators issuing + coins in well-known currencies. However, to see how Taler would work, you + can visit our "fake" bank at + <a href="https://bank.demo.taler.net/">bank.demo.taler.net</a> to + withdraw coins in the "KUDOS" currency that we created just for + demonstrating the system.</p> + </div> + </div> + </div> + </body> +</html> diff --git a/src/webex/pages/logs.html b/src/webex/pages/logs.html new file mode 100644 index 000000000..9545269e3 --- /dev/null +++ b/src/webex/pages/logs.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Logs</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/logs-bundle.js"></script> + + <style> + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/logs.tsx b/src/webex/pages/logs.tsx new file mode 100644 index 000000000..0c533bfa8 --- /dev/null +++ b/src/webex/pages/logs.tsx @@ -0,0 +1,83 @@ +/* + 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/> + */ + +/** + * Show wallet logs. + * + * @author Florian Dold + */ + +import {LogEntry, getLogs} from "../../logging"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface LogViewProps { + log: LogEntry; +} + +class LogView extends React.Component<LogViewProps, void> { + render(): JSX.Element { + let e = this.props.log; + return ( + <div className="tree-item"> + <ul> + <li>level: {e.level}</li> + <li>msg: {e.msg}</li> + <li>id: {e.id || "unknown"}</li> + <li>file: {e.source || "(unknown)"}</li> + <li>line: {e.line || "(unknown)"}</li> + <li>col: {e.col || "(unknown)"}</li> + {(e.detail ? <li> detail: <pre>{e.detail}</pre></li> : [])} + </ul> + </div> + ); + } +} + +interface LogsState { + logs: LogEntry[]|undefined; +} + +class Logs extends React.Component<any, LogsState> { + constructor() { + super(); + this.update(); + this.state = {} as any; + } + + async update() { + let logs = await getLogs(); + this.setState({logs}); + } + + render(): JSX.Element { + let logs = this.state.logs; + if (!logs) { + return <span>...</span>; + } + return ( + <div className="tree-item"> + Logs: + {logs.map(e => <LogView log={e} />)} + </div> + ); + } +} + +document.addEventListener("DOMContentLoaded", () => { + ReactDOM.render(<Logs />, document.getElementById("container")!); +}); diff --git a/src/webex/pages/payback.html b/src/webex/pages/payback.html new file mode 100644 index 000000000..d6fe334c8 --- /dev/null +++ b/src/webex/pages/payback.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Payback</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/payback-bundle.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> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/payback.tsx b/src/webex/pages/payback.tsx new file mode 100644 index 000000000..7bcc581d8 --- /dev/null +++ b/src/webex/pages/payback.tsx @@ -0,0 +1,100 @@ +/* + 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 + */ + + +import { amountToPretty, getTalerStampDate } from "../../helpers"; +import { + ExchangeRecord, + ExchangeForCurrencyRecord, + DenominationRecord, + AuditorRecord, + CurrencyRecord, + ReserveRecord, + CoinRecord, + PreCoinRecord, + Denomination, + WalletBalance, +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getCurrencies, + updateCurrency, + getPaybackReserves, + withdrawPaybackReserve, +} from "../wxApi"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +class Payback extends ImplicitStateComponent<any> { + reserves: StateHolder<ReserveRecord[]|null> = this.makeState(null); + constructor() { + super(); + let port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + } + + async update() { + let reserves = await getPaybackReserves(); + this.reserves(reserves); + } + + withdrawPayback(pub: string) { + withdrawPaybackReserve(pub); + } + + render(): JSX.Element { + let reserves = this.reserves(); + if (!reserves) { + return <span>loading ...</span>; + } + if (reserves.length == 0) { + return <span>No reserves with payback available.</span>; + } + return ( + <div> + {reserves.map(r => ( + <div> + <h2>Reserve for ${amountToPretty(r.current_amount!)}</h2> + <ul> + <li>Exchange: ${r.exchange_base_url}</li> + </ul> + <button onClick={() => this.withdrawPayback(r.reserve_pub)}>Withdraw again</button> + </div> + ))} + </div> + ); + } +} + +export function main() { + ReactDOM.render(<Payback />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/pages/popup.css b/src/webex/pages/popup.css new file mode 100644 index 000000000..675412c11 --- /dev/null +++ b/src/webex/pages/popup.css @@ -0,0 +1,84 @@ + +/** + * @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; +} + +.nav { + background-color: #ddd; + padding: 0.5em 0; +} + +.nav a { + color: black; + padding: 0.5em; + text-decoration: none; +} + +.nav a.active { + background-color: white; + 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 { + border: 1px solid black; + border-radius: 10px; + padding-left: 0.5em; + margin: 0.5em; +} + +.historyDate { + font-size: 90%; + margin: 0.3em; + color: slategray; +} diff --git a/src/webex/pages/popup.html b/src/webex/pages/popup.html new file mode 100644 index 000000000..98f24bccc --- /dev/null +++ b/src/webex/pages/popup.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + <link rel="stylesheet" type="text/css" href="popup.css"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/popup-bundle.js"></script> +</head> + +<body> + <div id="content" style="margin:0;padding:0"></div> +</body> + +</html> diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx new file mode 100644 index 000000000..a806cfef9 --- /dev/null +++ b/src/webex/pages/popup.tsx @@ -0,0 +1,548 @@ +/* + 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 + */ + + +"use strict"; + +import { + AmountJson, + Amounts, + WalletBalance, + WalletBalanceEntry +} from "../../types"; +import { HistoryRecord, HistoryLevel } from "../../wallet"; +import { amountToPretty } from "../../helpers"; +import * as i18n from "../../i18n"; + +import { abbrev } from "../renderHtml"; + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +function onUpdateNotification(f: () => void): () => void { + let port = chrome.runtime.connect({name: "notifications"}); + let listener = (msg: any, port: any) => { + f(); + }; + port.onMessage.addListener(listener); + return () => { + port.onMessage.removeListener(listener); + } +} + + +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 () => { + let i = Router.routeHandlers.indexOf(f); + this.routeHandlers = this.routeHandlers.splice(i, 1); + } + } + + static routeHandlers: any[] = []; + + componentWillMount() { + console.log("router mounted"); + window.onhashchange = () => { + this.setState({}); + for (let f of Router.routeHandlers) { + f(); + } + } + } + + componentWillUnmount() { + console.log("router unmounted"); + } + + + render(): JSX.Element { + let 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) => { + let childProps: any = (child as any).props; + if (!childProps) { + return; + } + if (childProps["default"]) { + defaultChild = child; + } + if (childProps["route"] == route) { + foundChild = child; + } + }) + let child: React.ReactChild | null = foundChild || defaultChild; + if (!child) { + throw Error("unknown route"); + } + Router.setRoute((child as any).props["route"]); + return <div>{child}</div>; + } +} + + +interface TabProps { + target: string; + children?: React.ReactNode; +} + +function Tab(props: TabProps) { + let cssClass = ""; + if (props.target == Router.getRoute()) { + cssClass = "active"; + } + let onClick = (e: React.MouseEvent<HTMLAnchorElement>) => { + 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> { + cancelSubscription: any; + + componentWillMount() { + this.cancelSubscription = Router.onRoute(() => { + this.setState({}); + }); + } + + componentWillUnmount() { + if (this.cancelSubscription) { + this.cancelSubscription(); + } + } + + render() { + 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="/debug"> + {i18n.str`Debug`} + </Tab> + </div>); + } +} + + +function ExtensionLink(props: any) { + let onClick = (e: React.MouseEvent<HTMLAnchorElement>) => { + chrome.tabs.create({ + "url": chrome.extension.getURL(props.target) + }); + e.preventDefault(); + }; + return ( + <a onClick={onClick} href={props.target}> + {props.children} + </a>) +} + + +export function bigAmount(amount: AmountJson): JSX.Element { + let v = amount.value + amount.fraction / Amounts.fractionalBase; + return ( + <span> + <span style={{fontSize: "300%"}}>{v}</span> + {" "} + <span>{amount.currency}</span> + </span> + ); +} + +class WalletBalanceView extends React.Component<any, any> { + balance: WalletBalance; + gotError = false; + canceler: (() => void) | undefined = undefined; + unmount = false; + + componentWillMount() { + this.canceler = onUpdateNotification(() => this.updateBalance()); + this.updateBalance(); + } + + componentWillUnmount() { + console.log("component WalletBalanceView will unmount"); + if (this.canceler) { + this.canceler(); + } + this.unmount = true; + } + + updateBalance() { + chrome.runtime.sendMessage({type: "balances"}, (resp) => { + if (this.unmount) { + return; + } + if (resp.error) { + this.gotError = true; + console.error("could not retrieve balances", resp); + this.setState({}); + return; + } + this.gotError = false; + console.log("got wallet", resp); + this.balance = resp; + this.setState({}); + }); + } + + renderEmpty(): JSX.Element { + let helpLink = ( + <ExtensionLink target="/src/pages/help/empty-wallet.html"> + {i18n.str`help`} + </ExtensionLink> + ); + return ( + <div> + <i18n.Translate wrap="p"> + You have no balance to show. Need some + {" "}<span>{helpLink}</span>{" "} + getting started? + </i18n.Translate> + </div> + ); + } + + formatPending(entry: WalletBalanceEntry): JSX.Element { + let incoming: JSX.Element | undefined; + let payment: JSX.Element | undefined; + + console.log("available: ", entry.pendingIncoming ? amountToPretty(entry.available) : null); + console.log("incoming: ", entry.pendingIncoming ? amountToPretty(entry.pendingIncoming) : null); + + if (Amounts.isNonZero(entry.pendingIncoming)) { + incoming = ( + <i18n.Translate wrap="span"> + <span style={{color: "darkgreen"}}> + {"+"} + {amountToPretty(entry.pendingIncoming)} + </span> + {" "} + incoming + </i18n.Translate> + ); + } + + if (Amounts.isNonZero(entry.pendingPayment)) { + payment = ( + <i18n.Translate wrap="span"> + <span style={{color: "darkblue"}}> + {amountToPretty(entry.pendingPayment)} + </span> + {" "} + being spent + </i18n.Translate> + ); + } + + let 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 { + let wallet = this.balance; + if (this.gotError) { + return i18n.str`Error: could not retrieve balance information.`; + } + if (!wallet) { + return <span></span>; + } + console.log(wallet); + let paybackAvailable = false; + let listing = Object.keys(wallet).map((key) => { + let entry: WalletBalanceEntry = wallet[key]; + if (entry.paybackAmount.value != 0 || entry.paybackAmount.fraction != 0) { + paybackAvailable = true; + } + return ( + <p> + {bigAmount(entry.available)} + {" "} + {this.formatPending(entry)} + </p> + ); + }); + let link = chrome.extension.getURL("/src/pages/auditors.html"); + let linkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>; + let paybackLink = chrome.extension.getURL("/src/pages/payback.html"); + let paybackLinkElem = <a className="actionLink" href={link} target="_blank">Trusted Auditors and Exchanges</a>; + return ( + <div> + {listing.length > 0 ? listing : this.renderEmpty()} + {paybackAvailable && paybackLinkElem} + {linkElem} + </div> + ); + } +} + + +function formatHistoryItem(historyItem: HistoryRecord) { + const d = historyItem.detail; + const t = historyItem.timestamp; + console.log("hist item", historyItem); + switch (historyItem.type) { + case "create-reserve": + return ( + <i18n.Translate wrap="p"> + Bank requested reserve (<span>{abbrev(d.reservePub)}</span>) for <span>{amountToPretty(d.requestedAmount)}</span>. + </i18n.Translate> + ); + case "confirm-reserve": { + // FIXME: eventually remove compat fix + let exchange = d.exchangeBaseUrl ? (new URI(d.exchangeBaseUrl)).host() : "??"; + let pub = abbrev(d.reservePub); + return ( + <i18n.Translate wrap="p"> + Started to withdraw + {" "}{amountToPretty(d.requestedAmount)}{" "} + from <span>{exchange}</span> (<span>{pub}</span>). + </i18n.Translate> + ); + } + case "offer-contract": { + let link = chrome.extension.getURL("view-contract.html"); + let linkElem = <a href={link}>{abbrev(d.contractHash)}</a>; + let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>; + return ( + <i18n.Translate wrap="p"> + Merchant <em>{abbrev(d.merchantName, 15)}</em> offered contract <a href={link}>{abbrev(d.contractHash)}</a>; + </i18n.Translate> + ); + } + case "depleted-reserve": { + let exchange = d.exchangeBaseUrl ? (new URI(d.exchangeBaseUrl)).host() : "??"; + let amount = amountToPretty(d.requestedAmount); + let pub = abbrev(d.reservePub); + return ( + <i18n.Translate wrap="p"> + Withdrew <span>{amount}</span> from <span>{exchange}</span> (<span>{pub}</span>). + </i18n.Translate> + ); + } + case "pay": { + let url = d.fulfillmentUrl; + let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>; + let fulfillmentLinkElem = <a href={url} onClick={openTab(url)}>view product</a>; + return ( + <i18n.Translate wrap="p"> + Paid <span>{amountToPretty(d.amount)}</span> to merchant <span>{merchantElem}</span>. (<span>{fulfillmentLinkElem}</span>) + </i18n.Translate> + ); + } + default: + return (<p>{i18n.str`Unknown event (${historyItem.type})`}</p>); + } +} + + +class WalletHistory extends React.Component<any, any> { + myHistory: any[]; + gotError = false; + unmounted = false; + + componentWillMount() { + this.update(); + onUpdateNotification(() => this.update()); + } + + componentWillUnmount() { + console.log("history component unmounted"); + this.unmounted = true; + } + + update() { + chrome.runtime.sendMessage({type: "get-history"}, (resp) => { + if (this.unmounted) { + return; + } + console.log("got history response"); + if (resp.error) { + this.gotError = true; + console.error("could not retrieve history", resp); + this.setState({}); + return; + } + this.gotError = false; + console.log("got history", resp.history); + this.myHistory = resp.history; + this.setState({}); + }); + } + + render(): JSX.Element { + console.log("rendering history"); + let history: HistoryRecord[] = this.myHistory; + if (this.gotError) { + return i18n.str`Error: could not retrieve event history`; + } + + if (!history) { + // We're not ready yet + return <span />; + } + + let subjectMemo: {[s: string]: boolean} = {}; + let listing: any[] = []; + for (let record of history.reverse()) { + if (record.subjectId && subjectMemo[record.subjectId]) { + continue; + } + if (record.level != undefined && record.level < HistoryLevel.User) { + continue; + } + subjectMemo[record.subjectId as string] = true; + + let item = ( + <div className="historyItem"> + <div className="historyDate"> + {(new Date(record.timestamp)).toString()} + </div> + {formatHistoryItem(record)} + </div> + ); + + listing.push(item); + } + + if (listing.length > 0) { + return <div className="container">{listing}</div>; + } + return <p>{i18n.str`Your wallet has no events recorded.`}</p> + } + +} + + +function reload() { + try { + chrome.runtime.reload(); + window.close(); + } catch (e) { + // Functionality missing in firefox, ignore! + } +} + +function confirmReset() { + if (confirm("Do you want to IRREVOCABLY DESTROY everything inside your" + + " wallet and LOSE ALL YOUR COINS?")) { + chrome.runtime.sendMessage({type: "reset"}); + window.close(); + } +} + + +function WalletDebug(props: any) { + return (<div> + <p>Debug tools:</p> + <button onClick={openExtensionPage("/src/pages/popup.html")}> + wallet tab + </button> + <button onClick={openExtensionPage("/src/pages/show-db.html")}> + show db + </button> + <button onClick={openExtensionPage("/src/pages/tree.html")}> + show tree + </button> + <button onClick={openExtensionPage("/src/pages/logs.html")}> + show logs + </button> + <br /> + <button onClick={confirmReset}> + reset + </button> + <button onClick={reload}> + reload chrome extension + </button> + </div>); +} + + +function openExtensionPage(page: string) { + return function() { + chrome.tabs.create({ + "url": chrome.extension.getURL(page) + }); + } +} + + +function openTab(page: string) { + return function() { + chrome.tabs.create({ + "url": page + }); + } +} + + +let el = ( + <div> + <WalletNavBar /> + <div style={{margin: "1em"}}> + <Router> + <WalletBalanceView route="/balance" default/> + <WalletHistory route="/history"/> + <WalletDebug route="/debug"/> + </Router> + </div> + </div> +); + +document.addEventListener("DOMContentLoaded", () => { + ReactDOM.render(el, document.getElementById("content")!); +}) diff --git a/src/webex/pages/show-db.html b/src/webex/pages/show-db.html new file mode 100644 index 000000000..215c726d9 --- /dev/null +++ b/src/webex/pages/show-db.html @@ -0,0 +1,18 @@ +<!doctype html> +<html> + <head> + <meta charset="UTF-8"> + <title>Taler Wallet: Reserve Created</title> + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + <link rel="icon" href="/img/icon.png"> + <script src="/dist/page-common.js"></script> + <script src="/dist/show-db-bundle.js"></script> + </head> + <body> + <h1>DB Dump</h1> + <input type="file" id="fileInput" style="display:none"> + <button id="import">Import Dump</button> + <button id="download">Download Dump</button> + <pre id="dump"></pre> + </body> +</html> diff --git a/src/webex/pages/show-db.ts b/src/webex/pages/show-db.ts new file mode 100644 index 000000000..d95951385 --- /dev/null +++ b/src/webex/pages/show-db.ts @@ -0,0 +1,94 @@ +/* + 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/> + */ + + +/** + * Wallet database dump for debugging. + * + * @author Florian Dold + */ + +function replacer(match: string, pIndent: string, pKey: string, pVal: string, + pEnd: string) { + const key = "<span class=json-key>"; + const val = "<span class=json-value>"; + const str = "<span class=json-string>"; + let r = pIndent || ""; + if (pKey) { + r = r + key + '"' + pKey.replace(/[": ]/g, "") + '":</span> '; + } + if (pVal) { + r = r + (pVal[0] === '"' ? str : val) + pVal + "</span>"; + } + return r + (pEnd || ""); +} + + +function prettyPrint(obj: any) { + const jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg; + return JSON.stringify(obj, null as any, 3) + .replace(/&/g, "&").replace(/\\"/g, """) + .replace(/</g, "<").replace(/>/g, ">") + .replace(jsonLine, replacer); +} + + +document.addEventListener("DOMContentLoaded", () => { + chrome.runtime.sendMessage({type: "dump-db"}, (resp) => { + const el = document.getElementById("dump"); + if (!el) { + throw Error(); + } + el.innerHTML = prettyPrint(resp); + + document.getElementById("download")!.addEventListener("click", (evt) => { + console.log("creating download"); + const element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(JSON.stringify(resp))); + element.setAttribute("download", "wallet-dump.txt"); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + }); + + }); + + + const fileInput = document.getElementById("fileInput")! as HTMLInputElement; + fileInput.onchange = (evt) => { + if (!fileInput.files || fileInput.files.length !== 1) { + alert("please select exactly one file to import"); + return; + } + const file = fileInput.files[0]; + const fr = new FileReader(); + fr.onload = (e: any) => { + console.log("got file"); + const dump = JSON.parse(e.target.result); + console.log("parsed contents", dump); + chrome.runtime.sendMessage({ type: "import-db", detail: { dump } }, (resp) => { + alert("loaded"); + }); + }; + console.log("reading file", file); + fr.readAsText(file); + }; + + document.getElementById("import")!.addEventListener("click", (evt) => { + fileInput.click(); + evt.preventDefault(); + }); +}); diff --git a/src/webex/pages/tree.html b/src/webex/pages/tree.html new file mode 100644 index 000000000..0c0a368b3 --- /dev/null +++ b/src/webex/pages/tree.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8"> + <title>Taler Wallet: Tree View</title> + + <link rel="stylesheet" type="text/css" href="../style/wallet.css"> + + <link rel="icon" href="/img/icon.png"> + + <script src="/dist/page-common-bundle.js"></script> + <script src="/dist/tree-bundle.js"></script> + + <style> + .tree-item { + margin: 2em; + border-radius: 5px; + border: 1px solid gray; + padding: 1em; + } + </style> + + <body> + <div id="container"></div> + </body> +</html> diff --git a/src/webex/pages/tree.tsx b/src/webex/pages/tree.tsx new file mode 100644 index 000000000..ddf8f2dbc --- /dev/null +++ b/src/webex/pages/tree.tsx @@ -0,0 +1,437 @@ +/* + 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/> + */ + +/** + * Show contents of the wallet as a tree. + * + * @author Florian Dold + */ + + +import { amountToPretty, getTalerStampDate } from "../../helpers"; +import { + CoinRecord, + CoinStatus, + Denomination, + DenominationRecord, + ExchangeRecord, + PreCoinRecord, + ReserveRecord, +} from "../../types"; + +import { ImplicitStateComponent, StateHolder } from "../components"; +import { + getReserves, getExchanges, getCoins, getPreCoins, + refresh, getDenoms, payback, +} from "../wxApi"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +interface ReserveViewProps { + reserve: ReserveRecord; +} + +class ReserveView extends React.Component<ReserveViewProps, void> { + render(): JSX.Element { + let r: ReserveRecord = this.props.reserve; + return ( + <div className="tree-item"> + <ul> + <li>Key: {r.reserve_pub}</li> + <li>Created: {(new Date(r.created * 1000).toString())}</li> + <li>Current: {r.current_amount ? amountToPretty(r.current_amount!) : "null"}</li> + <li>Requested: {amountToPretty(r.requested_amount)}</li> + <li>Confirmed: {r.confirmed}</li> + </ul> + </div> + ); + } +} + +interface ReserveListProps { + exchangeBaseUrl: string; +} + +interface ToggleProps { + expanded: StateHolder<boolean>; +} + +class Toggle extends ImplicitStateComponent<ToggleProps> { + renderButton() { + let show = () => { + this.props.expanded(true); + this.setState({}); + }; + let hide = () => { + this.props.expanded(false); + this.setState({}); + }; + if (this.props.expanded()) { + return <button onClick={hide}>hide</button>; + } + return <button onClick={show}>show</button>; + + } + render() { + return ( + <div style={{display: "inline"}}> + {this.renderButton()} + {this.props.expanded() ? this.props.children : []} + </div>); + } +} + + +interface CoinViewProps { + coin: CoinRecord; +} + +interface RefreshDialogProps { + coin: CoinRecord; +} + +class RefreshDialog extends ImplicitStateComponent<RefreshDialogProps> { + refreshRequested = this.makeState<boolean>(false); + render(): JSX.Element { + if (!this.refreshRequested()) { + return ( + <div style={{display: "inline"}}> + <button onClick={() => this.refreshRequested(true)}>refresh</button> + </div> + ); + } + return ( + <div> + Refresh amount: <input type="text" size={10} /> + <button onClick={() => refresh(this.props.coin.coinPub)}>ok</button> + <button onClick={() => this.refreshRequested(false)}>cancel</button> + </div> + ); + } +} + +class CoinView extends React.Component<CoinViewProps, void> { + render() { + let c = this.props.coin; + return ( + <div className="tree-item"> + <ul> + <li>Key: {c.coinPub}</li> + <li>Current amount: {amountToPretty(c.currentAmount)}</li> + <li>Denomination: <ExpanderText text={c.denomPub} /></li> + <li>Suspended: {(c.suspended || false).toString()}</li> + <li>Status: {CoinStatus[c.status]}</li> + <li><RefreshDialog coin={c} /></li> + <li><button onClick={() => payback(c.coinPub)}>Payback</button></li> + </ul> + </div> + ); + } +} + + + +interface PreCoinViewProps { + precoin: PreCoinRecord; +} + +class PreCoinView extends React.Component<PreCoinViewProps, void> { + render() { + let c = this.props.precoin; + return ( + <div className="tree-item"> + <ul> + <li>Key: {c.coinPub}</li> + </ul> + </div> + ); + } +} + +interface CoinListProps { + exchangeBaseUrl: string; +} + +class CoinList extends ImplicitStateComponent<CoinListProps> { + coins = this.makeState<CoinRecord[] | null>(null); + expanded = this.makeState<boolean>(false); + + constructor(props: CoinListProps) { + super(props); + this.update(props); + } + + async update(props: CoinListProps) { + let coins = await getCoins(props.exchangeBaseUrl); + this.coins(coins); + } + + componentWillReceiveProps(newProps: CoinListProps) { + this.update(newProps); + } + + render(): JSX.Element { + if (!this.coins()) { + return <div>...</div>; + } + return ( + <div className="tree-item"> + Coins ({this.coins() !.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {this.coins() !.map((c) => <CoinView coin={c} />)} + </Toggle> + </div> + ); + } +} + + +interface PreCoinListProps { + exchangeBaseUrl: string; +} + +class PreCoinList extends ImplicitStateComponent<PreCoinListProps> { + precoins = this.makeState<PreCoinRecord[] | null>(null); + expanded = this.makeState<boolean>(false); + + constructor(props: PreCoinListProps) { + super(props); + this.update(); + } + + async update() { + let precoins = await getPreCoins(this.props.exchangeBaseUrl); + this.precoins(precoins); + } + + render(): JSX.Element { + if (!this.precoins()) { + return <div>...</div>; + } + return ( + <div className="tree-item"> + Planchets ({this.precoins() !.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {this.precoins() !.map((c) => <PreCoinView precoin={c} />)} + </Toggle> + </div> + ); + } +} + +interface DenominationListProps { + exchange: ExchangeRecord; +} + +interface ExpanderTextProps { + text: string; +} + +class ExpanderText extends ImplicitStateComponent<ExpanderTextProps> { + expanded = this.makeState<boolean>(false); + textArea: any = undefined; + + componentDidUpdate() { + if (this.expanded() && this.textArea) { + this.textArea.focus(); + this.textArea.scrollTop = 0; + } + } + + render(): JSX.Element { + if (!this.expanded()) { + return ( + <span onClick={() => { this.expanded(true); }}> + {(this.props.text.length <= 10) + ? this.props.text + : ( + <span> + {this.props.text.substring(0,10)} + <span style={{textDecoration: "underline"}}>...</span> + </span> + ) + } + </span> + ); + } + return ( + <textarea + readOnly + style={{display: "block"}} + onBlur={() => this.expanded(false)} + ref={(e) => this.textArea = e}> + {this.props.text} + </textarea> + ); + } +} + +class DenominationList extends ImplicitStateComponent<DenominationListProps> { + expanded = this.makeState<boolean>(false); + denoms = this.makeState<undefined|DenominationRecord[]>(undefined); + + constructor(props: DenominationListProps) { + super(props); + this.update(); + } + + async update() { + let d = await getDenoms(this.props.exchange.baseUrl); + this.denoms(d); + } + + renderDenom(d: DenominationRecord) { + return ( + <div className="tree-item"> + <ul> + <li>Offered: {d.isOffered ? "yes" : "no"}</li> + <li>Value: {amountToPretty(d.value)}</li> + <li>Withdraw fee: {amountToPretty(d.feeWithdraw)}</li> + <li>Refresh fee: {amountToPretty(d.feeRefresh)}</li> + <li>Deposit fee: {amountToPretty(d.feeDeposit)}</li> + <li>Refund fee: {amountToPretty(d.feeRefund)}</li> + <li>Start: {getTalerStampDate(d.stampStart)!.toString()}</li> + <li>Withdraw expiration: {getTalerStampDate(d.stampExpireWithdraw)!.toString()}</li> + <li>Legal expiration: {getTalerStampDate(d.stampExpireLegal)!.toString()}</li> + <li>Deposit expiration: {getTalerStampDate(d.stampExpireDeposit)!.toString()}</li> + <li>Denom pub: <ExpanderText text={d.denomPub} /></li> + </ul> + </div> + ); + } + + render(): JSX.Element { + let denoms = this.denoms() + if (!denoms) { + return ( + <div className="tree-item"> + Denominations (...) + {" "} + <Toggle expanded={this.expanded}> + ... + </Toggle> + </div> + ); + } + return ( + <div className="tree-item"> + Denominations ({denoms.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {denoms.map((d) => this.renderDenom(d))} + </Toggle> + </div> + ); + } +} + +class ReserveList extends ImplicitStateComponent<ReserveListProps> { + reserves = this.makeState<ReserveRecord[] | null>(null); + expanded = this.makeState<boolean>(false); + + constructor(props: ReserveListProps) { + super(props); + this.update(); + } + + async update() { + let reserves = await getReserves(this.props.exchangeBaseUrl); + this.reserves(reserves); + } + + render(): JSX.Element { + if (!this.reserves()) { + return <div>...</div>; + } + return ( + <div className="tree-item"> + Reserves ({this.reserves() !.length.toString()}) + {" "} + <Toggle expanded={this.expanded}> + {this.reserves() !.map((r) => <ReserveView reserve={r} />)} + </Toggle> + </div> + ); + } +} + +interface ExchangeProps { + exchange: ExchangeRecord; +} + +class ExchangeView extends React.Component<ExchangeProps, void> { + render(): JSX.Element { + let e = this.props.exchange; + return ( + <div className="tree-item"> + <ul> + <li>Exchange Base Url: {this.props.exchange.baseUrl}</li> + <li>Master public key: <ExpanderText text={this.props.exchange.masterPublicKey} /></li> + </ul> + <DenominationList exchange={e} /> + <ReserveList exchangeBaseUrl={this.props.exchange.baseUrl} /> + <CoinList exchangeBaseUrl={this.props.exchange.baseUrl} /> + <PreCoinList exchangeBaseUrl={this.props.exchange.baseUrl} /> + </div> + ); + } +} + +interface ExchangesListState { + exchanges?: ExchangeRecord[]; +} + +class ExchangesList extends React.Component<any, ExchangesListState> { + constructor() { + super(); + let port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + this.update(); + this.state = {} as any; + } + + async update() { + let exchanges = await getExchanges(); + console.log("exchanges: ", exchanges); + this.setState({ exchanges }); + } + + render(): JSX.Element { + let exchanges = this.state.exchanges; + if (!exchanges) { + return <span>...</span>; + } + return ( + <div className="tree-item"> + Exchanges ({exchanges.length.toString()}): + {exchanges.map(e => <ExchangeView exchange={e} />)} + </div> + ); + } +} + +export function main() { + ReactDOM.render(<ExchangesList />, document.getElementById("container")!); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx new file mode 100644 index 000000000..440cd5789 --- /dev/null +++ b/src/webex/renderHtml.tsx @@ -0,0 +1,79 @@ +/* + 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, + Contract, +} from "../types"; +import * as i18n from "../i18n"; +import { amountToPretty } from "../helpers"; + +import * as React from "react"; + + +export function renderContract(contract: Contract): JSX.Element { + let merchantName; + if (contract.merchant && contract.merchant.name) { + merchantName = <strong>{contract.merchant.name}</strong>; + } else { + merchantName = <strong>(pub: {contract.merchant_pub})</strong>; + } + let amount = <strong>{amountToPretty(contract.amount)}</strong>; + + return ( + <div> + <i18n.Translate wrap="p"> + The merchant <span>{merchantName}</span> + wants to enter a contract over <span>{amount}</span>{" "} + with you. + </i18n.Translate> + <p>{i18n.str`You are about to purchase:`}</p> + <ul> + {contract.products.map( + (p: any, i: number) => (<li key={i}>{`${p.description}: ${amountToPretty(p.price)}`}</li>)) + } + </ul> + </div> + ); +} + + +/** + * Abbreviate a string to a given length, and show the full + * string on hover as a tooltip. + */ +export function abbrev(s: string, n: number = 5) { + let sAbbrev = s; + if (s.length > n) { + sAbbrev = s.slice(0, n) + ".."; + } + return ( + <span className="abbrev" title={s}> + {sAbbrev} + </span> + ); +} diff --git a/src/webex/style/pure.css b/src/webex/style/pure.css new file mode 100644 index 000000000..739113970 --- /dev/null +++ b/src/webex/style/pure.css @@ -0,0 +1,1508 @@ +/*! +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.5000%; + *width: 12.4690%; +} + +.pure-u-1-6, +.pure-u-4-24 { + width: 16.6667%; + *width: 16.6357%; +} + +.pure-u-1-5 { + width: 20%; + *width: 19.9690%; +} + +.pure-u-5-24 { + width: 20.8333%; + *width: 20.8023%; +} + +.pure-u-1-4, +.pure-u-6-24 { + width: 25%; + *width: 24.9690%; +} + +.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.5000%; + *width: 37.4690%; +} + +.pure-u-2-5 { + width: 40%; + *width: 39.9690%; +} + +.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.9690%; +} + +.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.9690%; +} + +.pure-u-5-8, +.pure-u-15-24 { + width: 62.5000%; + *width: 62.4690%; +} + +.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.9690%; +} + +.pure-u-19-24 { + width: 79.1667%; + *width: 79.1357%; +} + +.pure-u-4-5 { + width: 80%; + *width: 79.9690%; +} + +.pure-u-5-6, +.pure-u-20-24 { + width: 83.3333%; + *width: 83.3023%; +} + +.pure-u-7-8, +.pure-u-21-24 { + width: 87.5000%; + *width: 87.4690%; +} + +.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.80); /* 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.10)); + background-image: linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10)); +} +.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.20) 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.40; + 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: .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: .3em 0; +} + +.pure-menu-horizontal .pure-menu-separator { + width: 1px; + height: 1.3em; + margin: 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: .5em 1em; +} + +.pure-menu-disabled { + opacity: .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/src/webex/style/wallet.css b/src/webex/style/wallet.css new file mode 100644 index 000000000..752fc6d75 --- /dev/null +++ b/src/webex/style/wallet.css @@ -0,0 +1,222 @@ +#main { + border: solid 1px black; + border-radius: 10px; + margin-left: auto; + margin-right: auto; + margin-top: 2em; + max-width: 50%; + padding: 2em; +} + +header { + width: 100%; + height: 100px; + margin: 0; + padding: 0; + border-bottom: 1px solid black; +} + +header h1 { + font-size: 200%; + margin: 0; + padding: 0 0 0 120px; + position: relative; + top: 50%; + transform: translateY(-50%); +} + +header #logo { + float: left; + width: 100px; + height: 100px; + padding: 0; + margin: 0; + text-align: center; + border-right: 1px solid black; + background-image: url(/img/logo.png); + background-size: 100px; +} + +aside { + width: 100px; + float: left; +} + +section#main { + margin: auto; + padding: 20px; + border-left: 1px solid black; + height: 100%; + max-width: 50%; +} + +section#main h1:first-child { + margin-top: 0; +} + +h1 { + font-size: 160%; +} + +h2 { + font-size: 140%; +} + +h3 { + font-size: 120%; +} + +h4, h5, h6 { + font-size: 100%; +} + +.form-row { + padding-top: 5px; + padding-bottom: 5px; +} + +label { + padding-right: 1em; +} + +label::after { + content: ":"; +} + +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; +} + +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; +} diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts new file mode 100644 index 000000000..e5a502406 --- /dev/null +++ b/src/webex/wxApi.ts @@ -0,0 +1,174 @@ +/* + 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, + CoinRecord, + CurrencyRecord, + DenominationRecord, + ExchangeRecord, + PreCoinRecord, + ReserveCreationInfo, + ReserveRecord, +} from "../types"; + + +/** + * Query the wallet for the coins that would be used to withdraw + * from a given reserve. + */ +export function getReserveCreationInfo(baseUrl: string, + amount: AmountJson): Promise<ReserveCreationInfo> { + const m = { type: "reserve-creation-info", detail: { baseUrl, amount } }; + return new Promise<ReserveCreationInfo>((resolve, reject) => { + chrome.runtime.sendMessage(m, (resp) => { + if (resp.error) { + console.error("error response", resp); + const e = Error("call to reserve-creation-info failed"); + (e as any).errorResponse = resp; + reject(e); + return; + } + resolve(resp); + }); + }); +} + + +async function callBackend(type: string, detail?: any): Promise<any> { + return new Promise<any>((resolve, reject) => { + chrome.runtime.sendMessage({ type, detail }, (resp) => { + if (resp && resp.error) { + reject(resp); + } else { + resolve(resp); + } + }); + }); +} + + +/** + * Get all exchanges the wallet knows about. + */ +export async function getExchanges(): Promise<ExchangeRecord[]> { + return await callBackend("get-exchanges"); +} + + +/** + * Get all currencies the exchange knows about. + */ +export async function getCurrencies(): Promise<CurrencyRecord[]> { + return await callBackend("get-currencies"); +} + + +/** + * Get information about a specific currency. + */ +export async function getCurrency(name: string): Promise<CurrencyRecord|null> { + return await callBackend("currency-info", {name}); +} + + +/** + * Get information about a specific exchange. + */ +export async function getExchangeInfo(baseUrl: string): Promise<ExchangeRecord> { + return await callBackend("exchange-info", {baseUrl}); +} + + +/** + * Replace an existing currency record with the one given. The currency to + * replace is specified inside the currency record. + */ +export async function updateCurrency(currencyRecord: CurrencyRecord): Promise<void> { + return await callBackend("update-currency", { currencyRecord }); +} + + +/** + * Get all reserves the wallet has at an exchange. + */ +export async function getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> { + return await callBackend("get-reserves", { exchangeBaseUrl }); +} + + +/** + * Get all reserves for which a payback is available. + */ +export async function getPaybackReserves(): Promise<ReserveRecord[]> { + return await callBackend("get-payback-reserves"); +} + + +/** + * Withdraw the payback that is available for a reserve. + */ +export async function withdrawPaybackReserve(reservePub: string): Promise<ReserveRecord[]> { + return await callBackend("withdraw-payback-reserve", { reservePub }); +} + + +/** + * Get all coins withdrawn from the given exchange. + */ +export async function getCoins(exchangeBaseUrl: string): Promise<CoinRecord[]> { + return await callBackend("get-coins", { exchangeBaseUrl }); +} + + +/** + * Get all precoins withdrawn from the given exchange. + */ +export async function getPreCoins(exchangeBaseUrl: string): Promise<PreCoinRecord[]> { + return await callBackend("get-precoins", { exchangeBaseUrl }); +} + + +/** + * Get all denoms offered by the given exchange. + */ +export async function getDenoms(exchangeBaseUrl: string): Promise<DenominationRecord[]> { + return await callBackend("get-denoms", { exchangeBaseUrl }); +} + + +/** + * Start refreshing a coin. + */ +export async function refresh(coinPub: string): Promise<void> { + return await callBackend("refresh-coin", { coinPub }); +} + + +/** + * Request payback for a coin. Only works for non-refreshed coins. + */ +export async function payback(coinPub: string): Promise<void> { + return await callBackend("payback-coin", { coinPub }); +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts new file mode 100644 index 000000000..35e1ff938 --- /dev/null +++ b/src/webex/wxBackend.ts @@ -0,0 +1,719 @@ +/* + 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 { Checkable } from "../checkable"; +import { BrowserHttpLib } from "../http"; +import * as logging from "../logging"; +import { + Index, + Store, +} from "../query"; +import { + AmountJson, + Contract, + Notifier, +} from "../types"; +import { + Badge, + ConfirmReserveRequest, + CreateReserveRequest, + OfferRecord, + Stores, + Wallet, +} from "../wallet"; + +import { ChromeBadge } from "./chromeBadge"; +import URI = require("urijs"); +import Port = chrome.runtime.Port; +import MessageSender = chrome.runtime.MessageSender; + + +const DB_NAME = "taler"; + +/** + * Current database version, should be incremented + * each time we do incompatible schema changes on the database. + * In the future we might consider adding migration functions for + * each version increment. + */ +const DB_VERSION = 17; + +type Handler = (detail: any, sender: MessageSender) => Promise<any>; + +function makeHandlers(db: IDBDatabase, + wallet: Wallet): { [msg: string]: Handler } { + return { + ["balances"]: (detail, sender) => { + return wallet.getBalances(); + }, + ["dump-db"]: (detail, sender) => { + return exportDb(db); + }, + ["import-db"]: (detail, sender) => { + return importDb(db, detail.dump); + }, + ["get-tab-cookie"]: (detail, sender) => { + if (!sender || !sender.tab || !sender.tab.id) { + return Promise.resolve(); + } + const id: number = sender.tab.id; + const info: any = paymentRequestCookies[id] as any; + delete paymentRequestCookies[id]; + return Promise.resolve(info); + }, + ["ping"]: (detail, sender) => { + return Promise.resolve(); + }, + ["reset"]: (detail, sender) => { + if (db) { + const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < db.objectStoreNames.length; i++) { + tx.objectStore(db.objectStoreNames[i]).clear(); + } + } + deleteDb(); + + chrome.browserAction.setBadgeText({ text: "" }); + console.log("reset done"); + // Response is synchronous + return Promise.resolve({}); + }, + ["create-reserve"]: (detail, sender) => { + const d = { + amount: detail.amount, + exchange: detail.exchange, + }; + const req = CreateReserveRequest.checked(d); + return wallet.createReserve(req); + }, + ["confirm-reserve"]: (detail, sender) => { + // TODO: make it a checkable + const d = { + reservePub: detail.reservePub, + }; + const req = ConfirmReserveRequest.checked(d); + return wallet.confirmReserve(req); + }, + ["generate-nonce"]: (detail, sender) => { + return wallet.generateNonce(); + }, + ["confirm-pay"]: (detail, sender) => { + let offer: OfferRecord; + try { + offer = OfferRecord.checked(detail.offer); + } catch (e) { + if (e instanceof Checkable.SchemaError) { + console.error("schema error:", e.message); + return Promise.resolve({ + detail, + error: "invalid contract", + hint: e.message, + }); + } else { + throw e; + } + } + + return wallet.confirmPay(offer); + }, + ["check-pay"]: (detail, sender) => { + let offer: OfferRecord; + try { + offer = OfferRecord.checked(detail.offer); + } catch (e) { + if (e instanceof Checkable.SchemaError) { + console.error("schema error:", e.message); + return Promise.resolve({ + detail, + error: "invalid contract", + hint: e.message, + }); + } else { + throw e; + } + } + return wallet.checkPay(offer); + }, + ["query-payment"]: (detail: any, sender: MessageSender) => { + if (sender.tab && sender.tab.id) { + rateLimitCache[sender.tab.id]++; + if (rateLimitCache[sender.tab.id] > 10) { + console.warn("rate limit for query-payment exceeded"); + const msg = { + error: "rate limit exceeded for query-payment", + hint: "Check for redirect loops", + rateLimitExceeded: true, + }; + return Promise.resolve(msg); + } + } + return wallet.queryPayment(detail.url); + }, + ["exchange-info"]: (detail) => { + if (!detail.baseUrl) { + return Promise.resolve({ error: "bad url" }); + } + return wallet.updateExchangeFromUrl(detail.baseUrl); + }, + ["currency-info"]: (detail) => { + if (!detail.name) { + return Promise.resolve({ error: "name missing" }); + } + return wallet.getCurrencyRecord(detail.name); + }, + ["hash-contract"]: (detail) => { + if (!detail.contract) { + return Promise.resolve({ error: "contract missing" }); + } + return wallet.hashContract(detail.contract).then((hash) => { + return { hash }; + }); + }, + ["put-history-entry"]: (detail: any) => { + if (!detail.historyEntry) { + return Promise.resolve({ error: "historyEntry missing" }); + } + return wallet.putHistory(detail.historyEntry); + }, + ["save-offer"]: (detail: any) => { + const offer = detail.offer; + if (!offer) { + return Promise.resolve({ error: "offer missing" }); + } + console.log("handling safe-offer", detail); + // FIXME: fully migrate to new terminology + const checkedOffer = OfferRecord.checked(offer); + return wallet.saveOffer(checkedOffer); + }, + ["reserve-creation-info"]: (detail, sender) => { + if (!detail.baseUrl || typeof detail.baseUrl !== "string") { + return Promise.resolve({ error: "bad url" }); + } + const amount = AmountJson.checked(detail.amount); + return wallet.getReserveCreationInfo(detail.baseUrl, amount); + }, + ["get-history"]: (detail, sender) => { + // TODO: limit history length + return wallet.getHistory(); + }, + ["get-offer"]: (detail, sender) => { + return wallet.getOffer(detail.offerId); + }, + ["get-exchanges"]: (detail, sender) => { + return wallet.getExchanges(); + }, + ["get-currencies"]: (detail, sender) => { + return wallet.getCurrencies(); + }, + ["update-currency"]: (detail, sender) => { + return wallet.updateCurrency(detail.currencyRecord); + }, + ["get-reserves"]: (detail, sender) => { + if (typeof detail.exchangeBaseUrl !== "string") { + return Promise.reject(Error("exchangeBaseUrl missing")); + } + return wallet.getReserves(detail.exchangeBaseUrl); + }, + ["get-payback-reserves"]: (detail, sender) => { + return wallet.getPaybackReserves(); + }, + ["withdraw-payback-reserve"]: (detail, sender) => { + if (typeof detail.reservePub !== "string") { + return Promise.reject(Error("reservePub missing")); + } + return wallet.withdrawPaybackReserve(detail.reservePub); + }, + ["get-coins"]: (detail, sender) => { + if (typeof detail.exchangeBaseUrl !== "string") { + return Promise.reject(Error("exchangBaseUrl missing")); + } + return wallet.getCoins(detail.exchangeBaseUrl); + }, + ["get-precoins"]: (detail, sender) => { + if (typeof detail.exchangeBaseUrl !== "string") { + return Promise.reject(Error("exchangBaseUrl missing")); + } + return wallet.getPreCoins(detail.exchangeBaseUrl); + }, + ["get-denoms"]: (detail, sender) => { + if (typeof detail.exchangeBaseUrl !== "string") { + return Promise.reject(Error("exchangBaseUrl missing")); + } + return wallet.getDenoms(detail.exchangeBaseUrl); + }, + ["refresh-coin"]: (detail, sender) => { + if (typeof detail.coinPub !== "string") { + return Promise.reject(Error("coinPub missing")); + } + return wallet.refresh(detail.coinPub); + }, + ["payback-coin"]: (detail, sender) => { + if (typeof detail.coinPub !== "string") { + return Promise.reject(Error("coinPub missing")); + } + return wallet.payback(detail.coinPub); + }, + ["payment-failed"]: (detail, sender) => { + // For now we just update exchanges (maybe the exchange did something + // wrong and the keys were messed up). + // FIXME: in the future we should look at what actually went wrong. + console.error("payment reported as failed"); + wallet.updateExchanges(); + return Promise.resolve(); + }, + ["payment-succeeded"]: (detail, sender) => { + const contractHash = detail.contractHash; + const merchantSig = detail.merchantSig; + if (!contractHash) { + return Promise.reject(Error("contractHash missing")); + } + if (!merchantSig) { + return Promise.reject(Error("merchantSig missing")); + } + return wallet.paymentSucceeded(contractHash, merchantSig); + }, + }; +} + + +async function dispatch(handlers: any, req: any, sender: any, sendResponse: any): Promise<void> { + if (!(req.type in handlers)) { + console.error(`Request type ${JSON.stringify(req)} unknown, req ${req.type}`); + try { + sendResponse({ error: "request unknown" }); + } catch (e) { + // might fail if tab disconnected + } + } + + try { + const p = handlers[req.type](req.detail, sender); + 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: "exception", + hint: e.message, + stack, + }); + } catch (e) { + console.log(e); + // might fail if tab disconnected + } + } +} + + +class ChromeNotifier implements Notifier { + private ports: Port[] = []; + + constructor() { + chrome.runtime.onConnect.addListener((port) => { + console.log("got connect!"); + this.ports.push(port); + port.onDisconnect.addListener(() => { + const i = this.ports.indexOf(port); + if (i >= 0) { + this.ports.splice(i, 1); + } else { + console.error("port already removed"); + } + }); + }); + } + + notify() { + for (const p of this.ports) { + p.postMessage({ notify: true }); + } + } +} + + +/** + * Mapping from tab ID to payment information (if any). + */ +const paymentRequestCookies: { [n: number]: any } = {}; + + +/** + * Handle a HTTP response that has the "402 Payment Required" status. + * In this callback we don't have access to the body, and must communicate via + * shared state with the content script that will later be run later + * in this tab. + */ +function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: string, tabId: number): any { + const headers: { [s: string]: string } = {}; + for (const kv of headerList) { + if (kv.value) { + headers[kv.name.toLowerCase()] = kv.value; + } + } + + const fields = { + contract_query: headers["x-taler-contract-query"], + contract_url: headers["x-taler-contract-url"], + offer_url: headers["x-taler-offer-url"], + }; + + const talerHeaderFound = Object.keys(fields).filter((x: any) => (fields as any)[x]).length !== 0; + + if (!talerHeaderFound) { + // looks like it's not a taler request, it might be + // for a different payment system (or the shop is buggy) + console.log("ignoring non-taler 402 response"); + return; + } + + const payDetail = { + contract_url: fields.contract_url, + offer_url: fields.offer_url, + }; + + console.log("got pay detail", payDetail); + + // This cookie will be read by the injected content script + // in the tab that displays the page. + paymentRequestCookies[tabId] = { + payDetail, + type: "pay", + }; +} + + +function handleBankRequest(wallet: Wallet, headerList: chrome.webRequest.HttpHeader[], + url: string, tabId: number): any { + const headers: { [s: string]: string } = {}; + for (const kv of headerList) { + if (kv.value) { + headers[kv.name.toLowerCase()] = kv.value; + } + } + + const reservePub = headers["x-taler-reserve-pub"]; + if (reservePub !== undefined) { + console.log(`confirming reserve ${reservePub} via 201`); + wallet.confirmReserve({reservePub}); + return; + } + + const amount = headers["x-taler-amount"]; + if (amount) { + const callbackUrl = headers["x-taler-callback-url"]; + if (!callbackUrl) { + console.log("202 not understood (X-Taler-Callback-Url missing)"); + return; + } + let amountParsed; + try { + amountParsed = JSON.parse(amount); + } catch (e) { + const uri = new URI(chrome.extension.getURL("/src/pages/error.html")); + const p = { + message: `Can't parse amount ("${amount}"): ${e.message}`, + }; + const redirectUrl = uri.query(p).href(); + // FIXME: use direct redirect when https://bugzilla.mozilla.org/show_bug.cgi?id=707624 is fixed + chrome.tabs.update(tabId, {url: redirectUrl}); + return; + } + const wtTypes = headers["x-taler-wt-types"]; + if (!wtTypes) { + console.log("202 not understood (X-Taler-Wt-Types missing)"); + return; + } + const params = { + amount, + bank_url: url, + callback_url: new URI(callbackUrl) .absoluteTo(url), + suggested_exchange_url: headers["x-taler-suggested-exchange"], + wt_types: wtTypes, + }; + const uri = new URI(chrome.extension.getURL("/src/pages/confirm-create-reserve.html")); + const redirectUrl = uri.query(params).href(); + console.log("redirecting to", redirectUrl); + // FIXME: use direct redirect when https://bugzilla.mozilla.org/show_bug.cgi?id=707624 is fixed + chrome.tabs.update(tabId, {url: redirectUrl}); + return; + } + // no known headers found, not a taler request ... +} + + +// Rate limit cache for executePayment operations, to break redirect loops +let rateLimitCache: { [n: number]: number } = {}; + +function clearRateLimitCache() { + rateLimitCache = {}; +} + +/** + * Main function to run for the WebExtension backend. + * + * Sets up all event handlers and other machinery. + */ +export async function wxMain() { + window.onerror = (m, source, lineno, colno, error) => { + logging.record("error", m + error, undefined, source || "(unknown)", lineno || 0, colno || 0); + }; + + chrome.browserAction.setBadgeText({ text: "" }); + const badge = new ChromeBadge(); + + chrome.tabs.query({}, (tabs) => { + for (const tab of tabs) { + if (!tab.url || !tab.id) { + return; + } + const uri = new URI(tab.url); + if (uri.protocol() === "http" || uri.protocol() === "https") { + console.log("injecting into existing tab", tab.id); + chrome.tabs.executeScript(tab.id, { file: "/dist/contentScript-bundle.js" }); + const code = ` + if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) { + document.dispatchEvent(new Event("taler-probe-result")); + } + `; + chrome.tabs.executeScript(tab.id, { code, runAt: "document_idle" }); + } + } + }); + + const tabTimers: {[n: number]: number[]} = {}; + + chrome.tabs.onRemoved.addListener((tabId, changeInfo) => { + const tt = tabTimers[tabId] || []; + for (const t of tt) { + chrome.extension.getBackgroundPage().clearTimeout(t); + } + }); + chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status !== "complete") { + return; + } + const timers: number[] = []; + + const addRun = (dt: number) => { + const id = chrome.extension.getBackgroundPage().setTimeout(run, dt); + timers.push(id); + }; + + const run = () => { + timers.shift(); + chrome.tabs.get(tabId, (tab) => { + if (chrome.runtime.lastError) { + return; + } + if (!tab.url || !tab.id) { + return; + } + const uri = new URI(tab.url); + if (!(uri.protocol() === "http" || uri.protocol() === "https")) { + return; + } + const code = ` + if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) { + document.dispatchEvent(new Event("taler-probe-result")); + } + `; + chrome.tabs.executeScript(tab.id!, { code, runAt: "document_start" }); + }); + }; + + addRun(0); + addRun(50); + addRun(300); + addRun(1000); + addRun(2000); + addRun(4000); + addRun(8000); + addRun(16000); + tabTimers[tabId] = timers; + }); + + chrome.extension.getBackgroundPage().setInterval(clearRateLimitCache, 5000); + + let db: IDBDatabase; + try { + db = await openTalerDb(); + } catch (e) { + console.error("could not open database", e); + return; + } + const http = new BrowserHttpLib(); + const notifier = new ChromeNotifier(); + console.log("setting wallet"); + const wallet = new Wallet(db, http, badge!, notifier); + // Useful for debugging in the background page. + (window as any).talerWallet = wallet; + + // Handlers for messages coming directly from the content + // script on the page + const handlers = makeHandlers(db, wallet!); + chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { + dispatch(handlers, req, sender, sendResponse); + return true; + }); + + // Handlers for catching HTTP requests + chrome.webRequest.onHeadersReceived.addListener((details) => { + if (details.statusCode === 402) { + console.log(`got 402 from ${details.url}`); + return handleHttpPayment(details.responseHeaders || [], + details.url, + details.tabId); + } else if (details.statusCode === 202) { + return handleBankRequest(wallet!, details.responseHeaders || [], + details.url, + details.tabId); + } + }, { urls: ["<all_urls>"] }, ["responseHeaders", "blocking"]); +} + + +/** + * Return a promise that resolves + * to the taler wallet db. + */ +function openTalerDb(): Promise<IDBDatabase> { + return new Promise<IDBDatabase>((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = (e) => { + reject(e); + }; + req.onsuccess = (e) => { + resolve(req.result); + }; + req.onupgradeneeded = (e) => { + const db = req.result; + console.log("DB: upgrade needed: oldVersion = " + e.oldVersion); + switch (e.oldVersion) { + case 0: // DB does not exist yet + + for (const n in Stores) { + if ((Stores as any)[n] instanceof Store) { + const si: Store<any> = (Stores as any)[n]; + const s = db.createObjectStore(si.name, si.storeParams); + for (const indexName in (si as any)) { + if ((si as any)[indexName] instanceof Index) { + const ii: Index<any, any> = (si as any)[indexName]; + s.createIndex(ii.indexName, ii.keyPath); + } + } + } + } + break; + default: + if (e.oldVersion !== DB_VERSION) { + window.alert("Incompatible wallet dababase version, please reset" + + " db."); + chrome.browserAction.setBadgeText({text: "err"}); + chrome.browserAction.setBadgeBackgroundColor({color: "#F00"}); + throw Error("incompatible DB"); + } + break; + } + }; + }); +} + + +function exportDb(db: IDBDatabase): Promise<any> { + const dump = { + name: db.name, + stores: {} as {[s: string]: any}, + version: db.version, + }; + + return new Promise((resolve, reject) => { + + const tx = db.transaction(Array.from(db.objectStoreNames)); + tx.addEventListener("complete", () => { + resolve(dump); + }); + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < db.objectStoreNames.length; i++) { + const name = db.objectStoreNames[i]; + const storeDump = {} as {[s: string]: any}; + dump.stores[name] = storeDump; + tx.objectStore(name) + .openCursor() + .addEventListener("success", (e: Event) => { + const cursor = (e.target as any).result; + if (cursor) { + storeDump[cursor.key] = cursor.value; + cursor.continue(); + } + }); + } + }); +} + + +function importDb(db: IDBDatabase, dump: any): Promise<void> { + console.log("importing db", dump); + return new Promise<void>((resolve, reject) => { + const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); + if (dump.stores) { + for (const storeName in dump.stores) { + const objects = []; + const dumpStore = dump.stores[storeName]; + for (const key in dumpStore) { + objects.push(dumpStore[key]); + } + console.log(`importing ${objects.length} records into ${storeName}`); + const store = tx.objectStore(storeName); + const clearReq = store.clear(); + for (const obj of objects) { + store.put(obj); + } + } + } + tx.addEventListener("complete", () => { + resolve(); + }); + }); +} + + +function deleteDb() { + indexedDB.deleteDatabase(DB_NAME); +} |