From a409c28b2020946aa4f91928ba5dfcb694365624 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Fri, 8 May 2020 14:37:19 +0200 Subject: [PATCH] bip39 keystore import --- build.gradle | 4 +- drongo | 2 +- .../com/sparrowwallet/sparrow/MainApp.java | 4 +- .../sparrow/control/DevicePane.java | 3 +- .../control/FileKeystoreImportPane.java | 121 +++++++ .../control/KeystoreFileImportPane.java | 229 ------------- .../control/KeystoreImportAccordion.java | 6 +- .../sparrow/control/KeystoreImportPane.java | 144 ++++++++ .../control/MnemonicKeystoreImportPane.java | 312 ++++++++++++++++++ .../com/sparrowwallet/sparrow/io/Bip39.java | 12 +- .../KeystoreImportController.java | 6 +- .../keystoreimport/KeystoreImportDialog.java | 4 +- .../sparrow/keystoreimport/SwController.java | 3 +- .../sparrow/wallet/WalletController.java | 5 + .../sparrow/keystoreimport/keystoreimport.css | 6 +- src/main/resources/image/ledger.png | Bin 1470 -> 3097 bytes src/main/resources/image/seed.png | Bin 0 -> 6365 bytes 17 files changed, 613 insertions(+), 248 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java delete mode 100644 src/main/java/com/sparrowwallet/sparrow/control/KeystoreFileImportPane.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportPane.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystoreImportPane.java create mode 100644 src/main/resources/image/seed.png diff --git a/build.gradle b/build.gradle index 7d729ebd..d2d6858c 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ dependencies { mainClassName = 'com.sparrowwallet.sparrow/com.sparrowwallet.sparrow.MainApp' run { - applicationDefaultJvmArgs = ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png", "--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls"] + applicationDefaultJvmArgs = ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png", "--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls", "--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls"] } jlink { @@ -63,7 +63,7 @@ jlink { options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png'] launcher { name = 'sparrow' - jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls"] + jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls", "--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls"] } addExtraDependencies("javafx") jpackage { diff --git a/drongo b/drongo index d394c25a..be0c4d11 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit d394c25a3c05c02d984b1f709623a311c2afb7a1 +Subproject commit be0c4d1176da41671c2629e65f812fd28fab202b diff --git a/src/main/java/com/sparrowwallet/sparrow/MainApp.java b/src/main/java/com/sparrowwallet/sparrow/MainApp.java index 950b415a..08be68eb 100644 --- a/src/main/java/com/sparrowwallet/sparrow/MainApp.java +++ b/src/main/java/com/sparrowwallet/sparrow/MainApp.java @@ -41,9 +41,9 @@ public class MainApp extends Application { wallet.setScriptType(ScriptType.P2WPKH); KeystoreImportDialog dlg = new KeystoreImportDialog(wallet); - //dlg.showAndWait(); + dlg.showAndWait(); - stage.show(); + //stage.show(); } public static void main(String[] args) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java index f4011bdc..c07c1d67 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java @@ -128,7 +128,8 @@ public class DevicePane extends TitledPane { listItem.getChildren().add(buttonBox); this.layoutBoundsProperty().addListener((observable, oldValue, newValue) -> { - listItem.setPrefWidth(newValue.getWidth()); + //Hack to force listItem to expand to full available width less border + listItem.setPrefWidth(newValue.getWidth() - 2); }); return listItem; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java new file mode 100644 index 00000000..de4fb658 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java @@ -0,0 +1,121 @@ +package com.sparrowwallet.sparrow.control; + +import com.google.gson.JsonParseException; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.KeystoreImportEvent; +import com.sparrowwallet.sparrow.io.KeystoreFileImport; +import com.sparrowwallet.sparrow.io.KeystoreImport; +import javafx.beans.property.SimpleStringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import org.controlsfx.control.textfield.CustomPasswordField; +import org.controlsfx.control.textfield.TextFields; + +import java.io.*; + +public class FileKeystoreImportPane extends KeystoreImportPane { + private final KeystoreFileImport importer; + private Button importButton; + private final SimpleStringProperty password = new SimpleStringProperty(""); + + public FileKeystoreImportPane(KeystoreImportAccordion importAccordion, Wallet wallet, KeystoreFileImport importer) { + super(importAccordion, wallet, importer); + this.importer = importer; + } + + @Override + protected Node getTitle(KeystoreImport importer) { + Node title = super.getTitle(importer); + + setDescription("Keystore file import"); + + importButton = new Button("Import File..."); + importButton.setAlignment(Pos.CENTER_RIGHT); + importButton.setOnAction(event -> { + importFile(); + }); + buttonBox.getChildren().add(importButton); + + return title; + } + + private void importFile() { + Stage window = new Stage(); + + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " keystore"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("All Files", "*.*"), + new FileChooser.ExtensionFilter("JSON", "*.json") + ); + + File file = fileChooser.showOpenDialog(window); + if(file != null) { + importFile(file, null); + } + } + + private void importFile(File file, String password) { + if(file.exists()) { + try { + if(importer.isEncrypted(file) && password == null) { + setDescription("Password Required"); + showHideLink.setVisible(false); + setContent(getPasswordEntry(file)); + importButton.setDisable(true); + setExpanded(true); + } else { + InputStream inputStream = new BufferedInputStream(new FileInputStream(file)); + Keystore keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password); + EventManager.get().post(new KeystoreImportEvent(keystore)); + } + } catch (Exception e) { + String errorMessage = e.getMessage(); + if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) { + errorMessage = e.getCause().getMessage(); + } + if(e instanceof ECKey.InvalidPasswordException || e.getCause() instanceof ECKey.InvalidPasswordException) { + errorMessage = "Invalid wallet password"; + } + if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) { + errorMessage = "File was not in JSON format"; + } + setError("Import Error", errorMessage); + importButton.setDisable(false); + } + } + } + + private Node getPasswordEntry(File file) { + CustomPasswordField passwordField = (CustomPasswordField) TextFields.createClearablePasswordField(); + passwordField.setPromptText("Wallet password"); + password.bind(passwordField.textProperty()); + HBox.setHgrow(passwordField, Priority.ALWAYS); + + Button importEncryptedButton = new Button("Import"); + importEncryptedButton.setOnAction(event -> { + showHideLink.setVisible(true); + setExpanded(false); + importFile(file, password.get()); + }); + + HBox contentBox = new HBox(); + contentBox.setAlignment(Pos.TOP_RIGHT); + contentBox.setSpacing(20); + contentBox.getChildren().add(passwordField); + contentBox.getChildren().add(importEncryptedButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + contentBox.setPrefHeight(60); + + return contentBox; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/KeystoreFileImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/KeystoreFileImportPane.java deleted file mode 100644 index e748463e..00000000 --- a/src/main/java/com/sparrowwallet/sparrow/control/KeystoreFileImportPane.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.sparrowwallet.sparrow.control; - -import com.google.gson.JsonParseException; -import com.sparrowwallet.drongo.crypto.ECKey; -import com.sparrowwallet.drongo.wallet.Keystore; -import com.sparrowwallet.drongo.wallet.Wallet; -import com.sparrowwallet.sparrow.EventManager; -import com.sparrowwallet.sparrow.event.KeystoreImportEvent; -import com.sparrowwallet.sparrow.io.KeystoreFileImport; -import javafx.application.Platform; -import javafx.beans.property.SimpleStringProperty; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Hyperlink; -import javafx.scene.control.Label; -import javafx.scene.control.TitledPane; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import javafx.stage.FileChooser; -import javafx.stage.Stage; -import org.controlsfx.control.textfield.CustomPasswordField; -import org.controlsfx.control.textfield.TextFields; - -import java.io.*; - -public class KeystoreFileImportPane extends TitledPane { - private final KeystoreImportAccordion importAccordion; - private final Wallet wallet; - private final KeystoreFileImport importer; - - private Label mainLabel; - private Label descriptionLabel; - private Hyperlink showHideLink; - private Button importButton; - - private final SimpleStringProperty password = new SimpleStringProperty(""); - - public KeystoreFileImportPane(KeystoreImportAccordion importAccordion, Wallet wallet, KeystoreFileImport importer) { - this.importAccordion = importAccordion; - this.wallet = wallet; - this.importer = importer; - - setPadding(Insets.EMPTY); - - setGraphic(getTitle()); - getStyleClass().add("importpane"); - setContent(getContentBox(importer.getKeystoreImportDescription())); - - removeArrow(); - } - - private void removeArrow() { - Platform.runLater(() -> { - Node arrow = this.lookup(".arrow"); - if (arrow != null) { - arrow.setVisible(false); - arrow.setManaged(false); - } else { - removeArrow(); - } - }); - } - - private Node getTitle() { - HBox listItem = new HBox(); - listItem.setPadding(new Insets(10, 20, 10, 10)); - listItem.setSpacing(10); - - HBox imageBox = new HBox(); - imageBox.setMinWidth(50); - imageBox.setMinHeight(50); - listItem.getChildren().add(imageBox); - - Image image = new Image("image/" + importer.getWalletModel().getType() + ".png", 50, 50, true, true); - if (!image.isError()) { - ImageView imageView = new ImageView(); - imageView.setImage(image); - imageBox.getChildren().add(imageView); - } - - VBox labelsBox = new VBox(); - labelsBox.setSpacing(5); - labelsBox.setAlignment(Pos.CENTER_LEFT); - this.mainLabel = new Label(); - mainLabel.setText(importer.getName()); - mainLabel.getStyleClass().add("main-label"); - labelsBox.getChildren().add(mainLabel); - - HBox descriptionBox = new HBox(); - descriptionBox.setSpacing(7); - labelsBox.getChildren().add(descriptionBox); - - descriptionLabel = new Label("Keystore file import"); - descriptionLabel.getStyleClass().add("description-label"); - showHideLink = new Hyperlink("View Details..."); - showHideLink.managedProperty().bind(showHideLink.visibleProperty()); - showHideLink.setOnAction(event -> { - if(showHideLink.getText().contains("View")) { - setExpanded(true); - showHideLink.setText("Hide Details..."); - } else { - setExpanded(false); - showHideLink.setText("View Details..."); - } - }); - descriptionBox.getChildren().addAll(descriptionLabel, showHideLink); - - listItem.getChildren().add(labelsBox); - HBox.setHgrow(labelsBox, Priority.ALWAYS); - - HBox buttonBox = new HBox(); - buttonBox.setAlignment(Pos.CENTER_RIGHT); - - importButton = new Button("Import File..."); - importButton.setAlignment(Pos.CENTER_RIGHT); - importButton.setOnAction(event -> { - importFile(); - }); - - buttonBox.getChildren().add(importButton); - listItem.getChildren().add(buttonBox); - - this.layoutBoundsProperty().addListener((observable, oldValue, newValue) -> { - listItem.setPrefWidth(newValue.getWidth()); - }); - - return listItem; - } - - private void importFile() { - Stage window = new Stage(); - - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " keystore"); - fileChooser.getExtensionFilters().addAll( - new FileChooser.ExtensionFilter("All Files", "*.*"), - new FileChooser.ExtensionFilter("JSON", "*.json") - ); - - File file = fileChooser.showOpenDialog(window); - if(file != null) { - importFile(file, null); - } - } - - private void importFile(File file, String password) { - if(file.exists()) { - try { - if(importer.isEncrypted(file) && password == null) { - descriptionLabel.getStyleClass().remove("description-error"); - descriptionLabel.getStyleClass().add("description-label"); - descriptionLabel.setText("Password Required"); - showHideLink.setVisible(false); - setContent(getPasswordEntry(file)); - importButton.setDisable(true); - setExpanded(true); - } else { - InputStream inputStream = new BufferedInputStream(new FileInputStream(file)); - Keystore keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password); - EventManager.get().post(new KeystoreImportEvent(keystore)); - } - } catch (Exception e) { - descriptionLabel.getStyleClass().remove("description-label"); - descriptionLabel.getStyleClass().add("description-error"); - descriptionLabel.setText("Import Error"); - String errorMessage = e.getMessage(); - if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) { - errorMessage = e.getCause().getMessage(); - } - if(e instanceof ECKey.InvalidPasswordException || e.getCause() instanceof ECKey.InvalidPasswordException) { - errorMessage = "Invalid wallet password"; - } - if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) { - errorMessage = "File was not in JSON format"; - } - setContent(getContentBox(errorMessage)); - setExpanded(true); - showHideLink.setText("Hide Details..."); - importButton.setDisable(false); - } - } - } - - private Node getContentBox(String message) { - Label details = new Label(message); - details.setWrapText(true); - - HBox contentBox = new HBox(); - contentBox.setAlignment(Pos.TOP_LEFT); - contentBox.getChildren().add(details); - contentBox.setPadding(new Insets(10, 30, 10, 30)); - - double width = TextUtils.computeTextWidth(details.getFont(), message, 0.0D); - double numLines = Math.max(1, width / 400); - double height = Math.max(60, numLines * 40); - contentBox.setPrefHeight(height); - - return contentBox; - } - - private Node getPasswordEntry(File file) { - CustomPasswordField passwordField = (CustomPasswordField) TextFields.createClearablePasswordField(); - passwordField.setPromptText("Wallet password"); - password.bind(passwordField.textProperty()); - HBox.setHgrow(passwordField, Priority.ALWAYS); - - Button importEncryptedButton = new Button("Import"); - importEncryptedButton.setOnAction(event -> { - showHideLink.setVisible(true); - setExpanded(false); - importFile(file, password.get()); - }); - - HBox contentBox = new HBox(); - contentBox.setAlignment(Pos.TOP_RIGHT); - contentBox.setSpacing(20); - contentBox.getChildren().add(passwordField); - contentBox.getChildren().add(importEncryptedButton); - contentBox.setPadding(new Insets(10, 30, 10, 30)); - contentBox.setPrefHeight(60); - - return contentBox; - } -} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportAccordion.java b/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportAccordion.java index 7a249c71..b8ef94ff 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportAccordion.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportAccordion.java @@ -16,12 +16,12 @@ public class KeystoreImportAccordion extends Accordion { this.importers = importers; for(KeystoreImport importer : importers) { - KeystoreFileImportPane importPane = null; + KeystoreImportPane importPane = null; if(importer instanceof KeystoreFileImport) { - importPane = new KeystoreFileImportPane(this, wallet, (KeystoreFileImport)importer); + importPane = new FileKeystoreImportPane(this, wallet, (KeystoreFileImport)importer); } else if(importer instanceof KeystoreMnemonicImport) { - //TODO: Import from the new Bip39KeystoreImport + importPane = new MnemonicKeystoreImportPane(this, wallet, (KeystoreMnemonicImport)importer); } else { throw new IllegalArgumentException("Could not create ImportPane for importer of type " + importer.getClass()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportPane.java new file mode 100644 index 00000000..73e5e6f4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/KeystoreImportPane.java @@ -0,0 +1,144 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.io.KeystoreImport; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.TitledPane; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +public abstract class KeystoreImportPane extends TitledPane { + protected final KeystoreImportAccordion importAccordion; + protected final Wallet wallet; + + private Label mainLabel; + private Label descriptionLabel; + protected Hyperlink showHideLink; + protected HBox buttonBox; + + public KeystoreImportPane(KeystoreImportAccordion importAccordion, Wallet wallet, KeystoreImport importer) { + this.importAccordion = importAccordion; + this.wallet = wallet; + + setPadding(Insets.EMPTY); + setGraphic(getTitle(importer)); + getStyleClass().add("importpane"); + setContent(getContentBox(importer.getKeystoreImportDescription())); + removeArrow(); + } + + private void removeArrow() { + Platform.runLater(() -> { + Node arrow = this.lookup(".arrow"); + if (arrow != null) { + arrow.setVisible(false); + arrow.setManaged(false); + } else { + removeArrow(); + } + }); + } + + protected Node getTitle(KeystoreImport importer) { + HBox listItem = new HBox(); + listItem.setPadding(new Insets(10, 20, 10, 10)); + listItem.setSpacing(10); + + HBox imageBox = new HBox(); + imageBox.setMinWidth(50); + imageBox.setMinHeight(50); + listItem.getChildren().add(imageBox); + + Image image = new Image("image/" + importer.getWalletModel().getType() + ".png", 50, 50, true, true); + if (!image.isError()) { + ImageView imageView = new ImageView(); + imageView.setImage(image); + imageBox.getChildren().add(imageView); + } + + VBox labelsBox = new VBox(); + labelsBox.setSpacing(5); + labelsBox.setAlignment(Pos.CENTER_LEFT); + this.mainLabel = new Label(); + mainLabel.setText(importer.getName()); + mainLabel.getStyleClass().add("main-label"); + labelsBox.getChildren().add(mainLabel); + + HBox descriptionBox = new HBox(); + descriptionBox.setSpacing(7); + labelsBox.getChildren().add(descriptionBox); + + descriptionLabel = new Label("Keystore Import"); + descriptionLabel.getStyleClass().add("description-label"); + showHideLink = new Hyperlink("Show Details..."); + showHideLink.managedProperty().bind(showHideLink.visibleProperty()); + showHideLink.setOnAction(event -> { + if(this.isExpanded()) { + setExpanded(false); + } else { + setExpanded(true); + } + }); + this.expandedProperty().addListener((observable, oldValue, newValue) -> { + if(newValue) { + showHideLink.setText(showHideLink.getText().replace("Show", "Hide")); + } else { + showHideLink.setText(showHideLink.getText().replace("Hide", "Show")); + } + }); + descriptionBox.getChildren().addAll(descriptionLabel, showHideLink); + + listItem.getChildren().add(labelsBox); + HBox.setHgrow(labelsBox, Priority.ALWAYS); + + buttonBox = new HBox(); + buttonBox.setAlignment(Pos.CENTER_RIGHT); + listItem.getChildren().add(buttonBox); + + this.layoutBoundsProperty().addListener((observable, oldValue, newValue) -> { + //Hack to force listItem to expand to full available width less border + listItem.setPrefWidth(newValue.getWidth() - 2); + }); + + return listItem; + } + + protected void setDescription(String text) { + descriptionLabel.getStyleClass().remove("description-error"); + descriptionLabel.getStyleClass().add("description-label"); + descriptionLabel.setText(text); + } + + protected void setError(String title, String detail) { + descriptionLabel.getStyleClass().remove("description-label"); + descriptionLabel.getStyleClass().add("description-error"); + descriptionLabel.setText(title); + setContent(getContentBox(detail)); + setExpanded(true); + } + + protected Node getContentBox(String message) { + Label details = new Label(message); + details.setWrapText(true); + + HBox contentBox = new HBox(); + contentBox.setAlignment(Pos.TOP_LEFT); + contentBox.getChildren().add(details); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + + double width = TextUtils.computeTextWidth(details.getFont(), message, 0.0D); + double numLines = Math.max(1, width / 400); + double height = Math.max(60, numLines * 40); + contentBox.setPrefHeight(height); + + return contentBox; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystoreImportPane.java new file mode 100644 index 00000000..7c2e37c0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystoreImportPane.java @@ -0,0 +1,312 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.wallet.Bip39Calculator; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.KeystoreImportEvent; +import com.sparrowwallet.sparrow.io.*; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.util.Callback; +import org.controlsfx.control.textfield.AutoCompletionBinding; +import org.controlsfx.control.textfield.CustomPasswordField; +import org.controlsfx.control.textfield.CustomTextField; +import org.controlsfx.control.textfield.TextFields; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.Validator; +import org.controlsfx.validation.decoration.StyleClassValidationDecoration; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class MnemonicKeystoreImportPane extends KeystoreImportPane { + private final KeystoreMnemonicImport importer; + + private SplitMenuButton enterMnemonicButton; + private SplitMenuButton importButton; + + private SimpleListProperty wordEntriesProperty; + private final SimpleStringProperty passphraseProperty = new SimpleStringProperty(); + + public MnemonicKeystoreImportPane(KeystoreImportAccordion importAccordion, Wallet wallet, KeystoreMnemonicImport importer) { + super(importAccordion, wallet, importer); + this.importer = importer; + } + + @Override + protected Node getTitle(KeystoreImport importer) { + Node title = super.getTitle(importer); + setDescription("Keystore file import"); + + createEnterMnemonicButton(); + createImportButton(); + buttonBox.getChildren().addAll(enterMnemonicButton, importButton); + + return title; + } + + private void createEnterMnemonicButton() { + enterMnemonicButton = new SplitMenuButton(); + enterMnemonicButton.setAlignment(Pos.CENTER_RIGHT); + enterMnemonicButton.setText("Enter Mnemonic"); + enterMnemonicButton.setOnAction(event -> { + enterMnemonic(24); + }); + int[] numberWords = new int[] {24, 21, 18, 15, 12}; + for(int i = 0; i < numberWords.length; i++) { + MenuItem item = new MenuItem(numberWords[i] + " words"); + final int words = numberWords[i]; + item.setOnAction(event -> { + enterMnemonic(words); + }); + enterMnemonicButton.getItems().add(item); + } + enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty()); + } + + private void createImportButton() { + importButton = new SplitMenuButton(); + importButton.setAlignment(Pos.CENTER_RIGHT); + importButton.setText("Import Keystore"); + importButton.setOnAction(event -> { + importButton.setDisable(true); + importKeystore(wallet.getScriptType().getDefaultDerivation(), false); + }); + String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"}; + for(int i = 0; i < accounts.length; i++) { + MenuItem item = new MenuItem(accounts[i]); + final List derivation = wallet.getScriptType().getDefaultDerivation(i); + item.setOnAction(event -> { + importButton.setDisable(true); + importKeystore(derivation, false); + }); + importButton.getItems().add(item); + } + + + + importButton.managedProperty().bind(importButton.visibleProperty()); + importButton.setVisible(false); + } + + private void enterMnemonic(int numWords) { + setDescription("Enter mnemonic word list"); + showHideLink.setVisible(false); + setContent(getMnemonicWordsEntry(numWords)); + setExpanded(true); + } + + private Node getMnemonicWordsEntry(int numWords) { + VBox vBox = new VBox(); + vBox.setSpacing(10); + + TilePane tilePane = new TilePane(); + tilePane.setPrefRows(numWords/3); + tilePane.setHgap(10); + tilePane.setVgap(10); + tilePane.setOrientation(Orientation.VERTICAL); + + List words = new ArrayList<>(); + for(int i = 0; i < numWords; i++) { + words.add(""); + } + + ObservableList wordEntryList = FXCollections.observableArrayList(words); + wordEntriesProperty = new SimpleListProperty<>(wordEntryList); + for(int i = 0; i < numWords; i++) { + WordEntry wordEntry = new WordEntry(i, wordEntryList); + tilePane.getChildren().add(wordEntry); + } + + vBox.getChildren().add(tilePane); + + AnchorPane anchorPane = new AnchorPane(); + anchorPane.setPadding(new Insets(0, 32, 0, 10)); + + PassphraseEntry passphraseEntry = new PassphraseEntry(); + AnchorPane.setLeftAnchor(passphraseEntry, 0.0); + + Button okButton = new Button("Ok"); + okButton.setPrefWidth(70); + okButton.setDisable(true); + okButton.setOnAction(event -> { + prepareImport(); + }); + + wordEntriesProperty.addListener((ListChangeListener) c -> { + for(String word : wordEntryList) { + if(!WordEntry.isValid(word)) { + okButton.setDisable(true); + return; + } + } + + okButton.setDisable(false); + }); + + AnchorPane.setRightAnchor(okButton, 0.0); + + anchorPane.getChildren().addAll(passphraseEntry, okButton); + vBox.getChildren().add(anchorPane); + + return vBox; + } + + private void prepareImport() { + if(importKeystore(wallet.getScriptType().getDefaultDerivation(), true)) { + setExpanded(false); + enterMnemonicButton.setVisible(false); + importButton.setVisible(true); + importButton.setDisable(false); + setDescription("Ready to import"); + showHideLink.setText("Show Derivation..."); + showHideLink.setVisible(true); + setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation())); + } + } + + private boolean importKeystore(List derivation, boolean dryrun) { + importButton.setDisable(true); + try { + Keystore keystore = importer.getKeystore(derivation, wordEntriesProperty.get(), passphraseProperty.get()); + if(!dryrun) { + EventManager.get().post(new KeystoreImportEvent(keystore)); + } + return true; + } catch (ImportException e) { + String errorMessage = e.getMessage(); + if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) { + errorMessage = e.getCause().getMessage(); + } + setError("Import Error", errorMessage); + importButton.setDisable(false); + return false; + } + } + + private static class WordEntry extends HBox { + private static List wordList; + + public WordEntry(int wordNumber, ObservableList wordEntryList) { + super(); + setAlignment(Pos.CENTER_RIGHT); + + setSpacing(10); + Label label = new Label((wordNumber+1) + "."); + label.setPrefWidth(20); + label.setAlignment(Pos.CENTER_RIGHT); + TextField wordField = new TextField(); + wordField.setMaxWidth(100); + + Bip39Calculator bip39Calculator = new Bip39Calculator(); + wordList = bip39Calculator.getWordList(); + TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList)); + + ValidationSupport validationSupport = new ValidationSupport(); + validationSupport.registerValidator(wordField, Validator.combine( + Validator.createEmptyValidator("Word is required"), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue)) + )); + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + + wordField.textProperty().addListener((observable, oldValue, newValue) -> { + wordEntryList.set(wordNumber, newValue); + }); + + this.getChildren().addAll(label, wordField); + } + + public static boolean isValid(String word) { + return wordList.contains(word); + } + } + + private static class WordlistSuggestionProvider implements Callback> { + private final List wordList; + + public WordlistSuggestionProvider(List wordList) { + this.wordList = wordList; + } + + @Override + public Collection call(AutoCompletionBinding.ISuggestionRequest request) { + List suggestions = new ArrayList<>(); + if(!request.getUserText().isEmpty()) { + for(String word : wordList) { + if(word.startsWith(request.getUserText())) { + suggestions.add(word); + } + } + } + + return suggestions; + } + } + + private class PassphraseEntry extends HBox { + public PassphraseEntry() { + super(); + + setAlignment(Pos.CENTER_RIGHT); + setSpacing(10); + Label passphraseLabel = new Label("Passphrase:"); + CustomTextField passphraseField = (CustomTextField) TextFields.createClearableTextField(); + passphraseProperty.bind(passphraseField.textProperty()); + + getChildren().addAll(passphraseLabel, passphraseField); + } + } + + private Node getDerivationEntry(List derivation) { + TextField derivationField = new TextField(); + derivationField.setPromptText("Derivation path"); + derivationField.setText(KeyDerivation.writePath(derivation)); + HBox.setHgrow(derivationField, Priority.ALWAYS); + + ValidationSupport validationSupport = new ValidationSupport(); + validationSupport.registerValidator(derivationField, Validator.combine( + Validator.createEmptyValidator("Derivation is required"), + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue)) + )); + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + + Button importDerivationButton = new Button("Import"); + importDerivationButton.setOnAction(event -> { + showHideLink.setVisible(true); + setExpanded(false); + List importDerivation = KeyDerivation.parsePath(derivationField.getText()); + importKeystore(importDerivation, false); + }); + + derivationField.textProperty().addListener((observable, oldValue, newValue) -> { + importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue)); + }); + + HBox contentBox = new HBox(); + contentBox.setAlignment(Pos.TOP_RIGHT); + contentBox.setSpacing(20); + contentBox.getChildren().add(derivationField); + contentBox.getChildren().add(importDerivationButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + contentBox.setPrefHeight(60); + + return contentBox; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Bip39.java b/src/main/java/com/sparrowwallet/sparrow/io/Bip39.java index 8b039609..423567f2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Bip39.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Bip39.java @@ -15,7 +15,7 @@ public class Bip39 implements KeystoreMnemonicImport { @Override public WalletModel getWalletModel() { - return WalletModel.SPARROW; + return WalletModel.SEED; } @Override @@ -25,8 +25,12 @@ public class Bip39 implements KeystoreMnemonicImport { @Override public Keystore getKeystore(List derivation, List mnemonicWords, String passphrase) throws ImportException { - Bip39Calculator bip39Calculator = new Bip39Calculator(); - byte[] seed = bip39Calculator.getSeed(mnemonicWords, passphrase); - return Keystore.fromSeed(seed, derivation); + try { + Bip39Calculator bip39Calculator = new Bip39Calculator(); + byte[] seed = bip39Calculator.getSeed(mnemonicWords, passphrase); + return Keystore.fromSeed(seed, derivation); + } catch (Exception e) { + throw new ImportException(e); + } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportController.java index a0070150..77bb9632 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportController.java @@ -37,8 +37,12 @@ public class KeystoreImportController implements Initializable { public void initializeView(Wallet wallet) { this.wallet = wallet; importMenu.selectedToggleProperty().addListener((observable, oldValue, selectedToggle) -> { + if(selectedToggle == null) { + oldValue.setSelected(true); + return; + } + KeystoreSource importType = (KeystoreSource) selectedToggle.getUserData(); - System.out.println(importType); String fxmlName = importType.toString().toLowerCase(); if(importType == KeystoreSource.SW_SEED || importType == KeystoreSource.SW_WATCH) { fxmlName = "sw"; diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportDialog.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportDialog.java index debc5c0a..a9da06e3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/KeystoreImportDialog.java @@ -31,8 +31,8 @@ public class KeystoreImportDialog extends Dialog { final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); dialogPane.getButtonTypes().addAll(cancelButtonType); - dialogPane.setPrefWidth(620); - dialogPane.setPrefHeight(500); + dialogPane.setPrefWidth(650); + dialogPane.setPrefHeight(600); setResultConverter(dialogButton -> dialogButton != cancelButtonType ? keystore : null); } catch(IOException e) { diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java index 05e29d35..1443daa7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow.keystoreimport; import com.sparrowwallet.sparrow.control.KeystoreImportAccordion; +import com.sparrowwallet.sparrow.io.Bip39; import com.sparrowwallet.sparrow.io.Electrum; import com.sparrowwallet.sparrow.io.KeystoreImport; import javafx.collections.FXCollections; @@ -13,7 +14,7 @@ public class SwController extends KeystoreImportDetailController { private KeystoreImportAccordion importAccordion; public void initializeView() { - List importers = List.of(new Electrum()); + List importers = List.of(new Bip39(), new Electrum()); importAccordion.setKeystoreImporters(getMasterController().getWallet(), FXCollections.observableList(importers)); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java index 0554a883..976a786f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java @@ -31,6 +31,11 @@ public class WalletController extends WalletFormController implements Initializa public void initializeView() { walletMenu.selectedToggleProperty().addListener((observable, oldValue, selectedToggle) -> { + if(selectedToggle == null) { + oldValue.setSelected(true); + return; + } + Function function = (Function)selectedToggle.getUserData(); boolean existing = false; diff --git a/src/main/resources/com/sparrowwallet/sparrow/keystoreimport/keystoreimport.css b/src/main/resources/com/sparrowwallet/sparrow/keystoreimport/keystoreimport.css index 6acf4fab..7f34159e 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/keystoreimport/keystoreimport.css +++ b/src/main/resources/com/sparrowwallet/sparrow/keystoreimport/keystoreimport.css @@ -3,12 +3,12 @@ } .list-menu { - -fx-pref-width: 130; + -fx-pref-width: 160; -fx-background-color: #3da0e3; } .list-item { - -fx-pref-width: 130; + -fx-pref-width: 160; -fx-padding: 0 20 0 20; -fx-background-color: #3da0e3; } @@ -36,6 +36,8 @@ .titled-pane > .title { -fx-background-color: white; -fx-padding: 0; + -fx-border-color: #e5e5e6; + /*-fx-border-width: 1;*/ } .titled-pane > .title > .arrow-button { diff --git a/src/main/resources/image/ledger.png b/src/main/resources/image/ledger.png index cf1af2f104f5810a3c57b9ba89331e4446ea6b30..10131be7f6a9eac28da38f502ba67d6d57df6661 100644 GIT binary patch literal 3097 zcmb_eYgAKL7Cs4x2qG$$Qm_)E_0gK#Bp5 zvJ~orL8Xvo?Lfg685F95rB*GosdIp{Y*5+D)+ zuy+*zhJyf%pejQb06Gc)-Max`WdZQMI{We#HfpGZ!vkZ4Lhvyv+k&~c#lQ-caOe-< zTmi<0N+25NHYLa5)=c`~Q6m+gBXC4t4UT4#m!R^2FJ!sImB>bFoCbqzyvyZJ()t}+7So@wniGm5()e!;pi8K_^DQ_WVlBqaqoknhoB9%a}99Ft7i~=bX zvmz0#2%g^DtVkp@Nex3H7*Xjo5*T$~VPOfszku05uwG^%IA$@edp4L>7GnX1Stekn zI!>gQEtRkkU345Q(VA&do2kqj#j%i?nhnkB%{HZ&C4e=uJ+RbIqf(e^sTOMx7|Rcb z_$EP9#h(mr$}mf&M6_b11P&B%Q6{oXCT02g2)yWil#O(XfI+4DdNS$0zUYZ!Fg!gN zbT0;-4ZQ<>>&UPt%upwrB3=$);ar)R<#XmL9N%R%3PngyMV zX>l*Ue?%*ohQ3Y95~`%Km?VK%3v;n{nOPhquP*0-R z5Y`^e+NJ5-O=!_(%(X)^{-&{W?Lv!+&{l6<9w_h$)#G|vuEaI^EUD?!ueE9*?tW70 zTpWD9O1O)JKz_?TZ!=6A z&8=g8_H+Z~->1eBV{UhjZOW9BJv`=HFXRE-|8>ZsrT$r&xb;Z+8btWCw631E-|=K; zNAg+;pS-LzXK?gF)DPRA?~=3I$&%xh)Ju=9*7edtPPveHqw(7ubz9u4LF&!^p>*2t zlfwhOUAX)7V0WE{y5Nde)kFS+42G42fH>79n^PBfR$$Hf_Kb_)mr!1gBswY2#M+T(9;;T!kulq6k42F~xFlNmq8Y6zodwO_hztZ96?H2>j7PMTv$ zI#2ZMySAq4);rhl(s;4a!zFrMY+COcw@90O$rkk^PEcfH%m;+1yS2>;PC++8M~8&& zgu_d(EsG7oFUkAMsuK$dPD5U&7aP4Q)_;ExoIGFiuNV2^weNfMj;|WnyN+?l02^}B z6S(9Aa%CQ+)1g0wjc>2u->&yJWHWAz_St3(FJOAR<20>=-kKG>)(!1r`>byyeO~UK z4w8B^1_l-p%s{dK6;=OQ=Q&as)Cxx(>sV_4;`OGGBcm5z@5pj>cHd|B$>wG8eEgEa z+^WLmysBD>BES$mSfZy}=X)icbC?%=F2%X%5O{BO`o-nAubS%Hnid+{+KW9`@`^_y z%3blH+3b6Vtk-PyPTR&ee08kmeg>%Oj_r%H4Nm9}cUx=>A72;cfctnlNby)sm;cGa z@r<8Z?JuRQ?xXHF0`Rs3gQbDO!GAG7eed#tRtT;rnirpw86UKAhj+IqnMCgB)ph0O zvl(#>*N3+acCOHjZ8pB5pWE+!$1l2Muzb9;KEb=swg>8LX5M$(T7kzETpsU9Tg~fn zD2z%4VJ)wQs+^2Nhr)_@h63gO(KeFR@qYU0p%c}+e!*&6@sdM|d^~#Q3jK71&8N+0 z9#wvJ^>6vNXuSG$b_>e$zRZg-IA3n5`TM36@+U!O5?$-rg&&=Zb~cu6-|m{zbg)G8 zYi+qmt_2hZ@`dv^ZSq?0uS zNn5Gkr;ISd7|CRrC)+w8?dR14nRUBH9IFFxIuWGv_%I58*49xP^>r;mY;s{^SK;P9 zyY972qD<5I8sD!w9FbIiw2pqV2=0#O{Cc&=<9^wN uxQfWKdp|eoZ|t=By%Jcgjb#Imn(F=HnH==|GKBr327$ht1wU@wec*o+0uy%t literal 1470 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFl%InM3hAM`dB6B=jtVb)aX^@765fKFxc2v6eK2Rr0UTq__OB&@Hb09I0xZL0)vRD^GUf^&XRs)DJWnQpRynYn_wrJkXw zxw(nCj)IYap{c%svA(f^u92~oiGh`gkpdJb0c|TvNwW%aaf8|g1^l#~=$>Fbx5 zm+O@q>*W`v>l<2HTIw4Z=^Gj80#)c1SLT%@R_NvxE5l51Ni9w;$}A|!%+FH*nV6WA zUs__T1av9H3%LbwWAlok!2}F2{ffi_eM3D1ke6TzeSPsO&CP|YE-nd5MYtEM!Nnn! z1*!T$sm1xFMajU3OH&3}Rbb^@l$uzQUlfv`p92fUfQRg48ydQrS(>`K0t3w4 z+|1bA)WX~prq?AuximL5uLPzy1)U>XL81-Q?1BhGomDBOEGMPcdxSGk+CBr_R*{Ym8V!X9`Ub zOX8ZObhXheiDyzTmq`3s$LkTyvpOw3(xfJEGZaZ(P-J+qf^jZ$!#Sx5EDW4L7qszI zWqVvwKOqir%!_v=wRU$_93=GwaWPR>ydsKfYCY$z;Ln z+jZgA^LY~gGO%z2ym;5nU-E93>|-4faIZlUfU?h{LN|CG%CWq4wRzpU%a;NH*f3yl1Et$!Am u@6d3V-aPxbO|uolR{IoN>oo*KbLh*2~7Y>sPpXr diff --git a/src/main/resources/image/seed.png b/src/main/resources/image/seed.png new file mode 100644 index 0000000000000000000000000000000000000000..ecd91127f8264ad20987128581883c331efcc592 GIT binary patch literal 6365 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 R<1cf7mb$K5=>^+}{{xaP>%#y5 literal 0 HcmV?d00001