├── icon.png ├── icon-active.png ├── .babelrc ├── screenshots ├── pay-prompt-640.png ├── withdraw-from-joule-640.png ├── withdraw-checkmark-tippin.gif ├── withdraw-checkmark-tippin.mp4 └── generate-invoice-here-no-webln.mp4 ├── .gitignore ├── static ├── popup.html ├── options.html └── manifest.json ├── src ├── components │ ├── ShowInvoice.js │ ├── Invoice.js │ ├── Home.js │ └── Payment.js ├── constants.js ├── style.styl ├── current-action.js ├── predefined-behaviors.js ├── webln.js ├── interfaces │ ├── index.js │ ├── ptarmigan.js │ ├── lightningd_spark.js │ └── eclair.js ├── options.js ├── popup.js ├── utils.js ├── content.js └── background.js ├── package.json ├── LICENSE ├── docs └── privacy-policy.md ├── Makefile └── README.md /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/icon.png -------------------------------------------------------------------------------- /icon-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/icon-active.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/pay-prompt-640.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/screenshots/pay-prompt-640.png -------------------------------------------------------------------------------- /screenshots/withdraw-from-joule-640.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/screenshots/withdraw-from-joule-640.png -------------------------------------------------------------------------------- /screenshots/withdraw-checkmark-tippin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/screenshots/withdraw-checkmark-tippin.gif -------------------------------------------------------------------------------- /screenshots/withdraw-checkmark-tippin.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/screenshots/withdraw-checkmark-tippin.mp4 -------------------------------------------------------------------------------- /screenshots/generate-invoice-here-no-webln.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fiatjaf/kwh/HEAD/screenshots/generate-invoice-here-no-webln.mp4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swo 3 | *.swp 4 | static/icon* 5 | static/*.js 6 | static/*.css 7 | browserify-cache.json 8 | *.zip 9 | tmpsrc 10 | -------------------------------------------------------------------------------- /static/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /static/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | KwH Settings 5 | 6 | 7 |
8 | 9 | -------------------------------------------------------------------------------- /src/components/ShowInvoice.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React from 'react' // eslint-disable-line 4 | import {QRCode} from 'react-qr-svg' 5 | 6 | export default function ShowInvoice({invoice}) { 7 | return ( 8 |
9 | 16 |

{invoice}

17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | export const BLANK = null 4 | export const HOME = 'HOME' 5 | export const PROMPT_PAYMENT = 'PROMPT_PAYMENT' 6 | export const MAKE_PAYMENT = 'MAKE_PAYMENT' 7 | export const PROMPT_INVOICE = 'PROMPT_INVOICE' 8 | export const MAKE_INVOICE = 'MAKE_INVOICE' 9 | export const REQUEST_GETINFO = 'REQUEST_GETINFO' 10 | 11 | export const MENUITEM_GENERATE = 'generate-invoice-here' 12 | export const MENUITEM_PAY = 'pay-invoice' 13 | export const MENUITEM_BLOCK = 'block-domain' 14 | 15 | export const INVOICE_EXPIRY_SECONDS = 60 * 60 * 24 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kwh", 3 | "private": true, 4 | "license": "MIT", 5 | "browserify": { 6 | "transform": [ 7 | [ 8 | "babelify", 9 | { 10 | "sourceMaps": true, 11 | "sourceMapsAbsolute": true 12 | } 13 | ] 14 | ] 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "7.0.0-rc.1", 18 | "@babel/preset-env": "7.0.0-rc.1", 19 | "@babel/preset-react": "7.0.0-rc.1", 20 | "babelify": "^9.0.0", 21 | "browserify": "^16.2.2", 22 | "browserify-incremental": "^3.1.1", 23 | "stylus": "^0.54.5", 24 | "terser": "^3.17.0" 25 | }, 26 | "dependencies": { 27 | "create-hmac": "^1.1.7", 28 | "cuid": "^2.1.6", 29 | "debounce-with-args": "^1.0.1", 30 | "friendly-time": "^1.1.1", 31 | "keysim": "^2.1.0", 32 | "prop-types": "^15.7.2", 33 | "react": "^16.8.4", 34 | "react-contenteditable": "^3.2.6", 35 | "react-dom": "^16.8.4", 36 | "react-qr-svg": "^2.2.1", 37 | "webextension-polyfill": "^0.4.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 alhur.es 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/style.styl: -------------------------------------------------------------------------------- 1 | .wrap 2 | word-wrap break-word 3 | 4 | .hide 5 | transition opacity .25s ease-in 6 | opacity 0 7 | 8 | .show 9 | opacity 1 10 | 11 | /* checkmark */ 12 | .checkmark 13 | width 56px 14 | height 56px 15 | border-radius 50% 16 | display block 17 | stroke-width 2 18 | stroke #fff 19 | stroke-miterlimit 10 20 | box-shadow inset 0px 0px 0px #19a974 21 | animation fill .4s ease-in-out .4s forwards, scale .3s ease-in-out .9s both 22 | 23 | .checkmark__circle 24 | stroke-dasharray 166 25 | stroke-dashoffset 166 26 | stroke-width 2 27 | stroke-miterlimit 10 28 | stroke #19a974 29 | fill none 30 | animation stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards 31 | 32 | .checkmark__check 33 | transform-origin 50% 50% 34 | stroke-dasharray 48 35 | stroke-dashoffset 48 36 | animation stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards 37 | 38 | @keyframes stroke 39 | 100% 40 | stroke-dashoffset 0 41 | @keyframes scale 42 | 0%, 100% 43 | transform none 44 | 50% 45 | transform scale3d(1.1, 1.1, 1) 46 | @keyframes fill 47 | 100% 48 | box-shadow inset 0px 0px 0px 30px #19a974 49 | -------------------------------------------------------------------------------- /docs/privacy-policy.md: -------------------------------------------------------------------------------- 1 | Privacy Policy 2 | ============== 3 | 4 | **kWh** doesn't collect any personal data, doesn't track websites you visit, doesn't collect any form of usage analytics, doesn't sends any information to any service over the internet -- in fact, it doesn't even have a server anywhere, it's just an extension installed in your computer. 5 | 6 | The only places **kWh** sends information to are the URL endpoints you define in the settings page for the extension, the URLs that identify the Lightning nodes you want to talk to, which **kWh** assumes are under your control or under the control of a third-party you trust. 7 | 8 | **kWh** stores only the information you explicitly write in the settings page: _node URL_, _username_ and _password_ (where applicable). That information, however, is only stored in your own browser, using the _local_ storage mode, not the _synced_ storage mode that saves data to a browser-determined sync server. So if the guarantees offered by the browser itself hold true _that information never leaves your browser_. 9 | 10 | For more information on the browser guarantees on which **kWh** depends, please read Firefox's [Privacy Policy](https://www.mozilla.org/en-US/privacy/websites/) and [Terms of Service](https://www.mozilla.org/en-US/about/legal/terms/mozilla/) and Google Chrome [Privacy Policy](https://policies.google.com/privacy) and [Terms of Service](https://ssl.gstatic.com/chrome/webstore/intl/en-US/gallery_tos.html). 11 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "kwh", 3 | "applications": { 4 | "gecko": { 5 | "id": "kwh@alhur.es", 6 | "strict_min_version": "57.0" 7 | } 8 | }, 9 | "manifest_version": 2, 10 | "name": "kWh", 11 | "description": "Send and receive Lightning payments in the browser with c-lightning, Eclair or Ptarmigan.", 12 | "version": "0.5.3", 13 | "author": "fiatjaf", 14 | "icons": { 15 | "16": "icon16.png", 16 | "48": "icon48.png", 17 | "64": "icon64.png", 18 | "128": "icon128.png" 19 | }, 20 | "options_ui": { 21 | "page": "options.html", 22 | "browser_style": true, 23 | "open_in_tab": false 24 | }, 25 | "content_scripts": [ 26 | { 27 | "matches": [ 28 | "file://*/*", 29 | "http://*/*", 30 | "https://*/*" 31 | ], 32 | "js": [ 33 | "content-bundle.js" 34 | ], 35 | "run_at": "document_start", 36 | "all_frames": true 37 | } 38 | ], 39 | "background": { 40 | "scripts": [ 41 | "background-bundle.js" 42 | ], 43 | "persistent": true 44 | }, 45 | "browser_action": { 46 | "default_icon": { 47 | "16": "icon16.png", 48 | "64": "icon64.png" 49 | }, 50 | "default_title": "KwH", 51 | "default_popup": "popup.html" 52 | }, 53 | "web_accessible_resources": [ 54 | "webln-bundle.js" 55 | ], 56 | "permissions": [ 57 | "storage", 58 | "clipboardWrite", 59 | "notifications", 60 | "contextMenus", 61 | "tabs", 62 | "" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/current-action.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import {HOME, PROMPT_PAYMENT, PROMPT_INVOICE} from './constants' 5 | 6 | const blankAction = {type: HOME} 7 | 8 | var currentActions = {} 9 | var actionIdNext = 1 10 | 11 | export function get(tabId) { 12 | if (tabId in currentActions) { 13 | return currentActions[tabId] 14 | } 15 | return [{...blankAction, id: actionIdNext++}, null] 16 | } 17 | 18 | export function set(tabId, action) { 19 | if (action.type === PROMPT_PAYMENT || action.type === PROMPT_INVOICE) { 20 | emphasizeBrowserAction(tabId, action.type) 21 | } else { 22 | cleanupBrowserAction(tabId) 23 | } 24 | 25 | // schedule cleanup 26 | setTimeout(() => { 27 | delete currentActions[tabId] 28 | cleanupBrowserAction(tabId) 29 | }, 1000 * 60 * 4 /* 4 minutes */) 30 | 31 | let promise = new Promise((resolve, reject) => { 32 | currentActions[tabId] = [{...action, id: actionIdNext++}, {resolve, reject}] 33 | }) 34 | return [currentActions[tabId][0], promise] 35 | } 36 | 37 | export const prompt_defs = { 38 | PROMPT_PAYMENT: ['pay', '#d5008f'], 39 | PROMPT_INVOICE: ['req', '#357edd'] 40 | } 41 | 42 | export function emphasizeBrowserAction(tabId, type) { 43 | let [label, bg] = prompt_defs[type] 44 | 45 | browser.browserAction.setBadgeText({text: label, tabId}) 46 | browser.browserAction.setIcon({ 47 | path: {16: 'icon16-active.png', 64: 'icon64-active.png'}, 48 | tabId 49 | }) 50 | browser.browserAction.setBadgeBackgroundColor({color: bg, tabId}) 51 | 52 | try { 53 | browser.browserAction.setBadgeTextColor({color: '#ffffff'}) 54 | } catch (e) {} 55 | } 56 | 57 | export function cleanupBrowserAction(tabId) { 58 | browser.browserAction.setBadgeText({text: '', tabId}) 59 | browser.browserAction.setIcon({ 60 | path: {16: 'icon16.png', 64: 'icon64.png'}, 61 | tabId 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: build 2 | 3 | icons: static/icon64.png static/icon128.png static/icon48.png static/icon16.png static/icon64-active.png static/icon128-active.png static/icon48-active.png static/icon16-active.png 4 | build: icons static/webln-bundle.js static/content-bundle.js static/background-bundle.js static/popup-bundle.js static/options-bundle.js static/style.css 5 | 6 | static/icon%.png: icon.png 7 | convert $< -resize $*x$* $@ 8 | 9 | static/icon%-active.png: icon-active.png 10 | convert $< -resize $*x$* $@ 11 | 12 | static/background-bundle.js: src/background.js src/predefined-behaviors.js src/current-action.js src/utils.js src/interfaces/index.js src/interfaces/lightningd_spark.js src/interfaces/eclair.js src/interfaces/ptarmigan.js 13 | ./node_modules/.bin/browserifyinc $< -dv --outfile $@ 14 | 15 | static/content-bundle.js: src/content.js src/utils.js 16 | ./node_modules/.bin/browserifyinc $< -dv --outfile $@ 17 | 18 | static/webln-bundle.js: src/webln.js src/utils.js 19 | ./node_modules/.bin/browserifyinc $< -dv --outfile $@ 20 | 21 | static/popup-bundle.js: src/popup.js $(shell find src/components/) 22 | ./node_modules/.bin/browserifyinc $< -dv --outfile $@ 23 | 24 | static/options-bundle.js: src/options.js src/utils.js 25 | ./node_modules/.bin/browserifyinc $< -dv --outfile $@ 26 | 27 | static/style.css: src/style.styl 28 | ./node_modules/.bin/stylus < $< > $@ 29 | 30 | extension.zip: build 31 | cd static/ && \ 32 | for file in $$(ls *.js); \ 33 | do ../node_modules/.bin/terser $$file --compress --mangle | sponge $$file; \ 34 | done; \ 35 | zip -r extension * && \ 36 | mv extension.zip ../ 37 | 38 | sources.zip: build 39 | rm -fr tmpsrc/ 40 | mkdir -p tmpsrc 41 | mkdir -p tmpsrc/src 42 | mkdir -p tmpsrc/static 43 | cd static && \ 44 | cp -r *.html ../tmpsrc/static/ 45 | cd src && \ 46 | cp -r *.js ../tmpsrc/src 47 | cp *.png package.json Makefile README.md tmpsrc/ 48 | cd tmpsrc/ && \ 49 | zip -r sources * && \ 50 | mv sources.zip ../ 51 | -------------------------------------------------------------------------------- /src/predefined-behaviors.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | 5 | import {HOME} from './constants' 6 | import {set, cleanupBrowserAction} from './current-action' 7 | import {formatmsat, notify} from './utils' 8 | 9 | const behaviors = { 10 | 'navigate-home': (_, __, tabId) => { 11 | set(tabId, {type: HOME}) 12 | }, 13 | 'save-pending-to-current-action': (_, [action], tabId) => { 14 | set(tabId, {...action, pending: true}) 15 | }, 16 | 'return-preimage': ({preimage}, [_, promise]) => { 17 | if (promise) promise.resolve(preimage) 18 | }, 19 | 'notify-payment-success': ({msatoshi_paid, msatoshi_fees}, _) => { 20 | notify({ 21 | title: 'Payment succeeded', 22 | message: `${formatmsat(msatoshi_paid)} paid with a fee of ${formatmsat( 23 | msatoshi_fees 24 | )}.`, 25 | iconUrl: '/icon64-active.png' 26 | }) 27 | }, 28 | 'return-payment-error': (resp, [_, promise]) => { 29 | console.log(resp) 30 | if (promise) promise.reject(new Error('Payment failed or still pending.')) 31 | }, 32 | 'notify-payment-error': (e, _) => { 33 | notify({ 34 | message: e.message, 35 | title: 'Payment error' 36 | }) 37 | }, 38 | 'paste-invoice': ({bolt11}, [{pasteOn}]) => { 39 | if (pasteOn) { 40 | browser.tabs.sendMessage(pasteOn[0], { 41 | paste: true, 42 | elementId: pasteOn[1], 43 | bolt11 44 | }) 45 | } 46 | }, 47 | 'save-invoice-to-current-action': (invoiceData, [action, _], tabId) => { 48 | set(tabId, {...action, invoiceData}) 49 | }, 50 | 'return-invoice': (invoiceData, [_, promise]) => { 51 | if (promise) promise.resolve(invoiceData) 52 | }, 53 | 'notify-invoice-error': (e, _) => { 54 | notify({ 55 | message: e.message, 56 | title: 'Error generating invoice' 57 | }) 58 | }, 59 | 'cleanup-browser-action': (_, [action]) => { 60 | cleanupBrowserAction(action.tabId) 61 | } 62 | } 63 | 64 | export function getBehavior(name) { 65 | return behaviors[name] 66 | } 67 | 68 | export default behaviors 69 | -------------------------------------------------------------------------------- /src/webln.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import {PROMPT_PAYMENT, PROMPT_INVOICE, REQUEST_GETINFO} from './constants' 4 | 5 | class WebLNProvider { 6 | enable() { 7 | return this._sendMessage({getBlocked: true}).then(blocked => { 8 | if (blocked) { 9 | throw new Error('webln acess is blocked on this domain.') 10 | } 11 | 12 | return true 13 | }) 14 | } 15 | 16 | getInfo() { 17 | return this.enable() 18 | .then(() => this._prompt(REQUEST_GETINFO)) 19 | .then(info => ({ 20 | node: { 21 | alias: info.alias, 22 | pubkey: info.id, 23 | color: info.color 24 | } 25 | })) 26 | } 27 | 28 | sendPayment(invoice) { 29 | return this.enable() 30 | .then(() => this._prompt(PROMPT_PAYMENT, {invoice})) 31 | .then(preimage => ({preimage})) 32 | } 33 | 34 | makeInvoice(args) { 35 | return this.enable().then(() => { 36 | if (typeof args !== 'object') { 37 | args = {amount: args} 38 | } 39 | return this._prompt(PROMPT_INVOICE, args).then(({bolt11}) => ({ 40 | paymentRequest: bolt11 41 | })) 42 | }) 43 | } 44 | 45 | signMessage(message) { 46 | return Promise.reject("Can't sign message.") 47 | } 48 | 49 | verifyMessage(signedMessage, rawMessage) { 50 | return Promise.reject("Can't verify message.") 51 | } 52 | 53 | _prompt(type, params) { 54 | return this._sendMessage({type, ...params}) 55 | } 56 | 57 | _sendMessage(message) { 58 | return new Promise((resolve, reject) => { 59 | window.postMessage({...message, application: 'kWh'}, '*') 60 | 61 | function handleWindowMessage(ev) { 62 | if (!ev.data || ev.data.application !== 'kWh' || !ev.data.response) { 63 | return 64 | } 65 | 66 | if (ev.data.error) { 67 | reject(new Error(ev.data.error)) 68 | } else { 69 | resolve(ev.data.data) 70 | } 71 | 72 | window.removeEventListener('message', handleWindowMessage) 73 | } 74 | 75 | window.addEventListener('message', handleWindowMessage) 76 | }) 77 | } 78 | } 79 | 80 | window.webln = new WebLNProvider() 81 | -------------------------------------------------------------------------------- /src/interfaces/index.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import React from 'react' // eslint-disable-line 4 | 5 | import * as lightningd_spark from './lightningd_spark' 6 | import * as eclair from './eclair' 7 | import * as ptarmigan from './ptarmigan' 8 | import {getRpcParams, structuredprint} from '../utils' 9 | 10 | const kinds = { 11 | lightningd_spark, 12 | eclair, 13 | ptarmigan 14 | } 15 | 16 | export const defs = [ 17 | { 18 | label: 'lightningd', 19 | value: 'lightningd_spark', 20 | title: 'Spark RPC Server', 21 | subtitle: ( 22 | <> 23 | Set up a Spark{' '} 24 | RPC endpoint server tied 25 | to your c-lightning node. 26 | 27 | ), 28 | fields: ['endpoint', 'username', 'password'] 29 | }, 30 | { 31 | label: 'Eclair', 32 | value: 'eclair', 33 | title: 'Eclair HTTP API', 34 | subtitle: ( 35 | <> 36 | Use Eclair >=0.3 and{' '} 37 | 38 | enable the API in the configuration 39 | {' '} 40 | so kWh can talk to it. 41 | 42 | ), 43 | fields: ['endpoint', 'password'] 44 | }, 45 | { 46 | label: 'Ptarmigan', 47 | value: 'ptarmigan', 48 | title: 'Ptarmigan REST API', 49 | subtitle: ( 50 | <> 51 | Start the{' '} 52 | 53 | REST API application 54 | {' '} 55 | for your Ptarmigan node and paste the URL here. 56 | 57 | ), 58 | fields: ['endpoint'] 59 | } 60 | ] 61 | 62 | export function handleRPC(rpcField = {}) { 63 | return getRpcParams().then(({kind}) => { 64 | for (let method in rpcField) { 65 | let args = rpcField[method] 66 | console.log(`[rpc][${kind}]: ${method} ${structuredprint(args)}`) 67 | return kinds[kind][method].apply(null, args) 68 | } 69 | }) 70 | } 71 | 72 | var currentListener 73 | 74 | export function listenForEvents(callback) { 75 | return getRpcParams().then(({kind}) => { 76 | if (currentListener) { 77 | console.log(`[stop-listening][${currentListener}]`) 78 | kinds[currentListener].cleanupListener() 79 | } 80 | 81 | console.log(`[listening][${kind}]`) 82 | let startListening = kinds[kind].listenForEvents(callback) 83 | Promise.all([startListening, kind]).then(([ok, kind]) => { 84 | if (ok) { 85 | currentListener = kind 86 | } 87 | }) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | kWh 3 |

4 | 5 |

The companion browser extension for c-lightning, Eclair and Ptarmigan nodes.

6 | 7 |

8 | Install for Firefox 9 | Install for Chrome 10 |

11 | 12 | ## Features 13 | 14 | - Browse balance and latest transactions; 15 | - [`webln`](https://webln.dev/#/) support; 16 | - Pay highlighted invoice with a context menu click; 17 | - Handle `lightning:` links; 18 | - ["Generate invoice here"](https://youtu.be/wzkxxz5FsJo) context menu; 19 | - Manual payments and invoice creation; 20 | - Instant notifications on received payments; 21 | - No window popups, all interactions happen in the `browserAction` popup. 22 | 23 |

24 | 25 |

26 | 27 | ## Requirements 28 | 29 | Either 30 | 31 | * a [lightningd](https://github.com/ElementsProject/lightning/) node with a [Spark](https://github.com/shesek/spark-wallet) [RPC server](https://github.com/fiatjaf/sparko) in front of it; 32 | * an [Eclair](https://github.com/ACINQ/eclair) node with the [API](https://github.com/ACINQ/eclair#configuring-eclair) enabled and accessible; 33 | * a [Ptarmigan](https://github.com/nayutaco/ptarmigan) node with the [REST API Node.js app](https://github.com/nayutaco/ptarmigan/blob/master/docs/howtouse_rest_api.md) running. 34 | 35 | ### Caveats 36 | 37 | * If you are using **Google Chrome**, consider enabling the `Experimental Extension APIs` flag on chrome://flags for a better experience. Read more in [this issue](https://github.com/fiatjaf/kwh/issues/4#issuecomment-485288552). 38 | * If you are using Eclair, [the WebSocket won't connect on Firefox](https://github.com/ACINQ/eclair/issues/1001), so you won't get notifications when a payment is sent or received, the experience will not be as nice as if it did. 39 | 40 | ## Build instructions 41 | 42 | You'll need: Node.js>=10, npm, Make 43 | 44 | ``` 45 | npm install 46 | make extension.zip 47 | ``` 48 | 49 | --- 50 | 51 |
Icons made by smalllikeart from Flaticon and licensed by CC 3.0 BY.
52 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import React, {useState, useEffect} from 'react' // eslint-disable-line 5 | import {render} from 'react-dom' 6 | import debounce from 'debounce-with-args' 7 | 8 | import {getRpcParams, defaultRpcParams} from './utils' 9 | import {defs} from './interfaces' 10 | 11 | export function RPCParams() { 12 | let [options, setOptions] = useState(defaultRpcParams) 13 | let [initialValues, setInitialValues] = useState(defaultRpcParams) 14 | let [saved, setSaved] = useState(false) 15 | 16 | useEffect(() => { 17 | getRpcParams().then(values => { 18 | setOptions(values) 19 | setInitialValues(values) 20 | }) 21 | }, []) 22 | 23 | const saveOptions = debounce( 24 | function(newValues) { 25 | browser.storage.local.set(newValues).then(() => { 26 | setSaved(true) 27 | setTimeout(() => { 28 | setSaved(false) 29 | }, 2500) 30 | }) 31 | }, 32 | 900, 33 | () => 'options' 34 | ) 35 | 36 | function handleChange(e) { 37 | let k = e.target.name 38 | let v = e.target.value.trim() 39 | let newValues = {...options, [k]: v} 40 | setOptions(newValues) 41 | saveOptions(newValues) 42 | } 43 | 44 | var currentInterface 45 | for (let i = 0; i < defs.length; i++) { 46 | if (defs[i].value === options.kind) { 47 | currentInterface = defs[i] 48 | break 49 | } 50 | } 51 | 52 | return ( 53 |
54 |
55 | {defs.map(({label: labelName, value}) => ( 56 | 66 | ))} 67 |
68 | 69 |
70 |

{currentInterface.title}

71 |

{currentInterface.subtitle}

72 |
73 |
74 | {currentInterface.fields.map(attr => ( 75 |
76 | 92 |
93 | ))} 94 |
95 | saved! 96 |
97 |
98 |
99 | ) 100 | } 101 | 102 | function App() { 103 | return 104 | } 105 | 106 | render(, document.getElementById('root')) 107 | -------------------------------------------------------------------------------- /src/interfaces/ptarmigan.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import {getRpcParams, backoff, normalizeURL} from '../utils' 4 | 5 | const fetch = window.fetch 6 | 7 | export function getInfo() { 8 | return rpcCall('getinfo').then(info => ({id: info.node_id})) 9 | } 10 | 11 | export function summary() { 12 | return Promise.all([ 13 | rpcCall('getinfo'), 14 | rpcCall('listpayments').then(pmts => 15 | pmts.filter(pmt => pmt.state === 'succeeded').slice(-15) 16 | ) 17 | ]) 18 | .then(([info, payments]) => 19 | Promise.all([ 20 | info, 21 | payments, 22 | Promise.all(payments.map(pmt => pmt.invoice).map(decode)) 23 | ]) 24 | ) 25 | .then(([{node_id, total_local_msat}, payments, decodedpayments]) => ({ 26 | info: { 27 | id: node_id 28 | }, 29 | balance: total_local_msat / 1000, 30 | transactions: payments 31 | .map((pmt, i) => ({ 32 | date: decodedpayments[i].creation, 33 | amount: -decodedpayments[i].msatoshi, 34 | fees: pmt.additional_amount_msat, 35 | description: decodedpayments[i].description 36 | })) 37 | .sort((a, b) => b.date - a.date) 38 | .slice(0, 15) 39 | })) 40 | } 41 | 42 | export function pay(bolt11, msatoshi = undefined) { 43 | if (!msatoshi) msatoshi = 0 44 | 45 | return rpcCall( 46 | 'sendpayment', 47 | {bolt11, addAmountMsat: msatoshi} /* returns just a payment id */ 48 | ) 49 | .then(( 50 | {payment_id} /* queyr listpayments until we get a success or error */ 51 | ) => 52 | backoff(() => 53 | rpcCall('listpayments').then(payments => { 54 | for (let i = 0; i < payments.length; i++) { 55 | let payment = payments[i] 56 | if (payment.payment_id !== payment_id) continue 57 | 58 | // if still processing, reject promise so backoff continues trying 59 | if (payment.state === 'processing') { 60 | return Promise.reject(payment) 61 | } 62 | 63 | // finished processing, return, but payment may have failed 64 | return Promise.resolve(payment) 65 | } 66 | 67 | // payment not found, return null 68 | return Promise.resolve(null) 69 | }) 70 | ) 71 | ) 72 | .then(payment => { 73 | // post process 74 | if (payment === null) throw new Error('Payment not found.') 75 | if (payment.state === 'failed') throw new Error('Payment failed.') 76 | return Promise.all([payment, decode(payment.invoice)]) 77 | }) 78 | .then(([payment, decoded]) => ({ 79 | msatoshi_paid: decoded.msatoshi, 80 | msatoshi_fees: payment.additional_amount_msat, 81 | preimage: payment.preimage 82 | })) 83 | } 84 | 85 | export function decode(bolt11) { 86 | return rpcCall('decodeinvoice', {bolt11}).then( 87 | ({ 88 | description_string, 89 | payment_hash, 90 | pubkey, 91 | amount_msat, 92 | expiry, 93 | timestamp 94 | }) => ({ 95 | description: description_string, 96 | msatoshi: amount_msat, 97 | nodeid: pubkey, 98 | hash: payment_hash, 99 | creation: Date.parse(timestamp) / 1000, 100 | expiry 101 | }) 102 | ) 103 | } 104 | 105 | export function makeInvoice(msatoshi, description) { 106 | return rpcCall('createinvoice', {amountMsat: msatoshi}).then( 107 | ({bolt11, hash}) => ({bolt11, hash}) 108 | ) 109 | } 110 | 111 | // not yet supported on ptarmigan 112 | export function cleanupListener() {} 113 | export function listenForEvents() {} 114 | 115 | function rpcCall(method, params = {}) { 116 | return getRpcParams().then(({endpoint, username, password}) => { 117 | return fetch(normalizeURL(endpoint) + '/' + method, { 118 | method: 'POST', 119 | headers: { 120 | 'Content-Type': 'application/json', 121 | Accept: 'application/json' 122 | }, 123 | body: JSON.stringify(params) 124 | }) 125 | .then(r => r.json()) 126 | .then(res => { 127 | if (res.error) { 128 | throw new Error(res.error.message || res.error.code) 129 | } 130 | 131 | return res.result 132 | }) 133 | }) 134 | } 135 | -------------------------------------------------------------------------------- /src/popup.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import React, {useState, useEffect} from 'react' // eslint-disable-line 5 | import {render} from 'react-dom' 6 | 7 | import { 8 | BLANK, 9 | HOME, 10 | PROMPT_PAYMENT, 11 | MAKE_INVOICE, 12 | PROMPT_INVOICE, 13 | MAKE_PAYMENT 14 | } from './constants' 15 | import Home from './components/Home' 16 | import Payment from './components/Payment' 17 | import Invoice from './components/Invoice' 18 | import {RPCParams} from './options' 19 | import {structuredprint, rpcParamsAreSet} from './utils' 20 | 21 | export const CurrentContext = React.createContext({action: null, tab: null}) 22 | 23 | function App() { 24 | let [currentAction, setAction] = useState(null) 25 | let [proxiedTab, setProxiedTab] = useState(null) 26 | let [missingRpcParams, setMissingRpcParams] = useState(null) 27 | 28 | useEffect(() => { 29 | rpcParamsAreSet().then(ok => { 30 | setMissingRpcParams(!ok) 31 | }) 32 | }, []) 33 | 34 | useEffect(() => { 35 | // when this page is rendered, query the current action 36 | browser.runtime.sendMessage({getInit: true}).then(({action, tab}) => { 37 | console.log(`[action]: ${structuredprint(action)} tab=${tab.id}`) 38 | setAction(action) 39 | setProxiedTab(tab) 40 | }) 41 | 42 | // if this page is already opened when a new action is set, react to it 43 | browser.runtime.onMessage.addListener(newActionListener) 44 | return () => browser.runtime.onMessage.removeListener(newActionListener) 45 | function newActionListener(message) { 46 | if (message.setAction) { 47 | console.log(`[action]: ${JSON.stringify(message.setAction)}`) 48 | setAction(message.setAction) 49 | } 50 | } 51 | }, []) 52 | 53 | if (!proxiedTab) return
54 | 55 | function navigate(e) { 56 | e.preventDefault() 57 | navigateTo(e.target.dataset.action) 58 | } 59 | 60 | function navigateTo(type) { 61 | let action = {type} 62 | setAction(action) 63 | browser.runtime.sendMessage({setAction: action, tab: proxiedTab}) 64 | } 65 | 66 | var selectedMenu 67 | var page =
68 | switch (currentAction && currentAction.type) { 69 | case BLANK: 70 | page =
71 | break 72 | case HOME: 73 | selectedMenu = HOME 74 | page = 75 | break 76 | case MAKE_PAYMENT: 77 | case PROMPT_PAYMENT: 78 | selectedMenu = MAKE_PAYMENT 79 | page = 80 | break 81 | case MAKE_INVOICE: 82 | case PROMPT_INVOICE: 83 | selectedMenu = MAKE_INVOICE 84 | page = 85 | break 86 | } 87 | 88 | let navItemClasses = 'link dim f6 dib pointer ma2 pa2 bg-animate' 89 | let activeNavItemClasses = ' b green bg-light-yellow' 90 | 91 | return ( 92 |
93 | {missingRpcParams ? ( 94 | 95 | ) : ( 96 | <> 97 | {' '} 98 | 130 | 133 |
{page}
134 |
{' '} 135 | 136 | )} 137 |
138 | ) 139 | } 140 | 141 | render(, document.getElementById('root')) 142 | -------------------------------------------------------------------------------- /src/components/Invoice.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import React, {useState, useEffect, useContext, useRef} from 'react' // eslint-disable-line 5 | import ContentEditable from 'react-contenteditable' 6 | 7 | import {CurrentContext} from '../popup' 8 | import ShowInvoice from './ShowInvoice' 9 | 10 | export default function Invoice() { 11 | const contentEditable = useRef() 12 | 13 | let {action, tab} = useContext(CurrentContext) 14 | 15 | let defaultAmount = 16 | action.defaultAmount || action.maximumAmount || action.minimumAmount || 100 17 | let amountFixed = !!action.amount 18 | 19 | let [invoiceData, setInvoiceData] = useState(action.invoiceData) 20 | let [satoshis, setSatoshis] = useState(action.amount || defaultAmount) 21 | let [desc, setDesc] = useState( 22 | action.defaultMemo || (action.origin ? action.origin.domain : `kWh invoice`) 23 | ) 24 | let [invoicePaid, setInvoicePaid] = useState(false) 25 | 26 | useEffect( 27 | () => { 28 | if (!invoiceData) return 29 | 30 | browser.runtime.onMessage.addListener(handleMessage) 31 | return () => { 32 | browser.runtime.onMessage.removeListener(handleMessage) 33 | } 34 | 35 | function handleMessage(message) { 36 | if (message.invoicePaid && message.hash === invoiceData.hash) { 37 | setInvoicePaid(true) 38 | return Promise.resolve(tab) 39 | } 40 | } 41 | }, 42 | [invoiceData] 43 | ) 44 | 45 | function makeInvoice(e) { 46 | e.preventDefault() 47 | 48 | browser.runtime 49 | .sendMessage({ 50 | tab, 51 | rpc: { 52 | makeInvoice: [satoshis * 1000, desc.replace(/ /g, '').trim()] 53 | }, 54 | behaviors: { 55 | success: [ 56 | 'paste-invoice', 57 | 'return-invoice', 58 | 'cleanup-browser-action', 59 | 'save-invoice-to-current-action' 60 | ], 61 | failure: ['notify-invoice-error', 'cleanup-browser-action'] 62 | } 63 | }) 64 | .then(invoiceData => { 65 | setInvoiceData(invoiceData) 66 | }) 67 | } 68 | 69 | let inputClasses = 'dark-pink hover-gold code b f6 bg-transparent pa1' 70 | 71 | return ( 72 |
73 | {invoiceData ? ( 74 | invoicePaid ? ( 75 |
76 | 81 | 88 | 93 | 94 |
95 | ) : ( 96 | 97 | ) 98 | ) : ( 99 |
100 |
101 | Making an invoice of 102 | {amountFixed ? ( 103 | {action.amount} 104 | ) : ( 105 | setSatoshis(e.target.value)} 109 | step="10" 110 | min={action.minimumAmount || 1} 111 | max={action.maximumAmount || Infinity} 112 | /> 113 | )} 114 | satoshis described as{' '} 115 | setDesc(e.target.value)} 120 | tagName="span" 121 | /> 122 | . 123 |
124 |
125 | 128 |
129 |
130 | )} 131 |
132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /src/components/Home.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import React, {useState, useEffect, useContext} from 'react' // eslint-disable-line 5 | import friendlyTime from 'friendly-time' 6 | 7 | import {CurrentContext} from '../popup' 8 | import {formatmsat} from '../utils' 9 | 10 | export default function Home() { 11 | let {tab} = useContext(CurrentContext) 12 | 13 | let [summary, setSummary] = useState({info: {}}) 14 | let [blocked, setBlocked] = useState({}) 15 | 16 | useEffect(() => { 17 | browser.runtime.sendMessage({tab, rpc: {summary: []}}).then(setSummary) 18 | browser.runtime.sendMessage({tab, getBlocked: true}).then(setBlocked) 19 | }, []) 20 | 21 | function unblock(e) { 22 | e.preventDefault() 23 | let domain = e.target.dataset.domain 24 | browser.storage.local.get('blocked').then(({blocked}) => { 25 | delete blocked[domain] 26 | return browser.storage.local.set({blocked}).then(() => { 27 | setBlocked(blocked) 28 | }) 29 | }) 30 | } 31 | 32 | return ( 33 |
34 |

Balance

35 |
{summary.balance || '~'} satoshi
36 |

Latest transactions

37 |
38 | 39 | 40 | {(summary.transactions || []).map((tx, i) => ( 41 | 42 | 43 | 55 | 60 | 61 | ))} 62 | 63 |
{formatDate(tx.date)} 53 | {formatmsat(tx.amount)} 54 | 56 | {tx.description.length > 17 57 | ? tx.description.slice(0, 16) + '…' 58 | : tx.description} 59 |
64 |
65 |

Node

66 |
70 | 71 | 72 | {['alias', 'id', 'address', 'blockheight'] 73 | .map(attr => [attr, summary.info[attr]]) 74 | .filter(([_, v]) => v) 75 | .map(([attr, val]) => ( 76 | 77 | 78 | 81 | 82 | ))} 83 | 84 |
{attr} 79 | {summary.info[attr]} 80 |
85 |
86 | {Object.keys(blocked).length > 0 && ( 87 | <> 88 |

Blacklist

89 |
90 | 91 | 92 | {Object.keys(blocked) 93 | .map(domain => [domain, blocked[domain]]) 94 | .map(([domain, _]) => ( 95 | 96 | 97 | 106 | 107 | ))} 108 | 109 |
{domain} 98 | 105 |
110 |
111 | 112 | )} 113 |
114 | ) 115 | } 116 | 117 | const now = Date.now() 118 | function formatDate(timestamp) { 119 | let date = new Date(timestamp * 1000) 120 | if (timestamp * 1000 + 86400 * 1000 * 2 < now) { 121 | return date.toISOString().split('T')[0] 122 | } 123 | 124 | return friendlyTime(date) 125 | } 126 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import {defs as kindDefs} from './interfaces' 5 | 6 | export function getOriginData() { 7 | return { 8 | domain: getDomain(), 9 | name: getName().split(/\W[^\w ]\W/)[0], 10 | icon: getIcon() 11 | } 12 | 13 | function getDomain() { 14 | var domain = window.location.host 15 | if (domain.slice(0, 4) === 'www.') { 16 | domain = domain.slice(4) 17 | } 18 | return domain 19 | } 20 | 21 | function getName() { 22 | let nameMeta = document.querySelector( 23 | 'head > meta[property="og:site_name"]' 24 | ) 25 | if (nameMeta) return nameMeta.content 26 | 27 | let titleMeta = document.querySelector('head > meta[name="title"]') 28 | if (titleMeta) return titleMeta.content 29 | 30 | return document.title 31 | } 32 | 33 | function getIcon() { 34 | let allIcons = Array.from( 35 | document.querySelectorAll('head > link[rel="icon"]') 36 | ).filter(icon => !!icon.href) 37 | 38 | if (allIcons.length) { 39 | let href = allIcons.sort((a, b) => { 40 | let aSize = parseInt(a.getAttribute('sizes') || '0', 10) 41 | let bSize = parseInt(b.getAttribute('sizes') || '0', 10) 42 | return bSize - aSize 43 | })[0].href 44 | return makeAbsoluteUrl(href) 45 | } 46 | 47 | // Try for favicon 48 | let favicon = document.querySelector('head > link[rel="shortcut icon"]') 49 | if (favicon) return makeAbsoluteUrl(favicon.href) 50 | 51 | // fallback to default favicon path, let it be replaced in view if it fails 52 | return `${window.location.origin}/favicon.ico` 53 | } 54 | 55 | function makeAbsoluteUrl(path) { 56 | return new URL(path, window.location.origin).href 57 | } 58 | } 59 | 60 | export const defaultRpcParams = { 61 | kind: 'lightningd_spark', 62 | endpoint: 'http://localhost:9737/', 63 | username: '', 64 | password: '' 65 | } 66 | 67 | export function getRpcParams() { 68 | return browser.storage.local.get(defaultRpcParams) 69 | } 70 | 71 | export function normalizeURL(endpoint) { 72 | let url = new URL(endpoint.trim(), 'http://localhost:9737/') 73 | return url.protocol + '//' + url.host 74 | } 75 | 76 | export function rpcParamsAreSet() { 77 | return browser.storage.local.get(defaultRpcParams).then(rpcParams => { 78 | var currentKindDef = null 79 | 80 | for (let i = 0; i < kindDefs.length; i++) { 81 | if (kindDefs[i].value === rpcParams.kind) { 82 | currentKindDef = kindDefs[i] 83 | } 84 | } 85 | 86 | // check if for the current kind (eclair, lightningd_spark etc.) 87 | // all the required options are set. 88 | for (let i = 0; i < currentKindDef.fields.length; i++) { 89 | let field = currentKindDef.fields[i] 90 | if (!rpcParams[field] || rpcParams[field] === '') { 91 | return false 92 | } 93 | } 94 | 95 | return true 96 | }) 97 | } 98 | 99 | export function formatmsat(msatoshis) { 100 | if (Math.abs(msatoshis) < 1000) { 101 | return `${msatoshis} msat` 102 | } 103 | 104 | if (msatoshis === 1000) return '1 satoshi' 105 | 106 | for (let prec = 3; prec >= 0; prec--) { 107 | let dec = 10 ** prec 108 | if (msatoshis / dec === parseInt(msatoshis / dec)) { 109 | return `${(msatoshis / 1000).toFixed(3 - prec)} sat` 110 | } 111 | } 112 | } 113 | 114 | export function structuredprint(o) { 115 | return Object.keys(o) 116 | .map( 117 | k => `${k}='${typeof o[k] === 'string' ? o[k] : JSON.stringify(o[k])}'` 118 | ) 119 | .join(' ') 120 | } 121 | 122 | export function abbreviate(longstring) { 123 | return `${longstring.slice(0, 4)}…${longstring.slice(-4)}` 124 | } 125 | 126 | export function notify(params, notificationId = null) { 127 | notificationId = notificationId || 'n-' + Math.random() 128 | params = {type: 'basic', iconUrl: '/icon64.png', ...params} 129 | browser.notifications.create(notificationId, params) 130 | setTimeout(() => { 131 | browser.notifications.clear(notificationId) 132 | }, 3000) 133 | } 134 | 135 | export function backoff(fn, nattempts = 10, multiplier = 1) { 136 | // fibonacci because why not? 137 | var currv = 1 138 | var prevv = 1 139 | 140 | var res = Promise.reject() 141 | 142 | for (let attempt = 0; attempt < nattempts; attempt++) { 143 | let newv = prevv + currv 144 | prevv = currv 145 | currv = newv 146 | ;((attempt, wait) => { 147 | res = res.catch(() => { 148 | return new Promise((resolve, reject) => { 149 | setTimeout(() => { 150 | fn(attempt, wait) 151 | .then(resolve) 152 | .catch(reject) 153 | }, wait * 1000) 154 | }) 155 | }) 156 | })(attempt, currv * multiplier) 157 | } 158 | 159 | return res 160 | } 161 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | 5 | const Keysim = require('keysim') 6 | 7 | import {PROMPT_PAYMENT, REQUEST_GETINFO} from './constants' 8 | import {getOriginData, rpcParamsAreSet, structuredprint} from './utils' 9 | 10 | if (document) { 11 | // intercept any `lightning:` links 12 | document.addEventListener('DOMContentLoaded', () => { 13 | document.body.addEventListener('click', ev => { 14 | let target = ev.target 15 | if (!target || !target.closest) { 16 | return 17 | } 18 | 19 | let lightningLink = target.closest('[href^="lightning:"]') 20 | if (lightningLink) { 21 | let href = lightningLink.getAttribute('href') 22 | let invoice = href.replace('lightning:', '') 23 | browser.runtime.sendMessage({ 24 | setAction: { 25 | type: PROMPT_PAYMENT, 26 | origin: getOriginData(), 27 | invoice 28 | } 29 | }) 30 | ev.preventDefault() 31 | } 32 | }) 33 | }) 34 | 35 | // listen for right-click events to show the context menu item 36 | // when a potential lightning invoice is selected. 37 | // also works for numeric amounts that get passed to the generate 38 | // invoice context menu. 39 | document.addEventListener( 40 | 'mousedown', 41 | event => { 42 | // 2 = right mouse button 43 | if (event.button === 2) { 44 | var invoice = window.getSelection().toString() 45 | // if nothing selected, try to get the text of the right-clicked element. 46 | if (!invoice && event.target) { 47 | let target = event.target 48 | invoice = target.innerText || target.value 49 | } 50 | if (invoice) { 51 | // send message to background script to toggle the context menu item 52 | // based on the content of the right-clicked text 53 | browser.runtime.sendMessage({ 54 | contextMenu: true, 55 | text: invoice 56 | }) 57 | } 58 | } 59 | }, 60 | true 61 | ) 62 | 63 | // respond to all messages (because apparently content scripts can 64 | // only have one onMessage handler) 65 | browser.runtime.onMessage.addListener( 66 | ({getOrigin, paste, elementId, bolt11}) => { 67 | if (paste) { 68 | // paste invoices on specific input fields 69 | let el = elementId 70 | ? browser.contextMenu.getTargetElement(elementId) 71 | : document.activeElement 72 | 73 | el.focus() 74 | 75 | let keyboard = Keysim.Keyboard.US_ENGLISH 76 | el.value = bolt11 77 | keyboard.dispatchEventsForInput(bolt11, el) 78 | } else if (getOrigin) { 79 | // return tab data to background page 80 | return Promise.resolve(getOriginData()) 81 | } 82 | } 83 | ) 84 | 85 | // insert webln 86 | var script = document.createElement('script') 87 | script.src = browser.runtime.getURL('webln-bundle.js') 88 | ;(document.head || document.documentElement).appendChild(script) 89 | 90 | // communicate with webln 91 | var _blocked = null // blocked status cache 92 | window.addEventListener('message', ev => { 93 | // only accept messages from the current window 94 | if (ev.source !== window) return 95 | if (!ev.data || ev.data.application !== 'kWh' || ev.data.response) return 96 | 97 | let origin = getOriginData() 98 | 99 | let {type, ...extra} = ev.data 100 | let action = { 101 | ...extra, 102 | type, 103 | origin 104 | } 105 | 106 | Promise.resolve() 107 | .then(() => { 108 | if (ev.data.getBlocked) { 109 | // return blocked status for this site 110 | if (_blocked !== null) return _blocked // cached 111 | 112 | // it's always blocked if the user has no options 113 | return rpcParamsAreSet().then(ok => { 114 | if (!ok) throw new Error('Lightning RPC params are not set.') 115 | 116 | return browser.runtime 117 | .sendMessage({ 118 | getBlocked: true, 119 | domain: origin.domain 120 | }) 121 | .then(blocked => { 122 | _blocked = blocked 123 | return blocked 124 | }) 125 | }) 126 | } else { 127 | // default: an action or prompt 128 | console.log(`[kWh]: ${type} ${structuredprint(extra)} ${structuredprint(origin)}`) 129 | 130 | switch (type) { 131 | case REQUEST_GETINFO: 132 | return browser.runtime.sendMessage({ 133 | rpc: {getInfo: []} 134 | }) 135 | default: 136 | return null 137 | } 138 | } 139 | }) 140 | .then(earlyResponse => { 141 | if (earlyResponse !== null && earlyResponse !== undefined) { 142 | // we have a response already. end here. 143 | return earlyResponse 144 | } else { 145 | // proceed to call the background page 146 | // and prompt the user if necessary 147 | return browser.runtime.sendMessage({setAction: action}) 148 | } 149 | }) 150 | .then(response => { 151 | window.postMessage( 152 | {response: true, application: 'kWh', data: response}, 153 | '*' 154 | ) 155 | }) 156 | .catch(err => { 157 | window.postMessage( 158 | { 159 | response: true, 160 | application: 'kWh', 161 | error: err ? err.message || err : err 162 | }, 163 | '*' 164 | ) 165 | }) 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /src/components/Payment.js: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import browser from 'webextension-polyfill' 4 | import React, {useState, useEffect, useContext} from 'react' // eslint-disable-line 5 | 6 | import {CurrentContext} from '../popup' 7 | import {formatmsat, abbreviate} from '../utils' 8 | 9 | export default function Payment() { 10 | let {action, tab} = useContext(CurrentContext) 11 | 12 | let [bolt11, setBolt11] = useState(action.invoice || '') 13 | let [doneTyping, setDoneTyping] = useState(!!action.invoice) 14 | let [paymentPending, setPaymentPending] = useState(!!action.pending) 15 | 16 | let [invoiceData, setInvoiceData] = useState(null) 17 | let [satoshiActual, setSatoshiActual] = useState(0) 18 | 19 | let [iconShown, showIcon] = useState(false) 20 | 21 | useEffect( 22 | () => { 23 | if (bolt11 === '' || doneTyping === false || paymentPending) return 24 | 25 | browser.runtime 26 | .sendMessage({tab, rpc: {decode: [bolt11]}}) 27 | .then(data => { 28 | setInvoiceData(data) 29 | }) 30 | .catch(err => console.log('error', err)) 31 | }, 32 | [bolt11, doneTyping] 33 | ) 34 | 35 | function handleIconLoaded() { 36 | showIcon(true) 37 | } 38 | 39 | function typedInvoice(e) { 40 | e.preventDefault() 41 | setDoneTyping(true) 42 | } 43 | 44 | function sendPayment(e) { 45 | e.preventDefault() 46 | 47 | // set pending 48 | setPaymentPending(true) 49 | browser.runtime.sendMessage({ 50 | tab, 51 | triggerBehaviors: true, 52 | behaviors: ['save-pending-to-current-action'] 53 | }) 54 | 55 | // actually send payment 56 | browser.runtime 57 | .sendMessage({ 58 | tab, 59 | rpc: { 60 | pay: [ 61 | bolt11, 62 | satoshiActual ? satoshiActual * 1000 : undefined, 63 | invoiceData.description 64 | ] 65 | }, 66 | behaviors: { 67 | success: [ 68 | 'notify-payment-success', 69 | 'return-preimage', 70 | 'navigate-home' 71 | ], 72 | failure: [ 73 | 'notify-payment-error', 74 | 'return-payment-error', 75 | 'navigate-home' 76 | ] 77 | } 78 | }) 79 | .then(() => { 80 | setPaymentPending(false) 81 | window.close() 82 | }) 83 | .catch(() => { 84 | window.close() 85 | }) 86 | } 87 | 88 | if (paymentPending) { 89 | return ( 90 |
91 |
sending payment…
92 |
93 | ) 94 | } 95 | 96 | if (!doneTyping) { 97 | return ( 98 |
99 |