Merge pull request #10 from kremalicious/feature/touchbar-electron

Electron Touch Bar
This commit is contained in:
Matthias Kretschmann 2019-05-25 03:21:31 +02:00 committed by GitHub
commit db3919154f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 225 additions and 181 deletions

View File

@ -26,6 +26,7 @@
- [Features](#features)
- [Download](#download)
- [Development](#development)
- [Configuration](#configuration)
- [Build packages](#build-packages)
- [License](#license)
@ -39,9 +40,10 @@
- re-fetches everything automatically every minute
- balances are fetched via etherscan.io API
- spot prices are fetched from coingecko.com API
- detects system locale for number formatting
- detects dark appearance setting and switches to dark theme automatically (macOS only)
- detects system accent color and uses it as primary color (macOS & Windows only)
- Touch Bar support (macOS only)
- detects system locale for number formatting
- currently highly optimized for macOS, your mileage on Windows or Linux may vary
## Download
@ -57,6 +59,8 @@ Alternatively, you can [build the app on your system](#build-packages).
## Development
The main app is a React app in `src/renderer/` wrapped within an Electron app defined in `src/main/`.
Clone, and run:
```bash
@ -70,6 +74,18 @@ npm install
npm start
```
## Configuration
The app has a settings screen where you can add your account addresses.
When building the app yourself, you can configure more in the `src/config.js` file:
| Key | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `conversions` | Array defining the currencies the Ocean balance is converted to. Every currency listed here will appear in the ticker buttons. |
| `refreshInterval` | Defines the interval prices and balances are refetched. |
| `oceanTokenContract` | Contract address of the Ocean Token. You should not change this. |
## Build packages
```bash

View File

@ -3,7 +3,7 @@
"productName": "Blowfish",
"version": "1.0.2",
"description": "🐡 Simple Electron-based desktop app to retrieve and display your total Ocean Token balances.",
"main": "./src/main.js",
"main": "./src/main/index.js",
"scripts": {
"test": "eslint ./src/**/*.{js,jsx} && stylelint ./src/**/*.css",
"start": "webpack-dev-server --hot --host 0.0.0.0 --config=./webpack.dev.config.js",
@ -21,28 +21,23 @@
"license": "MIT",
"dependencies": {
"@coingecko/cryptoformat": "^0.3.1",
"@reach/router": "^1.2.1",
"ethereum-address": "0.0.4",
"ms": "^2.1.1",
"react": "^16.8.6",
"react-blockies": "^1.4.1",
"react-dom": "^16.8.6",
"react-pose": "^4.0.8",
"react-touchbar-electron": "0.0.1"
"ms": "^2.1.1"
},
"devDependencies": {
"@babel/core": "^7.4.4",
"@babel/core": "^7.4.5",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"@babel/preset-env": "^7.4.5",
"@babel/preset-react": "^7.0.0",
"@babel/runtime": "^7.4.4",
"@babel/runtime": "^7.4.5",
"@reach/router": "^1.2.1",
"@svgr/webpack": "^4.2.0",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.6",
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^2.1.1",
"electron": "^6.0.0-beta.3",
"electron": "^5.0.2",
"electron-builder": "^20.40.2",
"electron-devtools-installer": "^2.2.4",
"electron-store": "^3.2.0",
@ -53,10 +48,14 @@
"html-webpack-plugin": "^3.2.0",
"prettier": "^1.17.0",
"prettier-stylelint": "^0.4.2",
"react": "^16.8.6",
"react-blockies": "^1.4.1",
"react-dom": "^16.8.6",
"react-pose": "^4.0.8",
"style-loader": "^0.23.1",
"stylelint": "^10.0.1",
"stylelint-config-standard": "^18.3.0",
"webpack": "^4.31.0",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2",
"webpack-dev-server": "^3.3.1"
},
@ -65,6 +64,7 @@
"appId": "com.kremalicious.blowfish",
"files": [
"./build/**/*",
"./src/main/**/*",
"./src/*.js",
"package.json"
],

View File

@ -1,49 +0,0 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { TouchBar, Button } from 'react-touchbar-electron'
import { cryptoFormatter } from '../../utils'
import { AppContext } from '../store/createContext'
const TouchbarItems = ({ prices, currency, toggleCurrencies, accentColor }) => (
<>
<Button
label={cryptoFormatter(1, 'ocean')}
onClick={() => toggleCurrencies('ocean')}
backgroundColor={currency === 'ocean' ? accentColor : '#141414'}
/>
{Object.keys(prices).map(key => (
<Button
key={key}
label={cryptoFormatter(prices[key], key)}
onClick={() => toggleCurrencies(key)}
backgroundColor={
currency !== 'ocean' && currency === key ? accentColor : '#141414'
}
/>
))}
</>
)
TouchbarItems.propTypes = {
prices: PropTypes.object.isRequired,
currency: PropTypes.string.isRequired,
toggleCurrencies: PropTypes.func.isRequired,
accentColor: PropTypes.string
}
export default class Touchbar extends PureComponent {
render() {
return (
<TouchBar>
<TouchbarItems
prices={this.context.prices}
currency={this.context.currency}
toggleCurrencies={this.context.toggleCurrencies}
accentColor={this.context.accentColor}
/>
</TouchBar>
)
}
}
Touchbar.contextType = AppContext

View File

@ -1,5 +1,5 @@
module.exports = {
prices: ['eur', 'usd', 'btc', 'eth'],
conversions: ['eur', 'usd', 'btc', 'eth'],
refreshInterval: '1m',
oceanTokenContract: '0x985dd3D42De1e256d09e1c10F112bCCB8015AD41'
}

View File

@ -1,9 +1,9 @@
const path = require('path')
const { app, BrowserWindow, systemPreferences } = require('electron')
const { touchBarWrapper } = require('react-touchbar-electron')
const pkg = require('../package.json')
const { app, BrowserWindow, systemPreferences, ipcMain } = require('electron')
const pkg = require('../../package.json')
const buildMenu = require('./menu')
const { rgbaToHex } = require('./utils')
const { buildTouchbar, updateTouchbar } = require('./touchbar')
const { rgbaToHex } = require('../utils')
let mainWindow
@ -20,6 +20,84 @@ if (
const width = 620
const height = 440
const createWindow = async () => {
const isDarkMode = systemPreferences.isDarkMode()
mainWindow = new BrowserWindow({
width,
height,
minWidth: width,
minHeight: height,
acceptFirstMouse: true,
titleBarStyle: 'hiddenInset',
fullscreenWindowTitle: true,
backgroundColor: isDarkMode ? '#141414' : '#fff',
frame: false,
show: false,
title: pkg.productName,
webPreferences: {
nodeIntegration: true,
scrollBounce: true
}
})
mainWindow.loadURL(
isDev
? 'http://localhost:8080'
: `file://${path.join(__dirname, '../../build/index.html')}`
)
createWindowEvents(mainWindow)
installDevTools(mainWindow)
mainWindow.once('ready-to-show', () => {
mainWindow.show()
mainWindow.focus()
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Load menubar
buildMenu(mainWindow)
// Load touchbar
if (process.platform === 'darwin') {
const accentColor = getAccentColor()
buildTouchbar(mainWindow, accentColor)
ipcMain.on('prices-updated', (event, pricesNew) => {
updateTouchbar(pricesNew, mainWindow, accentColor)
})
ipcMain.on('currency-updated', (event, pricesNew, currentCurrency) => {
updateTouchbar(pricesNew, mainWindow, accentColor, currentCurrency)
})
}
}
app.on('ready', () => {
createWindow()
mainWindow.webContents.on('dom-ready', () => {
switchTheme()
switchAccentColor()
})
})
// Quit when all windows are closed.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
const installDevTools = async mainWindow => {
if (isDev) {
const {
@ -66,80 +144,17 @@ const createWindowEvents = mainWindow => {
)
}
const createWindow = async () => {
const isDarkMode = systemPreferences.isDarkMode()
mainWindow = new BrowserWindow({
width,
height,
minWidth: width,
minHeight: height,
acceptFirstMouse: true,
titleBarStyle: 'hiddenInset',
fullscreenWindowTitle: true,
backgroundColor: isDarkMode ? '#141414' : '#fff',
frame: false,
show: false,
title: pkg.productName,
webPreferences: {
nodeIntegration: true,
scrollBounce: true
}
})
mainWindow.loadURL(
isDev
? 'http://localhost:8080'
: `file://${path.join(__dirname, '../build/index.html')}`
)
createWindowEvents(mainWindow)
installDevTools(mainWindow)
mainWindow.once('ready-to-show', () => {
mainWindow.show()
mainWindow.focus()
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Load menubar
buildMenu(mainWindow)
// Load touchbar
process.platform === 'darwin' && touchBarWrapper(mainWindow)
}
app.on('ready', () => {
createWindow()
mainWindow.webContents.on('dom-ready', () => {
switchTheme()
switchAccentColor()
})
})
// Quit when all windows are closed.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
//
// Accent color setting
// macOS & Windows
//
const switchAccentColor = () => {
const getAccentColor = () => {
const systemAccentColor = systemPreferences.getAccentColor()
const accentColor = rgbaToHex(systemAccentColor)
return rgbaToHex(systemAccentColor)
}
const switchAccentColor = () => {
const accentColor = getAccentColor()
mainWindow.webContents.send('accent-color', accentColor)
}

View File

@ -1,6 +1,6 @@
const { app, Menu } = require('electron')
const { openUrl } = require('./utils')
const { homepage } = require('../package.json')
const { openUrl } = require('../utils')
const { homepage } = require('../../package.json')
const buildMenu = mainWindow => {
const template = [

54
src/main/touchbar.js Normal file
View File

@ -0,0 +1,54 @@
const { TouchBar } = require('electron')
const { cryptoFormatter } = require('../utils')
const { conversions } = require('../config')
const { TouchBarButton } = TouchBar
const createButton = (
value,
key,
mainWindow,
accentColor,
currentCurrency = 'ocean'
) =>
new TouchBarButton({
label: cryptoFormatter(value, key),
click: () => mainWindow.webContents.send('setCurrency', key),
backgroundColor: key === currentCurrency ? accentColor : '#141414'
})
const buildTouchbar = (mainWindow, accentColor) => {
const touchBar = new TouchBar({
items: [
createButton(1, 'ocean', mainWindow, accentColor),
...conversions.map(key => createButton(0, key, mainWindow, accentColor))
]
})
mainWindow.setTouchBar(touchBar)
}
const updateTouchbar = (
pricesNew,
mainWindow,
accentColor,
currentCurrency = 'ocean'
) => {
const items = pricesNew.map(item => {
return createButton(
item[1],
item[0],
mainWindow,
accentColor,
currentCurrency
)
})
const touchBar = new TouchBar({
items: [...items]
})
mainWindow.setTouchBar(touchBar)
}
module.exports = { buildTouchbar, updateTouchbar }

View File

@ -8,7 +8,6 @@ import Home from './screens/Home'
import Preferences from './screens/Preferences'
import './App.css'
import { defaultAnimation } from './components/Animations'
import Touchbar from './components/Touchbar'
//
// Disable zooming
@ -51,8 +50,6 @@ export default class App extends PureComponent {
<Preferences path="/preferences" />
</PosedRouter>
</div>
<Touchbar />
</>
)
}

View File

@ -11,8 +11,10 @@ export default class Ticker extends PureComponent {
static contextType = AppContext
items = activeStyle =>
Object.keys(this.context.prices).map((key, i) => (
<Item key={i} className="number-unit">
// convert Map to array first, cause for...of or forEach returns undefined,
// so it cannot be mapped to a collection of elements
[...this.context.prices.entries()].map(([key, value]) => (
<Item key={key} className="number-unit">
<button
className="label label--price"
onClick={() => this.context.toggleCurrencies(key)}
@ -23,7 +25,7 @@ export default class Ticker extends PureComponent {
: {}
}
>
{cryptoFormatter(this.context.prices[key], key)}
{cryptoFormatter(value, key)}
</button>
</Item>
))

View File

@ -1,7 +1,7 @@
import React, { PureComponent } from 'react'
import { AppContext } from '../store/createContext'
import Balance from './Balance'
import { prices } from '../config'
import { conversions } from '../../config'
const calculateTotalBalance = (accounts, currency) => {
const balanceTotalArray = []
@ -23,15 +23,15 @@ export default class Total extends PureComponent {
static contextType = AppContext
render() {
const conversions = Object.assign(
...prices.map(key => ({
const conversionsBalance = Object.assign(
...conversions.map(key => ({
[key]: calculateTotalBalance(this.context.accounts, key)
}))
)
const balanceNew = {
ocean: calculateTotalBalance(this.context.accounts, 'ocean'),
...conversions
...conversionsBalance
}
return (

View File

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 871 B

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,6 +1,5 @@
import React from 'react'
import { render } from 'react-dom'
import { TouchBarProvider } from 'react-touchbar-electron'
import AppProvider from './store/AppProvider'
import App from './App'
@ -13,9 +12,7 @@ document.body.appendChild(root)
render(
<AppProvider>
<TouchBarProvider>
<App />
</TouchBarProvider>
<App />
</AppProvider>,
document.getElementById('root')
)

View File

@ -4,8 +4,14 @@ import ms from 'ms'
import { ipcRenderer } from 'electron'
import Store from 'electron-store'
import { AppContext } from './createContext'
import fetchData from '../util/fetch'
import { refreshInterval, prices, oceanTokenContract } from '../config'
import fetchData from '../utils/fetch'
import { refreshInterval, conversions, oceanTokenContract } from '../../config'
// construct initial prices Map to get consistent
// order for Ticker and Touchbar
let pricesMap = new Map()
pricesMap.set('ocean', 1)
conversions.map(key => pricesMap.set(key, 0))
export default class AppProvider extends PureComponent {
static propTypes = {
@ -19,18 +25,26 @@ export default class AppProvider extends PureComponent {
accounts: [],
currency: 'ocean',
needsConfig: false,
prices: Object.assign(...prices.map(key => ({ [key]: 0 }))),
toggleCurrencies: currency => this.setState({ currency }),
prices: pricesMap,
toggleCurrencies: currency => this.toggleCurrencies(currency),
setBalances: () => this.setBalances(),
accentColor: ''
}
async componentDidMount() {
// listener for accent color
ipcRenderer.on('accent-color', (event, accentColor) => {
this.setState({ accentColor })
})
await this.fetchAndSetPrices()
// listener for touchbar
ipcRenderer.on('setCurrency', (evt, currency) =>
this.state.toggleCurrencies(currency)
)
const newPrizes = await this.fetchAndSetPrices()
this.setState({ prices: newPrizes })
await this.setBalances()
setInterval(this.fetchAndSetPrices, ms(refreshInterval))
@ -66,19 +80,17 @@ export default class AppProvider extends PureComponent {
}
fetchAndSetPrices = async () => {
const currencies = prices.join(',')
const currencies = conversions.join(',')
const json = await fetchData(
`https://api.coingecko.com/api/v3/simple/price?ids=ocean-protocol&vs_currencies=${currencies}`
)
const newPrizes = Object.assign(
...prices.map(key => ({
ocean: 1,
[key]: json['ocean-protocol'][key]
}))
)
let newPrices = new Map(this.state.prices) // make a shallow copy of the Map
conversions.map(key => newPrices.set(key, json['ocean-protocol'][key])) // modify the copy
this.setState({ prices: newPrizes })
ipcRenderer.send('prices-updated', Array.from(newPrices)) // convert Map to array, ipc messages seem to kill it
this.setState({ prices: newPrices })
return newPrices
}
setBalances = async () => {
@ -89,9 +101,9 @@ export default class AppProvider extends PureComponent {
for (const account of accountsPref) {
const oceanBalance = await this.getBalance(account)
const conversions = Object.assign(
...prices.map(key => ({
[key]: oceanBalance * this.state.prices[key] || 0
const conversionsBalance = Object.assign(
...conversions.map(key => ({
[key]: oceanBalance * this.state.prices.get(key) || 0
}))
)
@ -99,7 +111,7 @@ export default class AppProvider extends PureComponent {
address: account,
balance: {
ocean: oceanBalance,
...conversions
...conversionsBalance
}
}
@ -111,6 +123,12 @@ export default class AppProvider extends PureComponent {
}
}
toggleCurrencies(currency) {
const pricesNew = Array.from(this.state.prices)
ipcRenderer.send('currency-updated', pricesNew, currency)
this.setState({ currency })
}
render() {
return (
<AppContext.Provider value={this.state}>

View File

@ -15,7 +15,7 @@ const rgbaToHex = color => {
}
const locale =
typeof navigator !== 'undefined' ? navigator.language : app.getLocale()
typeof navigator !== 'undefined' ? navigator.language : () => app.getLocale()
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat
const numberFormatter = value =>
@ -39,7 +39,7 @@ const cryptoFormatter = (value, currency) => {
if (currency === 'ocean') {
return formatOcean(value)
} else {
return formatCurrency(value, currency.toUpperCase(), locale.split('-')[0])
return formatCurrency(value, currency.toUpperCase(), locale)
.replace(/BTC/, 'Ƀ')
.replace(/ETH/, 'Ξ')
}

View File

@ -2,11 +2,10 @@ const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyPlugin = require('copy-webpack-plugin')
// Any directories you will be adding code/files into, need to be added to this array so webpack will pick them up
const defaultInclude = [path.resolve(__dirname, 'src')]
const defaultInclude = [path.resolve(__dirname, 'src', 'renderer')]
module.exports = {
entry: path.resolve(__dirname, 'src') + '/app/index.js',
entry: path.resolve(__dirname, 'src', 'renderer', 'index.js'),
output: {
path: path.resolve(__dirname, 'build'),
filename: 'bundle.js',
@ -33,11 +32,6 @@ module.exports = {
test: /\.svg$/,
use: ['@svgr/webpack'],
include: defaultInclude
},
{
test: /\.(eot|ttf|woff|woff2)$/,
use: ['file-loader?name=font/[name]__[hash:base64:5].[ext]'],
include: defaultInclude
}
]
},
@ -48,7 +42,7 @@ module.exports = {
plugins: [
new HtmlWebpackPlugin(),
new CopyPlugin([
{ from: './src/app/images/icon.*', to: './', flatten: true }
{ from: './src/renderer/images/icon.*', to: './', flatten: true }
])
]
}