Constructed simple Bitcoin-S wallet GUI (#1285)

* Constructed simple Bitcoin-S wallet GUI

* Added note about bitcoin-s server in doc

* Removed wallet dep from gui

* Replaced lambdas with constructors for compatibility with scala 2.11

* Fixed after rebase
This commit is contained in:
Nadav Kohen 2020-03-31 13:30:24 -06:00 committed by GitHub
parent 47c2bc08c4
commit c31a4a3f8a
14 changed files with 459 additions and 5 deletions

View file

@ -237,11 +237,15 @@ object ConsoleCli {
case Some(conf) => conf
}
exec(config.command, config.debug)
}
def exec(command: CliCommand, debugEnabled: Boolean = false): Try[String] = {
import System.err.{println => printerr}
/** Prints the given message to stderr if debug is set */
def debug(message: Any): Unit = {
if (config.debug) {
if (debugEnabled) {
printerr(s"DEBUG: $message")
}
}
@ -251,7 +255,7 @@ object ConsoleCli {
Failure(new RuntimeException(message))
}
val requestParam: RequestParam = config.command match {
val requestParam: RequestParam = command match {
case GetBalance(isSats) =>
RequestParam("getbalance", Seq(up.writeJs(isSats)))
case GetNewAddress =>

12
app/gui/gui.sbt Normal file
View file

@ -0,0 +1,12 @@
name := "bitcoin-s-gui"
libraryDependencies ++= Deps.gui
mainClass := Some("org.bitcoins.gui.WalletGUI")
enablePlugins(JavaAppPackaging)
publish / skip := true
// Fork a new JVM for 'run' and 'test:run' to avoid JavaFX double initialization problems
fork := true

View file

@ -0,0 +1,9 @@
package org.bitcoins.gui
import scalafx.beans.property.{DoubleProperty, StringProperty}
object GlobalData {
val currentBalance: DoubleProperty = DoubleProperty(0)
val log: StringProperty = StringProperty("")
}

View file

@ -0,0 +1,76 @@
package org.bitcoins.gui
import scalafx.application.Platform
import scalafx.scene.Node
import scalafx.scene.control.Alert.AlertType
import scalafx.scene.control.{Alert, Label}
/**
* Runs a background task disabling the `mainView` and main visible `glassPane`.
* Shows statis using `statusLabel`.
*
* Copied from [[https://github.com/scalafx/ScalaFX-Tutorials/blob/master/slick-table/src/main/scala/org/scalafx/slick_table/TaskRunner.scala]]
*/
class TaskRunner(mainView: Node, glassPane: Node, statusLabel: Label) {
/**
* Run an operation on a separate thread. Return and wait for its completion,
* then return result of running that operation.
*
* A progress indicator is displayed while running the operation.
*
* @param caption name for the thread (useful in debugging) and status displayed
* when running the task.
* @param op operation to run.
* @tparam R type of result returned by the operation.
* @return result returned by operation `op`.
*/
def run[R](caption: String, op: => R): Unit = {
def showProgress(progressEnabled: Boolean): Unit = {
mainView.disable = progressEnabled
glassPane.visible = progressEnabled
}
// Indicate task in progress
Platform.runLater {
showProgress(true)
statusLabel.text = caption
}
val task = new javafx.concurrent.Task[R] {
override def call(): R = {
op
}
override def succeeded(): Unit = {
showProgress(false)
statusLabel.text = caption + " - Done."
// Do callback, if defined
}
override def failed(): Unit = {
showProgress(false)
statusLabel.text = caption + " - Failed."
val t = Option(getException)
t.foreach(_.printStackTrace())
// Show error message
val _ = new Alert(AlertType.Error) {
initOwner(owner)
title = caption
headerText = "Operation failed. " + t
.map("Exception: " + _.getClass)
.getOrElse("")
contentText = t.map(_.getMessage).getOrElse("")
}.showAndWait()
}
override def cancelled(): Unit = {
showProgress(false)
statusLabel.text = caption + " - Cancelled."
}
}
val th = new Thread(task, caption)
th.setDaemon(true)
th.start()
}
}

View file

@ -0,0 +1,95 @@
package org.bitcoins.gui
import javafx.event.{ActionEvent, EventHandler}
import scalafx.application.{JFXApp, Platform}
import scalafx.beans.property.StringProperty
import scalafx.geometry.{Insets, Pos}
import scalafx.scene.Scene
import scalafx.scene.control.Alert.AlertType
import scalafx.scene.control._
import scalafx.scene.layout.{BorderPane, HBox, StackPane, VBox}
object WalletGUI extends JFXApp {
// Catch unhandled exceptions on FX Application thread
Thread
.currentThread()
.setUncaughtExceptionHandler(
new Thread.UncaughtExceptionHandler {
override def uncaughtException(t: Thread, ex: Throwable): Unit = {
ex.printStackTrace()
val _ = new Alert(AlertType.Error) {
initOwner(owner)
title = "Unhandled exception"
headerText = "Exception: " + ex.getClass + ""
contentText = Option(ex.getMessage).getOrElse("")
}.showAndWait()
}
}
)
private val glassPane = new VBox {
children = new ProgressIndicator {
progress = ProgressIndicator.IndeterminateProgress
visible = true
}
alignment = Pos.Center
visible = false
}
private val statusLabel = new Label {
maxWidth = Double.MaxValue
padding = Insets(0, 10, 10, 10)
}
private val resultArea = new TextArea {
editable = false
wrapText = true
text <== StringProperty("Your current balance is: ") + GlobalData.currentBalance + StringProperty(
s"\n\n${(0 until 60).map(_ => "-").mkString("")}\n\n") + GlobalData.log
}
private val model = new WalletGUIModel()
private val getNewAddressButton = new Button {
text = "Get New Addreses"
onAction = new EventHandler[ActionEvent] {
override def handle(event: ActionEvent): Unit = model.onGetNewAddress()
}
}
private val sendButton = new Button {
text = "Send"
onAction = new EventHandler[ActionEvent] {
override def handle(event: ActionEvent): Unit = model.onSend()
}
}
private val buttonBar = new HBox {
children = Seq(getNewAddressButton, sendButton)
alignment = Pos.Center
spacing <== width / 2
}
private val borderPane = new BorderPane {
top = buttonBar
center = resultArea
bottom = statusLabel
}
private val rootView = new StackPane {
children = Seq(
borderPane,
glassPane
)
}
stage = new JFXApp.PrimaryStage {
title = "Bitcoin-S Wallet"
scene = new Scene(rootView)
}
private val taskRunner = new TaskRunner(resultArea, glassPane, statusLabel)
model.taskRunner = taskRunner
Platform.runLater(sendButton.requestFocus())
}

View file

@ -0,0 +1,80 @@
package org.bitcoins.gui
import org.bitcoins.cli.CliCommand.{GetBalance, GetNewAddress, SendToAddress}
import org.bitcoins.cli.ConsoleCli
import org.bitcoins.core.currency.Bitcoins
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.gui.dialog.{GetNewAddressDialog, SendDialog}
import scalafx.beans.property.{ObjectProperty, StringProperty}
import scalafx.scene.control.Alert.AlertType
import scalafx.scene.control.Alert
import scalafx.stage.Window
import scala.util.{Failure, Success}
class WalletGUIModel() {
var taskRunner: TaskRunner = _
// Sadly, it is a Java "pattern" to pass null into
// constructors to signal that you want some default
val parentWindow: ObjectProperty[Window] =
ObjectProperty[Window](null.asInstanceOf[Window])
updateBalance()
def onGetNewAddress(): Unit = {
val address = StringProperty("")
taskRunner.run(
caption = "Get New Address",
op = {
ConsoleCli.exec(GetNewAddress) match {
case Success(commandReturn) => address.value = commandReturn
case Failure(err) => throw err
}
}
)
GetNewAddressDialog.showAndWait(parentWindow.value, address)
}
def onSend(): Unit = {
val result = SendDialog.showAndWait(parentWindow.value)
result match {
case Some((address, amount)) =>
taskRunner.run(
caption = s"Send $amount to $address",
op = {
ConsoleCli.exec(
SendToAddress(BitcoinAddress(address).get,
Bitcoins(BigDecimal(amount)),
satoshisPerVirtualByte = None)) match {
case Success(txid) =>
GlobalData.log.value =
s"Sent $amount to $address in tx: $txid\n\n${GlobalData.log()}"
case Failure(err) => throw err
}
}
)
case None => ()
}
updateBalance()
}
private def updateBalance(): Unit = {
ConsoleCli.exec(GetBalance(isSats = false)) match {
case Success(commandReturn) =>
GlobalData.currentBalance.value = commandReturn.split(' ').head.toDouble
case Failure(err) =>
err.printStackTrace()
val _ = new Alert(AlertType.Error) {
initOwner(owner)
title = "Could not retrieve wallet balance"
headerText = s"Operation failed. Exception: ${err.getClass}"
contentText = err.getMessage
}.showAndWait()
}
}
}

View file

@ -0,0 +1,31 @@
package org.bitcoins.gui.dialog
import scalafx.Includes._
import scalafx.beans.property.StringProperty
import scalafx.scene.control.{ButtonType, Dialog, TextArea}
import scalafx.stage.Window
object GetNewAddressDialog {
def showAndWait(parentWindow: Window, address: String): Unit = {
showAndWait(parentWindow, StringProperty(address))
}
def showAndWait(parentWindow: Window, address: StringProperty): Unit = {
val dialog = new Dialog[Unit]() {
initOwner(parentWindow)
title = "New Address"
}
// TODO make a button to copy the address to clipboard
dialog.dialogPane().buttonTypes = Seq(ButtonType.OK)
dialog.dialogPane().content = new TextArea {
text <== address
editable = false
}
val _ = dialog.showAndWait()
}
}

View file

@ -0,0 +1,58 @@
package org.bitcoins.gui.dialog
import scalafx.Includes._
import scalafx.application.Platform
import scalafx.geometry.Insets
import scalafx.scene.control.{ButtonType, Dialog, Label, TextField}
import scalafx.scene.layout.GridPane
import scalafx.stage.Window
object SendDialog {
def showAndWait(parentWindow: Window): Option[(String, String)] = {
val dialog = new Dialog[Option[(String, String)]]() {
initOwner(parentWindow)
title = "Send"
}
dialog.dialogPane().buttonTypes = Seq(ButtonType.OK, ButtonType.Cancel)
val addressTF = new TextField()
val amountTF = new TextField()
dialog.dialogPane().content = new GridPane {
hgap = 10
vgap = 10
padding = Insets(20, 100, 10, 10)
var nextRow: Int = 0
def addRow(label: String, textField: TextField): Unit = {
add(new Label(label), 0, nextRow)
add(textField, 1, nextRow)
nextRow += 1
}
addRow("Address", addressTF)
addRow("Amount (in BTC)", amountTF)
}
// Enable/Disable OK button depending on whether all data was entered.
val okButton = dialog.dialogPane().lookupButton(ButtonType.OK)
// Simple validation that sufficient data was entered
okButton.disable <== addressTF.text.isEmpty || amountTF.text.isEmpty
Platform.runLater(addressTF.requestFocus())
// When the OK button is clicked, convert the result to a T.
dialog.resultConverter = dialogButton =>
if (dialogButton == ButtonType.OK) {
Some((addressTF.text(), amountTF.text()))
} else None
dialog.showAndWait() match {
case Some(Some((address: String, amount: String))) =>
Some((address, amount))
case Some(_) | None => None
}
}
}

View file

@ -52,6 +52,7 @@ lazy val `bitcoin-s` = project
bench,
eclairRpc,
eclairRpcTest,
gui,
keyManager,
keyManagerTest,
node,
@ -242,6 +243,13 @@ lazy val cliTest = project
testkit
)
lazy val gui = project
.in(file("app/gui"))
.settings(CommonSettings.prodSettings: _*)
.dependsOn(
cli
)
lazy val chainDbSettings = dbFlywaySettings("chaindb")
lazy val chain = project
.in(file("chain"))

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

55
docs/applications/gui.md Normal file
View file

@ -0,0 +1,55 @@
---
id: gui
title: GUI
---
## Bitcoin-S Graphical User Interface
There is now a GUI built using [scalafx](https://www.scalafx.org/) to allow users who do not wish to use the [command line interface](cli.md) to interact with a Bitcoin-S wallet. This GUI is currently very minimal and looks something like this:
![](gui-snapshot.png)
At the time of writing this document, creating addresses and sending Bitcoin are the only supported wallet functionalities in the wallet's RPC API. The expansion of this interface will enable a more robust GUI and this issue is being tracked [here](https://github.com/bitcoin-s/bitcoin-s/issues/1284).
### Running the GUI
The GUI will only function properly if it can connect to the [bitcoin-s server](server.md) which must be running in the background before running the GUI.
To run the gui, simply execute
```bashrc
sbt gui/run
```
alternatively the gui can be built into an executable using the [sbt native packager](https://www.scala-sbt.org/sbt-native-packager/).
### ScalaFX
[ScalaFX](https://www.scalafx.org/) is a scala library wrapping [JavaFX](https://openjfx.io/) which is itself a java interface to [GTK](https://www.gtk.org/) (which is useful to know when googling for answers as it is easier to search for answers in GTK or sometimes javafx and then translate them to scalafx).
ScalaFX itself requires the dependency
```scala
"org.scalafx" %% "scalafx"
```
but full use of the library requires also adding these dependencies:
```scala
"org.openjfx" % "javafx-base"
"org.openjfx" % "javafx-controls"
"org.openjfx" % "javafx-fxml"
"org.openjfx" % "javafx-graphics"
"org.openjfx" % "javafx-media"
"org.openjfx" % "javafx-swing"
"org.openjfx" % "javafx-web"
```
which for some reason must all be classified by the OS of the machine they are running on.
A GUI can now be built by creating an object which extends `scalafx.application.JFXApp` and setting the `stage` var to a `new JFXApp.PrimaryStage` whose `scene` is your `Node` (where `Node` is the super-class in this framework for all GUI elements).
#### ScalaFX Examples
Aside from looking at how the Bitcoin-S GUI is implemented, for more useful examples of ScalaFX, see [these tutorials](https://github.com/scalafx/ScalaFX-Tutorials) and specifically [this one](https://github.com/scalafx/ScalaFX-Tutorials/tree/master/slick-table) which I think is most illustrative.

View file

@ -20,6 +20,9 @@ object Deps {
val nativeLoaderV = "2.3.4"
val typesafeConfigV = "1.4.0"
val scalaFxV = "12.0.2-R18"
val javaFxV = "12.0.2"
// async dropped Scala 2.11 in 0.10.0
val asyncOldScalaV = "0.9.7"
val asyncNewScalaV = "0.10.0"
@ -66,6 +69,23 @@ object Deps {
val akkaStream = "com.typesafe.akka" %% "akka-stream" % V.akkaStreamv withSources () withJavadoc ()
val akkaActor = "com.typesafe.akka" %% "akka-actor" % V.akkaStreamv withSources () withJavadoc ()
val scalaFx = "org.scalafx" %% "scalafx" % V.scalaFxV withSources () withJavadoc ()
lazy val osName = System.getProperty("os.name") match {
case n if n.startsWith("Linux") => "linux"
case n if n.startsWith("Mac") => "mac"
case n if n.startsWith("Windows") => "win"
case _ => throw new Exception("Unknown platform!")
}
// Not sure if all of these are needed, some might be possible to remove
lazy val javaFxBase = "org.openjfx" % s"javafx-base" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxControls = "org.openjfx" % s"javafx-controls" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxFxml = "org.openjfx" % s"javafx-fxml" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxGraphics = "org.openjfx" % s"javafx-graphics" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxMedia = "org.openjfx" % s"javafx-media" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxSwing = "org.openjfx" % s"javafx-swing" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxWeb = "org.openjfx" % s"javafx-web" % V.javaFxV classifier osName withSources () withJavadoc ()
lazy val javaFxDeps = List(javaFxBase, javaFxControls, javaFxFxml, javaFxGraphics, javaFxMedia, javaFxSwing, javaFxWeb)
val playJson = "com.typesafe.play" %% "play-json" % V.playv withSources () withJavadoc ()
val typesafeConfig = "com.typesafe" % "config" % V.typesafeConfigV withSources () withJavadoc ()
@ -199,6 +219,8 @@ object Deps {
Compile.codehaus
)
val gui = List(Compile.scalaFx) ++ Compile.javaFxDeps
def picklers(scalaVersion: String) = List(
if (scalaVersion.startsWith("2.11")) Compile.oldMicroPickle
else Compile.newMicroPickle

View file

@ -12,7 +12,7 @@
"title": "CLI"
},
"applications/configuration": {
"title": "Application Configuration"
"title": "Application configuration"
},
"applications/dlc": {
"title": "Executing A DLC with Bitcoin-S"
@ -20,8 +20,11 @@
"applications/filter-sync": {
"title": "Syncing Blockfilters"
},
"applications/gui": {
"title": "GUI"
},
"applications/node": {
"title": "Light Client"
"title": "Light client"
},
"applications/server": {
"title": "Application Server"

View file

@ -4,7 +4,8 @@
"Getting Setup": ["getting-setup"],
"Applications": [
"applications/cli",
"applications/server"
"applications/server",
"applications/gui"
],
"Chain": [
"chain/chain",