taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mmanifest.json | 4++--
Dsrc/background/background.ts | 30------------------------------
Dsrc/chromeBadge.ts | 225-------------------------------------------------------------------------------
Dsrc/pages/add-auditor.tsx | 125-------------------------------------------------------------------------------
Dsrc/pages/auditors.tsx | 146-------------------------------------------------------------------------------
Dsrc/pages/confirm-contract.tsx | 240-------------------------------------------------------------------------------
Dsrc/pages/confirm-create-reserve.tsx | 639-------------------------------------------------------------------------------
Dsrc/pages/logs.tsx | 82-------------------------------------------------------------------------------
Dsrc/pages/payback.tsx | 98-------------------------------------------------------------------------------
Dsrc/pages/popup.tsx | 545-------------------------------------------------------------------------------
Dsrc/pages/tree.tsx | 436-------------------------------------------------------------------------------
Dsrc/renderHtml.tsx | 78------------------------------------------------------------------------------
Rsrc/background/background.html -> src/webex/background.html | 0
Asrc/webex/background.ts | 30++++++++++++++++++++++++++++++
Asrc/webex/chromeBadge.ts | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/components.ts -> src/webex/components.ts | 0
Rsrc/content_scripts/notify.ts -> src/webex/notify.ts | 0
Rsrc/pages/add-auditor.html -> src/webex/pages/add-auditor.html | 0
Asrc/webex/pages/add-auditor.tsx | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/auditors.html -> src/webex/pages/auditors.html | 0
Asrc/webex/pages/auditors.tsx | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/confirm-contract.html -> src/webex/pages/confirm-contract.html | 0
Asrc/webex/pages/confirm-contract.tsx | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/confirm-create-reserve.html -> src/webex/pages/confirm-create-reserve.html | 0
Asrc/webex/pages/confirm-create-reserve.tsx | 641+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/error.html -> src/webex/pages/error.html | 0
Rsrc/pages/error.tsx -> src/webex/pages/error.tsx | 0
Rsrc/pages/help/empty-wallet.html -> src/webex/pages/help/empty-wallet.html | 0
Rsrc/pages/logs.html -> src/webex/pages/logs.html | 0
Asrc/webex/pages/logs.tsx | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/payback.html -> src/webex/pages/payback.html | 0
Asrc/webex/pages/payback.tsx | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/popup.css -> src/webex/pages/popup.css | 0
Rsrc/pages/popup.html -> src/webex/pages/popup.html | 0
Asrc/webex/pages/popup.tsx | 548+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pages/show-db.html -> src/webex/pages/show-db.html | 0
Rsrc/pages/show-db.ts -> src/webex/pages/show-db.ts | 0
Rsrc/pages/tree.html -> src/webex/pages/tree.html | 0
Asrc/webex/pages/tree.tsx | 437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/webex/renderHtml.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/style/pure.css -> src/webex/style/pure.css | 0
Rsrc/style/wallet.css -> src/webex/style/wallet.css | 0
Asrc/webex/wxApi.ts | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/webex/wxBackend.ts | 719+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/wxApi.ts | 174-------------------------------------------------------------------------------
Dsrc/wxBackend.ts | 718-------------------------------------------------------------------------------
Mtsconfig.json | 34+++++++++++++++++-----------------
Mwebpack.config.js | 24++++++++++++------------
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({