commit b6e774585d32017e5f1ceeeb2b2e2a5e350354d3
parent 38a74188d759444d7e1abac856f78ae710e2a4c5
Author: Florian Dold <florian.dold@gmail.com>
Date: Sun, 28 May 2017 23:15:41 +0200
move webex specific things in their own directory
Diffstat:
48 files changed, 3582 insertions(+), 3567 deletions(-)
diff --git a/manifest.json b/manifest.json
@@ -36,7 +36,7 @@
"32": "img/icon.png"
},
"default_title": "Taler",
- "default_popup": "src/pages/popup.html"
+ "default_popup": "src/webex/pages/popup.html"
},
"content_scripts": [
@@ -54,7 +54,7 @@
],
"background": {
- "page": "src/background/background.html",
+ "page": "src/webex/background.html",
"persistent": true
}
}
diff --git a/src/background/background.ts b/src/background/background.ts
@@ -1,30 +0,0 @@
-/*
- 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/chromeBadge.ts b/src/chromeBadge.ts
@@ -1,225 +0,0 @@
-/*
- 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/pages/add-auditor.tsx b/src/pages/add-auditor.tsx
@@ -1,125 +0,0 @@
-/*
- 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 {
- ExchangeRecord,
- DenominationRecord,
- AuditorRecord,
- CurrencyRecord,
- ReserveRecord,
- CoinRecord,
- PreCoinRecord,
- Denomination
-} from "../types";
-import { ImplicitStateComponent, StateHolder } from "../components";
-import {
- getCurrencies,
- updateCurrency,
-} from "../wxApi";
-import { getTalerStampDate } from "../helpers";
-
-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/pages/auditors.tsx b/src/pages/auditors.tsx
@@ -1,146 +0,0 @@
-/*
- 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 {
- ExchangeRecord,
- ExchangeForCurrencyRecord,
- DenominationRecord,
- AuditorRecord,
- CurrencyRecord,
- ReserveRecord,
- CoinRecord,
- PreCoinRecord,
- Denomination
-} from "../types";
-import { ImplicitStateComponent, StateHolder } from "../components";
-import {
- getCurrencies,
- updateCurrency,
-} from "../wxApi";
-import { getTalerStampDate } from "../helpers";
-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/pages/confirm-contract.tsx b/src/pages/confirm-contract.tsx
@@ -1,240 +0,0 @@
-/*
- 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 { Contract, AmountJson, ExchangeRecord } from "../types";
-import { OfferRecord } from "../wallet";
-import { renderContract } from "../renderHtml";
-import { getExchanges } from "../wxApi";
-import * as i18n from "../i18n";
-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/pages/confirm-create-reserve.tsx b/src/pages/confirm-create-reserve.tsx
@@ -1,639 +0,0 @@
-/*
- 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 {getReserveCreationInfo, getCurrency, getExchangeInfo} from "../wxApi";
-import {ImplicitStateComponent, StateHolder} from "../components";
-import * as i18n from "../i18n";
-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/pages/logs.tsx b/src/pages/logs.tsx
@@ -1,82 +0,0 @@
-/*
- 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 * as React from "react";
-import * as ReactDOM from "react-dom";
-import {LogEntry, getLogs} from "../logging";
-
-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/pages/payback.tsx b/src/pages/payback.tsx
@@ -1,98 +0,0 @@
-/*
- 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 {
- 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 { amountToPretty, getTalerStampDate } from "../helpers";
-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/pages/popup.tsx b/src/pages/popup.tsx
@@ -1,545 +0,0 @@
-/*
- 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 BrowserClickedEvent = chrome.browserAction.BrowserClickedEvent;
-import { HistoryRecord, HistoryLevel } from "../wallet";
-import {
- AmountJson, WalletBalance, Amounts,
- WalletBalanceEntry
-} from "../types";
-import { amountToPretty } from "../helpers";
-import { abbrev } from "../renderHtml";
-import * as i18n from "../i18n";
-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/pages/tree.tsx b/src/pages/tree.tsx
@@ -1,436 +0,0 @@
-/*
- 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 {
- ExchangeRecord,
- DenominationRecord,
- CoinStatus,
- ReserveRecord,
- CoinRecord,
- PreCoinRecord,
- Denomination,
-} from "../types";
-import { ImplicitStateComponent, StateHolder } from "../components";
-import {
- getReserves, getExchanges, getCoins, getPreCoins,
- refresh, getDenoms, payback,
-} from "../wxApi";
-import { amountToPretty, getTalerStampDate } from "../helpers";
-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/renderHtml.tsx b/src/renderHtml.tsx
@@ -1,78 +0,0 @@
-/*
- 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/background/background.html b/src/webex/background.html
diff --git a/src/webex/background.ts 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
@@ -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/components.ts b/src/webex/components.ts
diff --git a/src/content_scripts/notify.ts b/src/webex/notify.ts
diff --git a/src/pages/add-auditor.html b/src/webex/pages/add-auditor.html
diff --git a/src/webex/pages/add-auditor.tsx 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/pages/auditors.html b/src/webex/pages/auditors.html
diff --git a/src/webex/pages/auditors.tsx 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/pages/confirm-contract.html b/src/webex/pages/confirm-contract.html
diff --git a/src/webex/pages/confirm-contract.tsx 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/pages/confirm-create-reserve.html b/src/webex/pages/confirm-create-reserve.html
diff --git a/src/webex/pages/confirm-create-reserve.tsx 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/pages/error.html b/src/webex/pages/error.html
diff --git a/src/pages/error.tsx b/src/webex/pages/error.tsx
diff --git a/src/pages/help/empty-wallet.html b/src/webex/pages/help/empty-wallet.html
diff --git a/src/pages/logs.html b/src/webex/pages/logs.html
diff --git a/src/webex/pages/logs.tsx 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/pages/payback.html b/src/webex/pages/payback.html
diff --git a/src/webex/pages/payback.tsx 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/pages/popup.css b/src/webex/pages/popup.css
diff --git a/src/pages/popup.html b/src/webex/pages/popup.html
diff --git a/src/webex/pages/popup.tsx 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/pages/show-db.html b/src/webex/pages/show-db.html
diff --git a/src/pages/show-db.ts b/src/webex/pages/show-db.ts
diff --git a/src/pages/tree.html b/src/webex/pages/tree.html
diff --git a/src/webex/pages/tree.tsx 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
@@ -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/style/pure.css b/src/webex/style/pure.css
diff --git a/src/style/wallet.css b/src/webex/style/wallet.css
diff --git a/src/webex/wxApi.ts 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
@@ -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);
+}
diff --git a/src/wxApi.ts b/src/wxApi.ts
@@ -1,174 +0,0 @@
-/*
- 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/wxBackend.ts b/src/wxBackend.ts
@@ -1,718 +0,0 @@
-/*
- 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 { ChromeBadge } from "./chromeBadge";
-import { BrowserHttpLib } from "./http";
-import * as logging from "./logging";
-import {
- Index,
- Store,
-} from "./query";
-import {
- AmountJson,
- Contract,
- Notifier,
-} from "./types";
-import URI = require("urijs");
-import {
- Badge,
- ConfirmReserveRequest,
- CreateReserveRequest,
- OfferRecord,
- Stores,
- Wallet,
-} from "./wallet";
-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);
-}
diff --git a/tsconfig.json b/tsconfig.json
@@ -22,11 +22,7 @@
"decl/chrome/chrome.d.ts",
"decl/jed.d.ts",
"decl/urijs.d.ts",
- "src/background/background.ts",
"src/checkable.ts",
- "src/chromeBadge.ts",
- "src/components.ts",
- "src/content_scripts/notify.ts",
"src/crypto/cryptoApi-test.ts",
"src/crypto/cryptoApi.ts",
"src/crypto/cryptoWorker.ts",
@@ -43,24 +39,28 @@
"src/i18n.tsx",
"src/i18n/strings.ts",
"src/logging.ts",
- "src/pages/add-auditor.tsx",
- "src/pages/auditors.tsx",
- "src/pages/confirm-contract.tsx",
- "src/pages/confirm-create-reserve.tsx",
- "src/pages/error.tsx",
- "src/pages/logs.tsx",
- "src/pages/payback.tsx",
- "src/pages/popup.tsx",
- "src/pages/show-db.ts",
- "src/pages/tree.tsx",
"src/query.ts",
- "src/renderHtml.tsx",
"src/timer.ts",
"src/types-test.ts",
"src/types.ts",
"src/wallet-test.ts",
"src/wallet.ts",
- "src/wxApi.ts",
- "src/wxBackend.ts"
+ "src/webex/background.ts",
+ "src/webex/chromeBadge.ts",
+ "src/webex/components.ts",
+ "src/webex/notify.ts",
+ "src/webex/pages/add-auditor.tsx",
+ "src/webex/pages/auditors.tsx",
+ "src/webex/pages/confirm-contract.tsx",
+ "src/webex/pages/confirm-create-reserve.tsx",
+ "src/webex/pages/error.tsx",
+ "src/webex/pages/logs.tsx",
+ "src/webex/pages/payback.tsx",
+ "src/webex/pages/popup.tsx",
+ "src/webex/pages/show-db.ts",
+ "src/webex/pages/tree.tsx",
+ "src/webex/renderHtml.tsx",
+ "src/webex/wxApi.ts",
+ "src/webex/wxBackend.ts"
]
}
\ No newline at end of file
diff --git a/webpack.config.js b/webpack.config.js
@@ -39,25 +39,25 @@ module.exports = function (env) {
};
const configBackground = {
- entry: {"background": "./src/background/background.ts"},
+ entry: {"background": "./src/webex/background.ts"},
};
const configContentScript = {
- entry: {"contentScript": "./src/content_scripts/notify.ts"},
+ entry: {"contentScript": "./src/webex/notify.ts"},
};
const configExtensionPages = {
entry: {
- "add-auditor": "./src/pages/add-auditor.tsx",
- "auditors": "./src/pages/auditors.tsx",
- "confirm-contract": "./src/pages/confirm-contract.tsx",
- "confirm-create-reserve": "./src/pages/confirm-create-reserve.tsx",
- "error": "./src/pages/error.tsx",
- "logs": "./src/pages/logs.tsx",
- "popup": "./src/pages/popup.tsx",
- "show-db": "./src/pages/show-db.ts",
- "tree": "./src/pages/tree.tsx",
- "payback": "./src/pages/payback.tsx",
+ "add-auditor": "./src/webex/pages/add-auditor.tsx",
+ "auditors": "./src/webex/pages/auditors.tsx",
+ "confirm-contract": "./src/webex/pages/confirm-contract.tsx",
+ "confirm-create-reserve": "./src/webex/pages/confirm-create-reserve.tsx",
+ "error": "./src/webex/pages/error.tsx",
+ "logs": "./src/webex/pages/logs.tsx",
+ "popup": "./src/webex/pages/popup.tsx",
+ "show-db": "./src/webex/pages/show-db.ts",
+ "tree": "./src/webex/pages/tree.tsx",
+ "payback": "./src/webex/pages/payback.tsx",
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({