diff --git a/build.gradle b/build.gradle index 038fc280..83103119 100644 --- a/build.gradle +++ b/build.gradle @@ -104,6 +104,7 @@ dependencies { implementation('org.apache.commons:commons-lang3:3.7') implementation('net.sourceforge.streamsupport:streamsupport:1.7.0') implementation('com.github.librepdf:openpdf:1.3.27') + implementation('com.googlecode.lanterna:lanterna:3.1.1') testImplementation('junit:junit:4.12') } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 68302d18..aa6c204d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -1028,8 +1028,7 @@ public class AppController implements Initializable { private void openWallet(Storage storage, WalletAndKey walletAndKey, AppController appController, boolean forceSameWindow) { try { - checkWalletNetwork(walletAndKey.getWallet()); - restorePublicKeysFromSeed(storage, walletAndKey.getWallet(), walletAndKey.getKey()); + storage.restorePublicKeysFromSeed(walletAndKey.getWallet(), walletAndKey.getKey()); if(!walletAndKey.getWallet().isValid()) { throw new IllegalStateException("Wallet file is not valid."); } @@ -1048,97 +1047,6 @@ public class AppController implements Initializable { } } - private void checkWalletNetwork(Wallet wallet) { - if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) { - throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet."); - } - } - - private void restorePublicKeysFromSeed(Storage storage, Wallet wallet, Key key) throws MnemonicException { - if(wallet.containsMasterPrivateKeys()) { - //Derive xpub and master fingerprint from seed, potentially with passphrase - Wallet copy = wallet.copy(); - for(int i = 0; i < copy.getKeystores().size(); i++) { - Keystore copyKeystore = copy.getKeystores().get(i); - if(copyKeystore.hasSeed() && copyKeystore.getSeed().getPassphrase() == null) { - if(copyKeystore.getSeed().needsPassphrase()) { - if(!wallet.isMasterWallet() && wallet.getMasterWallet().getKeystores().size() == copy.getKeystores().size() && wallet.getMasterWallet().getKeystores().get(i).hasSeed()) { - copyKeystore.getSeed().setPassphrase(wallet.getMasterWallet().getKeystores().get(i).getSeed().getPassphrase()); - } else { - KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(wallet.getFullDisplayName(), copyKeystore); - Optional optionalPassphrase = passphraseDialog.showAndWait(); - if(optionalPassphrase.isPresent()) { - copyKeystore.getSeed().setPassphrase(optionalPassphrase.get()); - } else { - return; - } - } - } else { - copyKeystore.getSeed().setPassphrase(""); - } - } - } - - if(wallet.isEncrypted()) { - if(key == null) { - throw new IllegalStateException("Wallet was not encrypted, but seed is"); - } - - copy.decrypt(key); - } - - if(wallet.isWhirlpoolMasterWallet()) { - String walletId = storage.getWalletId(wallet); - Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId); - whirlpool.setScode(wallet.getMasterMixConfig().getScode()); - whirlpool.setHDWallet(storage.getWalletId(wallet), copy); - Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); - soroban.setHDWallet(copy); - } - - StandardAccount standardAccount = wallet.getStandardAccountType(); - if(standardAccount != null && standardAccount.getMinimumGapLimit() != null && wallet.gapLimit() == null) { - wallet.setGapLimit(standardAccount.getMinimumGapLimit()); - } - - for(int i = 0; i < wallet.getKeystores().size(); i++) { - Keystore keystore = wallet.getKeystores().get(i); - if(keystore.hasSeed()) { - Keystore copyKeystore = copy.getKeystores().get(i); - Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation()); - keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); - keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); - keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase()); - keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); - copyKeystore.getSeed().clear(); - } else if(keystore.hasMasterPrivateExtendedKey()) { - Keystore copyKeystore = copy.getKeystores().get(i); - Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation()); - keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); - keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); - keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); - copyKeystore.getMasterPrivateKey().clear(); - } - } - } - - for(Wallet childWallet : wallet.getChildWallets()) { - if(childWallet.isBip47()) { - try { - Keystore masterKeystore = wallet.getKeystores().get(0); - Keystore keystore = childWallet.getKeystores().get(0); - keystore.setBip47ExtendedPrivateKey(masterKeystore.getBip47ExtendedPrivateKey()); - List derivation = keystore.getKeyDerivation().getDerivation(); - keystore.setKeyDerivation(new KeyDerivation(masterKeystore.getKeyDerivation().getMasterFingerprint(), derivation)); - DeterministicKey pubKey = keystore.getBip47ExtendedPrivateKey().getKey().dropPrivateBytes().dropParent(); - keystore.setExtendedPublicKey(new ExtendedKey(pubKey, keystore.getBip47ExtendedPrivateKey().getParentFingerprint(), derivation.get(derivation.size() - 1))); - } catch(Exception e) { - log.error("Cannot prepare BIP47 keystore", e); - } - } - } - } - public void importWallet(ActionEvent event) { WalletImportDialog dlg = new WalletImportDialog(); Optional optionalWallet = dlg.showAndWait(); @@ -1234,14 +1142,12 @@ public class AppController implements Initializable { try { storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY); storage.saveWallet(wallet); - checkWalletNetwork(wallet); - restorePublicKeysFromSeed(storage, wallet, null); + storage.restorePublicKeysFromSeed(wallet, null); addWalletTabOrWindow(storage, wallet, false); for(Wallet childWallet : wallet.getChildWallets()) { storage.saveWallet(childWallet); - checkWalletNetwork(childWallet); - restorePublicKeysFromSeed(storage, childWallet, null); + storage.restorePublicKeysFromSeed(childWallet, null); addWalletTabOrWindow(storage, childWallet, false); } Platform.runLater(() -> selectTab(wallet)); @@ -1261,8 +1167,7 @@ public class AppController implements Initializable { wallet.encrypt(key); storage.setEncryptionPubKey(encryptionPubKey); storage.saveWallet(wallet); - checkWalletNetwork(wallet); - restorePublicKeysFromSeed(storage, wallet, key); + storage.restorePublicKeysFromSeed(wallet, key); addWalletTabOrWindow(storage, wallet, false); for(Wallet childWallet : wallet.getChildWallets()) { @@ -1270,8 +1175,7 @@ public class AppController implements Initializable { childWallet.encrypt(key); } storage.saveWallet(childWallet); - checkWalletNetwork(childWallet); - restorePublicKeysFromSeed(storage, childWallet, key); + storage.restorePublicKeysFromSeed(childWallet, key); addWalletTabOrWindow(storage, childWallet, false); } Platform.runLater(() -> selectTab(wallet)); diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 19f8bdb9..f5c7da89 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -97,6 +97,8 @@ public class AppServices { private final SorobanServices sorobanServices = new SorobanServices(); + private InteractionServices interactionServices; + private static PayNymService payNymService; private final MainApp application; @@ -173,8 +175,9 @@ public class AppServices { openFiles(event.getFiles(), null); }; - public AppServices(MainApp application) { + private AppServices(MainApp application, InteractionServices interactionServices) { this.application = application; + this.interactionServices = interactionServices; EventManager.get().register(this); EventManager.get().register(whirlpoolServices); EventManager.get().register(sorobanServices); @@ -500,7 +503,11 @@ public class AppServices { } static void initialize(MainApp application) { - INSTANCE = new AppServices(application); + INSTANCE = new AppServices(application, new DefaultInteractionServices()); + } + + static void initialize(MainApp application, InteractionServices interactionServices) { + INSTANCE = new AppServices(application, interactionServices); } public static AppServices get() { @@ -515,6 +522,10 @@ public class AppServices { return get().sorobanServices; } + public static InteractionServices getInteractionServices() { + return get().interactionServices; + } + public static PayNymService getPayNymService() { if(payNymService == null) { HostAndPort torProxy = getTorProxy(); @@ -746,40 +757,7 @@ public class AppServices { } public static Optional showAlertDialog(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) { - Alert alert = new Alert(alertType, content, buttons); - setStageIcon(alert.getDialogPane().getScene().getWindow()); - alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); - alert.setTitle(title); - alert.setHeaderText(title); - if(graphic != null) { - alert.setGraphic(graphic); - } - - Pattern linkPattern = Pattern.compile("\\[(http.+)]"); - Matcher matcher = linkPattern.matcher(content); - if(matcher.find()) { - String link = matcher.group(1); - HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content); - hyperlinkLabel.setMaxWidth(Double.MAX_VALUE); - hyperlinkLabel.setMaxHeight(Double.MAX_VALUE); - hyperlinkLabel.getStyleClass().add("content"); - Label label = new Label(); - hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50)); - hyperlinkLabel.setOnAction(event -> { - alert.close(); - get().getApplication().getHostServices().showDocument(link); - }); - alert.getDialogPane().setContent(hyperlinkLabel); - } - - String[] lines = content.split("\r\n|\r|\n"); - if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) { - double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum(); - alert.getDialogPane().setPrefHeight(200 + numLines * 20); - } - - moveToActiveWindowScreen(alert); - return alert.showAndWait(); + return getInteractionServices().showAlert(title, content, alertType, graphic, buttons); } public static void setStageIcon(Window window) { @@ -1165,6 +1143,10 @@ public class AppServices { @Subscribe public void requestDisconnect(RequestDisconnectEvent event) { onlineProperty.set(false); + //Ensure services don't try to reconnect + connectionService.cancel(); + ratesService.cancel(); + versionCheckService.cancel(); } @Subscribe diff --git a/src/main/java/com/sparrowwallet/sparrow/Args.java b/src/main/java/com/sparrowwallet/sparrow/Args.java index cda17e3e..93a3d423 100644 --- a/src/main/java/com/sparrowwallet/sparrow/Args.java +++ b/src/main/java/com/sparrowwallet/sparrow/Args.java @@ -17,6 +17,9 @@ public class Args { @Parameter(names = { "--level", "-l" }, description = "Set log level") public Level level; + @Parameter(names = { "--terminal", "-t" }, description = "Terminal mode", arity = 0) + public boolean terminal; + @Parameter(names = { "--help", "-h" }, description = "Show usage", help = true) public boolean help; diff --git a/src/main/java/com/sparrowwallet/sparrow/DefaultInteractionServices.java b/src/main/java/com/sparrowwallet/sparrow/DefaultInteractionServices.java new file mode 100644 index 00000000..7585497f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/DefaultInteractionServices.java @@ -0,0 +1,65 @@ +package com.sparrowwallet.sparrow; + +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.sparrow.control.KeystorePassphraseDialog; +import com.sparrowwallet.sparrow.control.TextUtils; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.text.Font; +import org.controlsfx.control.HyperlinkLabel; + +import java.util.Arrays; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen; +import static com.sparrowwallet.sparrow.AppServices.setStageIcon; + +public class DefaultInteractionServices implements InteractionServices { + @Override + public Optional showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) { + Alert alert = new Alert(alertType, content, buttons); + setStageIcon(alert.getDialogPane().getScene().getWindow()); + alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + alert.setTitle(title); + alert.setHeaderText(title); + if(graphic != null) { + alert.setGraphic(graphic); + } + + Pattern linkPattern = Pattern.compile("\\[(http.+)]"); + Matcher matcher = linkPattern.matcher(content); + if(matcher.find()) { + String link = matcher.group(1); + HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content); + hyperlinkLabel.setMaxWidth(Double.MAX_VALUE); + hyperlinkLabel.setMaxHeight(Double.MAX_VALUE); + hyperlinkLabel.getStyleClass().add("content"); + Label label = new Label(); + hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50)); + hyperlinkLabel.setOnAction(event -> { + alert.close(); + AppServices.get().getApplication().getHostServices().showDocument(link); + }); + alert.getDialogPane().setContent(hyperlinkLabel); + } + + String[] lines = content.split("\r\n|\r|\n"); + if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) { + double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum(); + alert.getDialogPane().setPrefHeight(200 + numLines * 20); + } + + moveToActiveWindowScreen(alert); + return alert.showAndWait(); + } + + @Override + public Optional requestPassphrase(String walletName, Keystore keystore) { + KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore); + return passphraseDialog.showAndWait(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/InteractionServices.java b/src/main/java/com/sparrowwallet/sparrow/InteractionServices.java new file mode 100644 index 00000000..d33bafe8 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/InteractionServices.java @@ -0,0 +1,13 @@ +package com.sparrowwallet.sparrow; + +import com.sparrowwallet.drongo.wallet.Keystore; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; + +import java.util.Optional; + +public interface InteractionServices { + Optional showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons); + Optional requestPassphrase(String walletName, Keystore keystore); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/MainApp.java b/src/main/java/com/sparrowwallet/sparrow/MainApp.java index 9170bc73..d7f2f0f8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/MainApp.java +++ b/src/main/java/com/sparrowwallet/sparrow/MainApp.java @@ -14,6 +14,8 @@ import com.sparrowwallet.sparrow.preferences.PreferenceGroup; import com.sparrowwallet.sparrow.preferences.PreferencesDialog; import com.sparrowwallet.sparrow.instance.InstanceException; import com.sparrowwallet.sparrow.instance.InstanceList; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.terminal.TerminalInteractionServices; import javafx.application.Application; import javafx.scene.text.Font; import javafx.stage.Stage; @@ -179,6 +181,13 @@ public class MainApp extends Application { getLogger().info("Using " + Network.get() + " configuration"); } + if(args.terminal) { + MainApp mainApp = new MainApp(); + AppServices.initialize(mainApp, new TerminalInteractionServices()); + SparrowTerminal.startTerminal(); + return; + } + List fileUriArguments = jCommander.getUnknownOptions(); try { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java index c2431f15..581fded9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java @@ -1,12 +1,15 @@ package com.sparrowwallet.sparrow.io; -import com.sparrowwallet.drongo.Network; -import com.sparrowwallet.drongo.SecureString; -import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.*; import com.sparrowwallet.drongo.crypto.*; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.MnemonicException; +import com.sparrowwallet.drongo.wallet.StandardAccount; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.MainApp; +import com.sparrowwallet.sparrow.soroban.Soroban; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; import javafx.concurrent.Service; import javafx.concurrent.Task; import org.apache.commons.lang3.concurrent.BasicThreadFactory; @@ -136,6 +139,98 @@ public class Storage { closePersistenceService.start(); } + public void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException { + checkWalletNetwork(wallet); + + if(wallet.containsMasterPrivateKeys()) { + //Derive xpub and master fingerprint from seed, potentially with passphrase + Wallet copy = wallet.copy(); + for(int i = 0; i < copy.getKeystores().size(); i++) { + Keystore copyKeystore = copy.getKeystores().get(i); + if(copyKeystore.hasSeed() && copyKeystore.getSeed().getPassphrase() == null) { + if(copyKeystore.getSeed().needsPassphrase()) { + if(!wallet.isMasterWallet() && wallet.getMasterWallet().getKeystores().size() == copy.getKeystores().size() && wallet.getMasterWallet().getKeystores().get(i).hasSeed()) { + copyKeystore.getSeed().setPassphrase(wallet.getMasterWallet().getKeystores().get(i).getSeed().getPassphrase()); + } else { + Optional optionalPassphrase = AppServices.getInteractionServices().requestPassphrase(wallet.getFullDisplayName(), copyKeystore); + if(optionalPassphrase.isPresent()) { + copyKeystore.getSeed().setPassphrase(optionalPassphrase.get()); + } else { + return; + } + } + } else { + copyKeystore.getSeed().setPassphrase(""); + } + } + } + + if(wallet.isEncrypted()) { + if(key == null) { + throw new IllegalStateException("Wallet was not encrypted, but seed is"); + } + + copy.decrypt(key); + } + + if(wallet.isWhirlpoolMasterWallet()) { + String walletId = getWalletId(wallet); + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId); + whirlpool.setScode(wallet.getMasterMixConfig().getScode()); + whirlpool.setHDWallet(getWalletId(wallet), copy); + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + soroban.setHDWallet(copy); + } + + StandardAccount standardAccount = wallet.getStandardAccountType(); + if(standardAccount != null && standardAccount.getMinimumGapLimit() != null && wallet.gapLimit() == null) { + wallet.setGapLimit(standardAccount.getMinimumGapLimit()); + } + + for(int i = 0; i < wallet.getKeystores().size(); i++) { + Keystore keystore = wallet.getKeystores().get(i); + if(keystore.hasSeed()) { + Keystore copyKeystore = copy.getKeystores().get(i); + Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation()); + keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); + keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); + keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase()); + keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); + copyKeystore.getSeed().clear(); + } else if(keystore.hasMasterPrivateExtendedKey()) { + Keystore copyKeystore = copy.getKeystores().get(i); + Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation()); + keystore.setKeyDerivation(derivedKeystore.getKeyDerivation()); + keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey()); + keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey()); + copyKeystore.getMasterPrivateKey().clear(); + } + } + } + + for(Wallet childWallet : wallet.getChildWallets()) { + if(childWallet.isBip47()) { + try { + Keystore masterKeystore = wallet.getKeystores().get(0); + Keystore keystore = childWallet.getKeystores().get(0); + keystore.setBip47ExtendedPrivateKey(masterKeystore.getBip47ExtendedPrivateKey()); + List derivation = keystore.getKeyDerivation().getDerivation(); + keystore.setKeyDerivation(new KeyDerivation(masterKeystore.getKeyDerivation().getMasterFingerprint(), derivation)); + DeterministicKey pubKey = keystore.getBip47ExtendedPrivateKey().getKey().dropPrivateBytes().dropParent(); + keystore.setExtendedPublicKey(new ExtendedKey(pubKey, keystore.getBip47ExtendedPrivateKey().getParentFingerprint(), derivation.get(derivation.size() - 1))); + } catch(Exception e) { + log.error("Cannot prepare BIP47 keystore", e); + } + } + } + } + + private void checkWalletNetwork(Wallet wallet) { + if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) { + throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet."); + } + } + public void backupWallet() throws IOException { if(walletFile.toPath().startsWith(getWalletsDir().toPath())) { backupWallet(null); diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionListBox.java b/src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionListBox.java new file mode 100644 index 00000000..1983928c --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionListBox.java @@ -0,0 +1,77 @@ +package com.sparrowwallet.sparrow.terminal; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.ActionListBox; +import com.googlecode.lanterna.gui2.dialogs.ActionListDialogBuilder; +import com.googlecode.lanterna.gui2.dialogs.FileDialogBuilder; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.terminal.preferences.GeneralDialog; +import com.sparrowwallet.sparrow.terminal.preferences.ServerStatusDialog; +import com.sparrowwallet.sparrow.terminal.preferences.ServerTypeDialog; +import com.sparrowwallet.sparrow.terminal.wallet.LoadWallet; + +import java.io.File; +import java.util.Map; +import java.util.Optional; + +public class MasterActionListBox extends ActionListBox { + public static final int MAX_RECENT_WALLETS = 6; + + public MasterActionListBox(SparrowTerminal sparrowTerminal) { + super(new TerminalSize(14, 3)); + + addItem("Wallets", () -> { + ActionListDialogBuilder builder = new ActionListDialogBuilder(); + builder.setTitle("Wallets"); + for(int i = 0; i < Config.get().getRecentWalletFiles().size() && i < MAX_RECENT_WALLETS; i++) { + File recentWalletFile = Config.get().getRecentWalletFiles().get(i); + Storage storage = new Storage(recentWalletFile); + + Optional optWallet = AppServices.get().getOpenWallets().entrySet().stream() + .filter(entry -> entry.getValue().getWalletFile().equals(recentWalletFile)).map(Map.Entry::getKey) + .map(wallet -> wallet.isMasterWallet() ? wallet : wallet.getMasterWallet()).findFirst(); + if(optWallet.isPresent()) { + builder.addAction(storage.getWalletName(null) + "*", () -> LoadWallet.getOpeningDialog(optWallet.get()).showDialog(SparrowTerminal.get().getGui())); + } else { + builder.addAction(storage.getWalletName(null), new LoadWallet(storage)); + } + } + builder.addAction("Open Wallet...", () -> { + FileDialogBuilder openBuilder = new FileDialogBuilder().setTitle("Open Wallet"); + openBuilder.setShowHiddenDirectories(true); + openBuilder.setSelectedFile(Storage.getWalletsDir()); + File file = openBuilder.build().showDialog(SparrowTerminal.get().getGui()); + if(file != null) { + LoadWallet loadWallet = new LoadWallet(new Storage(file)); + SparrowTerminal.get().getGui().getGUIThread().invokeLater(loadWallet); + } + }); + builder.build().showDialog(SparrowTerminal.get().getGui()); + }); + + addItem("Preferences", () -> { + new ActionListDialogBuilder() + .setTitle("Preferences") + .addAction("General", () -> { + GeneralDialog generalDialog = new GeneralDialog(); + generalDialog.showDialog(sparrowTerminal.getGui()); + }) + .addAction("Server", () -> { + if(Config.get().hasServer()) { + ServerStatusDialog serverStatusDialog = new ServerStatusDialog(); + serverStatusDialog.showDialog(sparrowTerminal.getGui()); + } else { + ServerTypeDialog serverTypeDialog = new ServerTypeDialog(); + serverTypeDialog.showDialog(sparrowTerminal.getGui()); + } + }) + .build() + .showDialog(sparrowTerminal.getGui()); + }); + + addItem("Quit", sparrowTerminal::stop); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionWindow.java b/src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionWindow.java new file mode 100644 index 00000000..49a3980a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionWindow.java @@ -0,0 +1,20 @@ +package com.sparrowwallet.sparrow.terminal; + +import com.googlecode.lanterna.gui2.BasicWindow; +import com.googlecode.lanterna.gui2.BorderLayout; +import com.googlecode.lanterna.gui2.Panel; + +import java.util.List; + +public class MasterActionWindow extends BasicWindow { + public MasterActionWindow(SparrowTerminal sparrowTerminal) { + setHints(List.of(Hint.CENTERED)); + + Panel panel = new Panel(new BorderLayout()); + + MasterActionListBox masterActionListBox = new MasterActionListBox(sparrowTerminal); + panel.addComponent(masterActionListBox, BorderLayout.Location.CENTER); + + setComponent(panel); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTerminal.java b/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTerminal.java new file mode 100644 index 00000000..d9fd0eba --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTerminal.java @@ -0,0 +1,79 @@ +package com.sparrowwallet.sparrow.terminal; + +import com.googlecode.lanterna.TextColor; +import com.googlecode.lanterna.gui2.DefaultWindowManager; +import com.googlecode.lanterna.gui2.EmptySpace; +import com.googlecode.lanterna.screen.Screen; +import com.googlecode.lanterna.screen.TerminalScreen; +import com.googlecode.lanterna.terminal.DefaultTerminalFactory; +import com.googlecode.lanterna.terminal.Terminal; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.terminal.wallet.WalletData; +import javafx.application.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class SparrowTerminal { + private static final Logger log = LoggerFactory.getLogger(SparrowTerminal.class); + + private static SparrowTerminal sparrowTerminal; + + private final Terminal terminal; + private final Screen screen; + private final SparrowTextGui gui; + + private final Map walletData = new HashMap<>(); + + private SparrowTerminal() throws IOException { + this.terminal = new DefaultTerminalFactory().createTerminal(); + this.screen = new TerminalScreen(terminal); + this.gui = new SparrowTextGui(this, screen, new DefaultWindowManager(), new EmptySpace(TextColor.ANSI.BLUE)); + EventManager.get().register(gui); + } + + public Screen getScreen() { + return screen; + } + + public SparrowTextGui getGui() { + return gui; + } + + public Map getWalletData() { + return walletData; + } + + public void stop() { + try { + screen.stopScreen(); + terminal.exitPrivateMode(); + Platform.runLater(() -> { + AppServices.get().stop(); + Platform.exit(); + }); + } catch(Exception e) { + log.error("Could not stop terminal", e); + } + } + + public static void startTerminal() { + try { + sparrowTerminal = new SparrowTerminal(); + sparrowTerminal.getScreen().startScreen(); + sparrowTerminal.getGui().getMainWindow().waitUntilClosed(); + } catch(Exception e) { + log.error("Could not start terminal", e); + System.err.println("Could not start terminal: " + e.getMessage()); + } + } + + public static SparrowTerminal get() { + return sparrowTerminal; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java b/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java new file mode 100644 index 00000000..e3f438b9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTextGui.java @@ -0,0 +1,166 @@ +package com.sparrowwallet.sparrow.terminal; + +import com.google.common.eventbus.Subscribe; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.TextColor; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.screen.Screen; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.net.ServerType; +import javafx.animation.*; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.util.Duration; + +public class SparrowTextGui extends MultiWindowTextGUI { + private final BasicWindow mainWindow; + + private final Label connectedLabel; + private final Label statusLabel; + private final ProgressBar statusProgress; + + private PauseTransition wait; + private Timeline statusTimeline; + private final DoubleProperty progressProperty = new SimpleDoubleProperty(); + + public SparrowTextGui(SparrowTerminal sparrowTerminal, Screen screen, WindowManager windowManager, Component background) { + super(screen, windowManager, background); + + this.mainWindow = new MasterActionWindow(sparrowTerminal); + addWindow(mainWindow); + + Panel panel = new Panel(new BorderLayout()); + + Panel titleBar = new Panel(new GridLayout(2)); + new Label("Sparrow Terminal").addTo(titleBar); + this.connectedLabel = new Label("Disconnected"); + titleBar.addComponent(connectedLabel, GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, true, false)); + panel.addComponent(titleBar, BorderLayout.Location.TOP); + + panel.addComponent(new EmptySpace(TextColor.ANSI.BLUE)); + + Panel statusBar = new Panel(new GridLayout(2)); + this.statusLabel = new Label("").addTo(statusBar); + this.statusProgress = new ProgressBar(0, 100, 10); + statusBar.addComponent(statusProgress, GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, true, false)); + statusProgress.setVisible(false); + statusProgress.setLabelFormat(null); + progressProperty.addListener((observable, oldValue, newValue) -> statusProgress.setValue((int) (newValue.doubleValue() * 100))); + + panel.addComponent(statusBar, BorderLayout.Location.BOTTOM); + getBackgroundPane().setComponent(panel); + + getMainWindow().addWindowListener(new WindowListenerAdapter() { + @Override + public void onResized(Window window, TerminalSize oldSize, TerminalSize newSize) { + titleBar.invalidate(); + statusBar.invalidate(); + } + }); + + AppServices.get().start(); + } + + public BasicWindow getMainWindow() { + return mainWindow; + } + + private void setConnectedLabel(boolean connected) { + getGUIThread().invokeLater(() -> { + connectedLabel.setText(connected ? "Connected" : "Disconnected"); + }); + } + + @Subscribe + public void connectionStart(ConnectionStartEvent event) { + statusUpdated(new StatusEvent(event.getStatus(), 120)); + } + + @Subscribe + public void connectionFailed(ConnectionFailedEvent event) { + setConnectedLabel(false); + statusUpdated(new StatusEvent("Connection failed: " + event.getMessage())); + } + + @Subscribe + public void connection(ConnectionEvent event) { + setConnectedLabel(true); + statusUpdated(new StatusEvent("Connected to " + Config.get().getServerDisplayName() + " at height " + event.getBlockHeight())); + } + + @Subscribe + public void disconnection(DisconnectionEvent event) { + if(!AppServices.isConnecting() && !AppServices.isConnected()) { + setConnectedLabel(false); + statusUpdated(new StatusEvent("Disconnected")); + } + } + + @Subscribe + public void statusUpdated(StatusEvent event) { + getGUIThread().invokeLater(() -> statusLabel.setText(event.getStatus())); + + if(wait != null && wait.getStatus() == Animation.Status.RUNNING) { + wait.stop(); + } + wait = new PauseTransition(Duration.seconds(event.getShowDuration())); + wait.setOnFinished((e) -> { + if(statusLabel.getText().equals(event.getStatus())) { + getGUIThread().invokeLater(() -> statusLabel.setText("")); + } + }); + wait.play(); + } + + @Subscribe + public void timedEvent(TimedEvent event) { + if(event.getTimeMills() == 0) { + getGUIThread().invokeLater(() -> { + statusLabel.setText(""); + statusProgress.setVisible(false); + statusProgress.setValue(0); + }); + } else if(event.getTimeMills() < 0) { + getGUIThread().invokeLater(() -> { + statusLabel.setText(event.getStatus()); + statusProgress.setVisible(false); + }); + } else { + getGUIThread().invokeLater(() -> { + statusLabel.setText(event.getStatus()); + statusProgress.setVisible(true); + }); + statusTimeline = new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(progressProperty, 0)), + new KeyFrame(Duration.millis(event.getTimeMills()), e -> { + getGUIThread().invokeLater(() -> { + statusLabel.setText(""); + statusProgress.setVisible(false); + }); + }, new KeyValue(progressProperty, 1)) + ); + statusTimeline.setCycleCount(1); + statusTimeline.play(); + } + } + + @Subscribe + public void walletHistoryStarted(WalletHistoryStartedEvent event) { + statusUpdated(new StatusEvent("Loading wallet history...")); + } + + @Subscribe + public void walletHistoryFinished(WalletHistoryFinishedEvent event) { + if(statusLabel.getText().equals("Loading wallet history...")) { + getGUIThread().invokeLater(() -> statusLabel.setText("")); + } + } + + @Subscribe + public void walletHistoryFailed(WalletHistoryFailedEvent event) { + walletHistoryFinished(new WalletHistoryFinishedEvent(event.getWallet())); + statusUpdated(new StatusEvent("Error retrieving wallet history" + (Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? ", trying another server..." : ""))); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/TerminalInteractionServices.java b/src/main/java/com/sparrowwallet/sparrow/terminal/TerminalInteractionServices.java new file mode 100644 index 00000000..0266c883 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/TerminalInteractionServices.java @@ -0,0 +1,110 @@ +package com.sparrowwallet.sparrow.terminal; + +import com.googlecode.lanterna.gui2.dialogs.MessageDialogBuilder; +import com.googlecode.lanterna.gui2.dialogs.MessageDialogButton; +import com.googlecode.lanterna.gui2.dialogs.TextInputDialogBuilder; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.sparrow.InteractionServices; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; + +import java.text.BreakIterator; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; + +public class TerminalInteractionServices implements InteractionServices { + private final Object alertShowing = new Object(); + private final Object passphraseShowing = new Object(); + + @Override + public Optional showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) { + if(Platform.isFxApplicationThread()) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + Optional optButtonType = showMessageDialog(title, content, buttons); + Platform.runLater(() -> Platform.exitNestedEventLoop(alertShowing, optButtonType)); + }); + return (Optional)Platform.enterNestedEventLoop(alertShowing); + } else { + return showMessageDialog(title, content, buttons); + } + } + + private Optional showMessageDialog(String title, String content, ButtonType[] buttons) { + String formattedContent = formatLines(content, 50); + + MessageDialogBuilder builder = new MessageDialogBuilder().setTitle(title).setText(formattedContent); + for(ButtonType buttonType : buttons) { + builder.addButton(getButton(buttonType)); + } + + MessageDialogButton button = builder.build().showDialog(SparrowTerminal.get().getGui()); + return Arrays.stream(buttons).filter(buttonType -> button.equals(getButton(buttonType))).findFirst(); + } + + private String formatLines(String input, int maxLength) { + StringBuilder builder = new StringBuilder(); + BreakIterator boundary = BreakIterator.getLineInstance(Locale.ROOT); + boundary.setText(input); + int start = boundary.first(); + int end = boundary.next(); + int lineLength = 0; + + while(end != BreakIterator.DONE) { + String word = input.substring(start,end); + lineLength = lineLength + word.length(); + if (lineLength >= maxLength) { + builder.append("\n"); + lineLength = word.length(); + } + builder.append(word); + start = end; + end = boundary.next(); + } + + return builder.toString(); + } + + private MessageDialogButton getButton(ButtonType buttonType) { + if(ButtonType.OK.equals(buttonType)) { + return MessageDialogButton.OK; + } + if(ButtonType.CANCEL.equals(buttonType)) { + return MessageDialogButton.Cancel; + } + if(ButtonType.YES.equals(buttonType)) { + return MessageDialogButton.Yes; + } + if(ButtonType.NO.equals(buttonType)) { + return MessageDialogButton.No; + } + if(ButtonType.CLOSE.equals(buttonType)) { + return MessageDialogButton.Close; + } + + throw new IllegalArgumentException("Cannot find button for " + buttonType); + } + + @Override + public Optional requestPassphrase(String walletName, Keystore keystore) { + if(Platform.isFxApplicationThread()) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + Optional optPassphrase = showPassphraseDialog(walletName, keystore); + Platform.runLater(() -> Platform.exitNestedEventLoop(passphraseShowing, optPassphrase)); + }); + return (Optional)Platform.enterNestedEventLoop(passphraseShowing); + } else { + return showPassphraseDialog(walletName, keystore); + } + } + + private Optional showPassphraseDialog(String walletName, Keystore keystore) { + TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Passphrase for " + walletName); + builder.setDescription("Enter the BIP39 passphrase for keystore:\n" + keystore.getLabel()); + builder.setPasswordInput(true); + String passphrase = builder.build().showDialog(SparrowTerminal.get().getGui()); + return passphrase == null ? Optional.empty() : Optional.of(passphrase); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/BitcoinCoreDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/BitcoinCoreDialog.java new file mode 100644 index 00000000..cc519a8e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/BitcoinCoreDialog.java @@ -0,0 +1,139 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DirectoryDialogBuilder; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Server; +import com.sparrowwallet.sparrow.net.CoreAuthType; +import com.sparrowwallet.sparrow.net.Protocol; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; + +import java.io.File; +import java.util.List; + +public class BitcoinCoreDialog extends ServerUrlDialog { + private final ComboBox authentication; + private final Label dataFolderLabel; + private final TextBox dataFolder; + private final Button selectDataFolder; + private final Label userPassLabel; + private final TextBox user; + private final TextBox pass; + + public BitcoinCoreDialog() { + super("Bitcoin Core"); + + setHints(List.of(Hint.CENTERED)); + Panel mainPanel = new Panel(new GridLayout(3).setHorizontalSpacing(2).setVerticalSpacing(0)); + + if(Config.get().getCoreServer() == null) { + Config.get().setCoreServer(new Server("http://127.0.0.1:" + Network.get().getDefaultPort())); + } + addUrlComponents(mainPanel, Config.get().getRecentCoreServers(), Config.get().getCoreServer()); + addLine(mainPanel); + + mainPanel.addComponent(new Label("Authentication")); + authentication = new ComboBox<>("Default", "User/Pass"); + authentication.setSelectedIndex(Config.get().getCoreAuthType() == CoreAuthType.USERPASS ? 0 : 1); + mainPanel.addComponent(authentication); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + dataFolderLabel = new Label("Data Folder"); + mainPanel.addComponent(dataFolderLabel); + dataFolder = new TextBox(new TerminalSize(30, 1), Config.get().getCoreDataDir() != null ? Config.get().getCoreDataDir().getAbsolutePath() : ""); + mainPanel.addComponent(dataFolder); + selectDataFolder = new Button("Select..."); + mainPanel.addComponent(selectDataFolder); + + userPassLabel = new Label("User/Pass"); + mainPanel.addComponent(userPassLabel); + user = new TextBox(new TerminalSize(16, 1)); + mainPanel.addComponent(user); + pass = new TextBox(new TerminalSize(16, 1)); + pass.setMask('*'); + mainPanel.addComponent(pass); + + if(Config.get().getCoreAuth() != null) { + String[] userPass = Config.get().getCoreAuth().split(":"); + if(userPass.length > 0) { + user.setText(userPass[0]); + } + if(userPass.length > 1) { + pass.setText(userPass[1]); + } + } + + authentication.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + dataFolderLabel.setVisible(selectedIndex == 0); + dataFolder.setVisible(selectedIndex == 0); + selectDataFolder.setVisible(selectedIndex == 0); + userPassLabel.setVisible(selectedIndex == 1); + user.setVisible(selectedIndex == 1); + pass.setVisible(selectedIndex == 1); + Config.get().setCoreAuthType(selectedIndex == 0 ? CoreAuthType.COOKIE : CoreAuthType.USERPASS); + }); + authentication.setSelectedIndex(Config.get().getCoreAuthType() == CoreAuthType.USERPASS ? 1 : 0); + + dataFolder.setTextChangeListener((newText, changedByUserInteraction) -> { + File dataDir = new File(newText); + if(dataDir.exists()) { + Config.get().setCoreDataDir(dataDir); + } + }); + selectDataFolder.addListener(button -> { + DirectoryDialogBuilder builder = new DirectoryDialogBuilder().setTitle("Select Bitcoin Core Data Folder").setActionLabel("Select"); + builder.setShowHiddenDirectories(true); + builder.setSelectedDirectory(Config.get().getCoreDataDir() == null ? new File(System.getProperty("user.home")) : Config.get().getCoreDataDir()); + File file = builder.build().showDialog(SparrowTerminal.get().getGui()); + if(file != null) { + dataFolder.setText(file.getAbsolutePath()); + } + }); + user.setTextChangeListener((newText, changedByUserInteraction) -> { + setCoreAuth(); + }); + pass.setTextChangeListener((newText, changedByUserInteraction) -> { + setCoreAuth(); + }); + + addLine(mainPanel); + addProxyComponents(mainPanel); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Test", this::onTest)); + buttonPanel.addComponent(new Button("Done", this::onDone)); + + addLine(mainPanel); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + } + + protected void setServerConfig() { + Server currentServer = getCurrentServer(); + if(currentServer != null) { + Config.get().setCoreServer(currentServer); + } + } + + protected void setServerAlias(Server server) { + Config.get().setCoreServerAlias(server); + } + + protected Protocol getProtocol() { + return Protocol.HTTP; + } + + protected void setProtocol(Protocol protocol) { + //empty + } + + private void setCoreAuth() { + Config.get().setCoreAuth(user.getText() + ":" + pass.getText()); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/GeneralDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/GeneralDialog.java new file mode 100644 index 00000000..8f31b07d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/GeneralDialog.java @@ -0,0 +1,156 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.UnitFormat; +import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent; +import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent; +import com.sparrowwallet.sparrow.event.UnitFormatChangedEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.net.ExchangeSource; +import javafx.application.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Currency; +import java.util.List; + +public class GeneralDialog extends DialogWindow { + private static final Logger log = LoggerFactory.getLogger(GeneralDialog.class); + + private final ComboBox bitcoinUnit; + private final ComboBox unitFormat; + + private final ComboBox fiatCurrency; + private final ComboBox exchangeSource; + + private final ComboBox.Listener fiatCurrencyListener = new ComboBox.Listener() { + @Override + public void onSelectionChanged(int selectedIndex, int previousSelection, boolean changedByUserInteraction) { + Currency newValue = fiatCurrency.getSelectedItem(); + if(newValue != null) { + Config.get().setFiatCurrency(newValue); + Platform.runLater(() -> { + EventManager.get().post(new FiatCurrencySelectedEvent(exchangeSource.getSelectedItem(), newValue)); + }); + } + } + }; + + public GeneralDialog() { + super("General Preferences"); + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(5)); + + mainPanel.addComponent(new Label("Bitcoin Unit")); + bitcoinUnit = new ComboBox<>(BitcoinUnit.values()); + mainPanel.addComponent(bitcoinUnit); + + mainPanel.addComponent(new Label("Unit Format")); + unitFormat = new ComboBox<>("1,234.56", "1.235,56"); + mainPanel.addComponent(unitFormat); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("Currency")); + fiatCurrency = new ComboBox<>(); + mainPanel.addComponent(fiatCurrency); + + mainPanel.addComponent(new Label("Exchange rate source")); + exchangeSource = new ComboBox<>(ExchangeSource.values()); + mainPanel.addComponent(exchangeSource); + + bitcoinUnit.setSelectedItem(Config.get().getBitcoinUnit() == null ? BitcoinUnit.AUTO : Config.get().getBitcoinUnit()); + unitFormat.setSelectedIndex(Config.get().getUnitFormat() == UnitFormat.COMMA ? 1 : 0); + + if(Config.get().getExchangeSource() == null) { + Config.get().setExchangeSource(ExchangeSource.COINGECKO); + } + exchangeSource.setSelectedItem(Config.get().getExchangeSource()); + + bitcoinUnit.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> { + BitcoinUnit newValue = bitcoinUnit.getSelectedItem(); + Config.get().setBitcoinUnit(newValue); + Platform.runLater(() -> { + EventManager.get().post(new BitcoinUnitChangedEvent(newValue)); + }); + }); + + unitFormat.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> { + UnitFormat format = selectedIndex == 1 ? UnitFormat.COMMA : UnitFormat.DOT; + Config.get().setUnitFormat(format); + Platform.runLater(() -> { + EventManager.get().post(new UnitFormatChangedEvent(format)); + }); + }); + + exchangeSource.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> { + ExchangeSource source = exchangeSource.getSelectedItem(); + Config.get().setExchangeSource(source); + updateCurrencies(source); + }); + + updateCurrencies(exchangeSource.getSelectedItem()); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Done", this::onDone).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + } + + private void onDone() { + close(); + } + + private void updateCurrencies(ExchangeSource exchangeSource) { + Platform.runLater(() -> { + ExchangeSource.CurrenciesService currenciesService = new ExchangeSource.CurrenciesService(exchangeSource); + currenciesService.setOnSucceeded(event -> { + updateCurrencies(currenciesService.getValue()); + }); + currenciesService.setOnFailed(event -> { + log.error("Error retrieving currencies", event.getSource().getException()); + }); + currenciesService.start(); + }); + } + + private void updateCurrencies(List currencies) { + fiatCurrency.removeListener(fiatCurrencyListener); + + fiatCurrency.clearItems(); + currencies.forEach(fiatCurrency::addItem); + + Currency configCurrency = Config.get().getFiatCurrency(); + if(configCurrency != null && currencies.contains(configCurrency)) { + fiatCurrency.setVisible(true); + fiatCurrency.setSelectedItem(configCurrency); + } else if(!currencies.isEmpty()) { + fiatCurrency.setVisible(true); + fiatCurrency.setSelectedIndex(0); + Config.get().setFiatCurrency(fiatCurrency.getSelectedItem()); + } else { + fiatCurrency.setVisible(false); + } + + //Always fire event regardless of previous selection to update rates + Platform.runLater(() -> { + EventManager.get().post(new FiatCurrencySelectedEvent(exchangeSource.getSelectedItem(), fiatCurrency.getSelectedItem())); + }); + + fiatCurrency.addListener(fiatCurrencyListener); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/PrivateElectrumDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/PrivateElectrumDialog.java new file mode 100644 index 00000000..93151adf --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/PrivateElectrumDialog.java @@ -0,0 +1,104 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.FileDialogBuilder; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Server; +import com.sparrowwallet.sparrow.net.Protocol; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; + +import java.io.File; +import java.util.List; + +public class PrivateElectrumDialog extends ServerUrlDialog { + private final ComboBox useSsl; + private final TextBox certificate; + private final Button selectCertificate; + + public PrivateElectrumDialog() { + super("Private Electrum"); + + setHints(List.of(Hint.CENTERED)); + Panel mainPanel = new Panel(new GridLayout(3).setHorizontalSpacing(2).setVerticalSpacing(0)); + + if(Config.get().getElectrumServer() == null) { + Config.get().setElectrumServer(new Server("tcp://127.0.0.1:50001")); + } + addUrlComponents(mainPanel, Config.get().getRecentElectrumServers(), Config.get().getElectrumServer()); + addLine(mainPanel); + + mainPanel.addComponent(new Label("Use SSL?")); + useSsl = new ComboBox<>("Yes", "No"); + useSsl.setSelectedIndex(1); + mainPanel.addComponent(useSsl); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("Certificate")); + certificate = new TextBox(new TerminalSize(30, 1), Config.get().getElectrumServerCert() != null ? Config.get().getElectrumServerCert().getAbsolutePath() : ""); + mainPanel.addComponent(certificate); + selectCertificate = new Button("Select..."); + mainPanel.addComponent(selectCertificate); + + Server configuredServer = Config.get().getElectrumServer(); + if(configuredServer != null) { + Protocol protocol = configuredServer.getProtocol(); + boolean ssl = protocol.equals(Protocol.SSL); + useSsl.setSelectedIndex(ssl ? 0 : 1); + certificate.setEnabled(ssl); + selectCertificate.setEnabled(ssl); + } + + useSsl.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + setServerConfig(); + certificate.setEnabled(selectedIndex == 0); + selectCertificate.setEnabled(selectedIndex == 0); + }); + certificate.setTextChangeListener((newText, changedByUserInteraction) -> { + File crtFile = getCertificate(newText); + Config.get().setElectrumServerCert(crtFile); + }); + selectCertificate.addListener(button -> { + FileDialogBuilder builder = new FileDialogBuilder().setTitle("Select SSL Certificate").setActionLabel("Select"); + builder.setShowHiddenDirectories(true); + File file = builder.build().showDialog(SparrowTerminal.get().getGui()); + if(file != null && getCertificate(file.getAbsolutePath()) != null) { + certificate.setText(file.getAbsolutePath()); + } + }); + + addLine(mainPanel); + addProxyComponents(mainPanel); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Test", this::onTest)); + buttonPanel.addComponent(new Button("Done", this::onDone)); + + addLine(mainPanel); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + } + + protected void setServerConfig() { + Server currentServer = getCurrentServer(); + if(currentServer != null) { + Config.get().setElectrumServer(currentServer); + } + } + + protected void setServerAlias(Server server) { + Config.get().setElectrumServerAlias(server); + } + + protected Protocol getProtocol() { + return (useSsl.getSelectedIndex() == 0 ? Protocol.SSL : Protocol.TCP); + } + + protected void setProtocol(Protocol protocol) { + useSsl.setSelectedIndex(protocol == Protocol.SSL ? 0 : 1); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/PublicElectrumDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/PublicElectrumDialog.java new file mode 100644 index 00000000..f3d90b24 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/PublicElectrumDialog.java @@ -0,0 +1,66 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.net.PublicElectrumServer; + +import java.util.List; + +public class PublicElectrumDialog extends ServerProxyDialog { + private final ComboBox url; + + public PublicElectrumDialog() { + super("Public Electrum"); + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(new GridLayout(3).setHorizontalSpacing(2).setVerticalSpacing(0)); + mainPanel.addComponent(new Label("Warning!")); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new Label("Using a public server means it can see your transactions"), + GridLayout.createLayoutData(GridLayout.Alignment.BEGINNING, GridLayout.Alignment.CENTER,true,false, 3, 1)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("URL")); + url = new ComboBox<>(); + for(PublicElectrumServer server : PublicElectrumServer.getServers()) { + url.addItem(server); + } + if(Config.get().getPublicElectrumServer() == null) { + Config.get().changePublicServer(); + } + url.setSelectedItem(PublicElectrumServer.fromServer(Config.get().getPublicElectrumServer())); + url.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + if(selectedIndex != previousSelection) { + Config.get().setPublicElectrumServer(PublicElectrumServer.values()[selectedIndex].getServer()); + } + }); + mainPanel.addComponent(url); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + addProxyComponents(mainPanel); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Test", this::onTest)); + buttonPanel.addComponent(new Button("Done", this::onDone)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerProxyDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerProxyDialog.java new file mode 100644 index 00000000..71dae49f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerProxyDialog.java @@ -0,0 +1,129 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.google.common.net.HostAndPort; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.Mode; +import com.sparrowwallet.sparrow.event.RequestConnectEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import javafx.application.Platform; + +import java.io.File; +import java.io.FileInputStream; +import java.security.cert.CertificateFactory; +import java.util.regex.Pattern; + +public abstract class ServerProxyDialog extends DialogWindow { + private ComboBox useProxy; + private TextBox proxyHost; + private TextBox proxyPort; + + public ServerProxyDialog(String title) { + super(title); + } + + protected void onDone() { + close(); + + Platform.runLater(() -> { + if(Config.get().getMode() == Mode.ONLINE && !(AppServices.isConnecting() || AppServices.isConnected())) { + EventManager.get().post(new RequestConnectEvent()); + } + }); + } + + protected void onTest() { + close(); + + ServerTestDialog serverTestDialog = new ServerTestDialog(); + serverTestDialog.showDialog(SparrowTerminal.get().getGui()); + } + + protected void addProxyComponents(Panel mainPanel) { + mainPanel.addComponent(new Label("Use Proxy?")); + useProxy = new ComboBox<>("Yes", "No"); + useProxy.setSelectedIndex(Config.get().isUseProxy() ? 0 : 1); + useProxy.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + Config.get().setUseProxy(selectedIndex == 0); + }); + mainPanel.addComponent(useProxy); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("Proxy URL")); + proxyHost = new TextBox(new TerminalSize(30,1)).setValidationPattern(Pattern.compile("[a-zA-Z0-9.]+")); + mainPanel.addComponent(proxyHost); + + proxyPort = new TextBox(new TerminalSize(6,1)).setValidationPattern(Pattern.compile("[0-9]*")); + mainPanel.addComponent(proxyPort); + + String proxyServer = Config.get().getProxyServer(); + if(proxyServer != null) { + HostAndPort server = HostAndPort.fromString(proxyServer); + proxyHost.setText(server.getHost()); + if(server.hasPort()) { + proxyPort.setText(Integer.toString(server.getPort())); + } + } + + proxyHost.setTextChangeListener((newText, changedByUserInteraction) -> { + setProxyConfig(); + }); + proxyPort.setTextChangeListener((newText, changedByUserInteraction) -> { + setProxyConfig(); + }); + } + + private void setProxyConfig() { + String hostAsString = getHost(proxyHost.getText()); + Integer portAsInteger = getPort(proxyPort.getText()); + if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) { + Config.get().setProxyServer(HostAndPort.fromParts(hostAsString, portAsInteger).toString()); + } else if(hostAsString != null) { + Config.get().setProxyServer(HostAndPort.fromHost(hostAsString).toString()); + } + } + + protected String getHost(String text) { + try { + return HostAndPort.fromHost(text).getHost(); + } catch(IllegalArgumentException e) { + return null; + } + } + + protected Integer getPort(String text) { + try { + return Integer.parseInt(text); + } catch(NumberFormatException e) { + return null; + } + } + + protected static boolean isValidPort(int port) { + return port >= 0 && port <= 65535; + } + + protected File getCertificate(String crtFileLocation) { + try { + File crtFile = new File(crtFileLocation); + if(!crtFile.exists()) { + return null; + } + + CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile)); + return crtFile; + } catch (Exception e) { + return null; + } + } + + protected void addLine(Panel mainPanel) { + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerStatusDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerStatusDialog.java new file mode 100644 index 00000000..e1edea5e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerStatusDialog.java @@ -0,0 +1,74 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.Mode; +import com.sparrowwallet.sparrow.event.RequestConnectEvent; +import com.sparrowwallet.sparrow.event.RequestDisconnectEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import javafx.application.Platform; + +import java.util.List; + +public class ServerStatusDialog extends DialogWindow { + private final ComboBox connect; + + public ServerStatusDialog() { + super("Server Preferences"); + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(5)); + + mainPanel.addComponent(new Label("Connect?")); + connect = new ComboBox<>(); + connect.addItem("Yes"); + connect.addItem("No"); + connect.setSelectedIndex(Config.get().getMode() == Mode.ONLINE ? 0 : 1); + connect.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + if(selectedIndex != previousSelection) { + Config.get().setMode(selectedIndex == 0 ? Mode.ONLINE : Mode.OFFLINE); + Platform.runLater(() -> { + EventManager.get().post(selectedIndex == 0 ? new RequestConnectEvent() : new RequestDisconnectEvent()); + }); + } + }); + mainPanel.addComponent(connect); + + mainPanel.addComponent(new Label("Server Type")); + mainPanel.addComponent(new Label(Config.get().getServerType().getName())); + + mainPanel.addComponent(new Label("Server")); + mainPanel.addComponent(new Label(Config.get().getServer().getDisplayName())); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Edit", this::onEdit).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + buttonPanel.addComponent(new Button("Cancel", this::onCancel)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + } + + private void onEdit() { + Platform.runLater(() -> { + EventManager.get().post(new RequestDisconnectEvent()); + }); + close(); + + ServerTypeDialog serverTypeDialog = new ServerTypeDialog(); + serverTypeDialog.showDialog(SparrowTerminal.get().getGui()); + } + + private void onCancel() { + close(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java new file mode 100644 index 00000000..49dfa3dd --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTestDialog.java @@ -0,0 +1,228 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.google.common.eventbus.Subscribe; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.Mode; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.net.*; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import javafx.application.Platform; +import javafx.scene.control.ButtonType; +import javafx.util.Duration; +import org.berndpruenster.netlayer.tor.Tor; + +import java.io.File; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Optional; + +public class ServerTestDialog extends DialogWindow { + private final Label testStatus; + private final TextBox testResults; + + private TorService torService; + private ElectrumServer.ConnectionService connectionService; + + public ServerTestDialog() { + super("Server Test"); + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(new GridLayout(1)); + + this.testStatus = new Label(""); + mainPanel.addComponent(testStatus); + + this.testResults = new TextBox(new TerminalSize(60, 10)); + testResults.setReadOnly(true); + mainPanel.addComponent(testResults); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(3).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Back", this::onBack).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, false, false))); + buttonPanel.addComponent(new Button("Test", this::onTest).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + buttonPanel.addComponent(new Button("Done", this::onDone)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + + EventManager.get().register(this); + + onTest(); + } + + public void onBack() { + close(); + + if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) { + PublicElectrumDialog publicElectrumServer = new PublicElectrumDialog(); + publicElectrumServer.showDialog(SparrowTerminal.get().getGui()); + } else if(Config.get().getServerType() == ServerType.BITCOIN_CORE) { + BitcoinCoreDialog bitcoinCoreDialog = new BitcoinCoreDialog(); + bitcoinCoreDialog.showDialog(SparrowTerminal.get().getGui()); + } else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) { + PrivateElectrumDialog privateElectrumDialog = new PrivateElectrumDialog(); + privateElectrumDialog.showDialog(SparrowTerminal.get().getGui()); + } + } + + public void onTest() { + testResults.setText("Connecting " + (Config.get().hasServer() ? "to " + Config.get().getServer().getUrl() : "") + "..."); + + Platform.runLater(() -> { + if(Config.get().requiresInternalTor() && Tor.getDefault() == null) { + startTor(); + } else { + startElectrumConnection(); + } + }); + } + + public void onDone() { + EventManager.get().unregister(this); + close(); + + Platform.runLater(() -> { + if(Config.get().getMode() == Mode.ONLINE && !(AppServices.isConnecting() || AppServices.isConnected())) { + EventManager.get().post(new RequestConnectEvent()); + } + }); + } + + private void startTor() { + if(torService != null && torService.isRunning()) { + return; + } + + torService = new TorService(); + torService.setPeriod(Duration.hours(1000)); + torService.setRestartOnFailure(false); + + torService.setOnSucceeded(workerStateEvent -> { + Tor.setDefault(torService.getValue()); + torService.cancel(); + appendText("\nTor running, connecting to " + Config.get().getServer().getUrl() + "..."); + startElectrumConnection(); + }); + torService.setOnFailed(workerStateEvent -> { + torService.cancel(); + appendText("\nTor failed to start"); + showConnectionFailure(workerStateEvent.getSource().getException()); + }); + + torService.start(); + } + + private void startElectrumConnection() { + if(connectionService != null && connectionService.isRunning()) { + connectionService.cancel(); + } + + connectionService = new ElectrumServer.ConnectionService(false); + connectionService.setPeriod(Duration.hours(1)); + connectionService.setRestartOnFailure(false); + EventManager.get().register(connectionService); + + connectionService.setOnSucceeded(successEvent -> { + EventManager.get().unregister(connectionService); + ConnectionEvent connectionEvent = (ConnectionEvent)connectionService.getValue(); + showConnectionSuccess(connectionEvent.getServerVersion(), connectionEvent.getServerBanner()); + Config.get().setMode(Mode.ONLINE); + connectionService.cancel(); + Config.get().addRecentServer(); + }); + connectionService.setOnFailed(workerStateEvent -> { + EventManager.get().unregister(connectionService); + showConnectionFailure(workerStateEvent.getSource().getException()); + connectionService.cancel(); + }); + connectionService.start(); + } + + private void appendText(String text) { + testResults.setText(testResults.getText() + text); + } + + private void showConnectionSuccess(List serverVersion, String serverBanner) { + testStatus.setText("Success"); + if(serverVersion != null) { + testResults.setText("Connected to " + serverVersion.get(0) + " on protocol version " + serverVersion.get(1)); + if(ElectrumServer.supportsBatching(serverVersion)) { + testResults.setText(testResults.getText() + "\nBatched RPC enabled."); + } + } + if(serverBanner != null) { + testResults.setText(testResults.getText() + "\nServer Banner: " + serverBanner); + } + } + + private void showConnectionFailure(Throwable exception) { + String reason = exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage(); + if(exception instanceof TlsServerException && exception.getCause() != null) { + TlsServerException tlsServerException = (TlsServerException)exception; + if(exception.getCause().getMessage().contains("PKIX path building failed")) { + File configCrtFile = Config.get().getElectrumServerCert(); + File savedCrtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost()); + if(configCrtFile == null && savedCrtFile != null) { + Optional optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." + + "\n\nThis may indicate a man-in-the-middle attack!" + + "\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES); + if(optButton.isPresent() && optButton.get() == ButtonType.YES) { + if(savedCrtFile.delete()) { + Platform.runLater(this::startElectrumConnection); + return; + } else { + AppServices.showErrorDialog("Could not delete certificate", "The certificate file at " + savedCrtFile.getAbsolutePath() + " could not be deleted.\n\nPlease delete this file manually."); + } + } + } + } + + reason = tlsServerException.getMessage() + "\n\n" + reason; + } else if(exception instanceof ProxyServerException) { + reason += ". Check if the proxy server is running."; + } else if(exception instanceof TorServerAlreadyBoundException) { + reason += "\nIs a Tor proxy already running on port " + TorService.PROXY_PORT + "?"; + } else if(reason != null && reason.contains("Check if Bitcoin Core is running")) { + reason += "\n\nSee https://sparrowwallet.com/docs/connect-node.html"; + } + + testStatus.setText("Failed"); + testResults.setText("Could not connect:\n\n" + reason); + } + + @Subscribe + public void bwtStatus(BwtStatusEvent event) { + if(!(event instanceof BwtSyncStatusEvent)) { + appendText("\n" + event.getStatus()); + } + } + + @Subscribe + public void bwtSyncStatus(BwtSyncStatusEvent event) { + if(connectionService != null && connectionService.isRunning() && event.getProgress() < 100) { + DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm"); + appendText("\nThe connection to the Bitcoin Core node was successful, but it is still syncing and cannot be used yet."); + appendText("\nCurrently " + event.getProgress() + "% completed to date " + dateFormat.format(event.getTip())); + connectionService.cancel(); + } + } + + @Subscribe + public void torStatus(TorStatusEvent event) { + Platform.runLater(() -> { + if(torService != null && torService.isRunning()) { + appendText("\n" + event.getStatus()); + } + }); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTypeDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTypeDialog.java new file mode 100644 index 00000000..1036fa60 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerTypeDialog.java @@ -0,0 +1,68 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.net.ServerType; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; + +import java.util.List; + +public class ServerTypeDialog extends DialogWindow { + private final RadioBoxList type; + + public ServerTypeDialog() { + super("Server Type"); + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(5)); + + ServerType[] serverTypes = new ServerType[] { ServerType.PUBLIC_ELECTRUM_SERVER, ServerType.BITCOIN_CORE, ServerType.ELECTRUM_SERVER }; + + mainPanel.addComponent(new Label("Connect using")); + type = new RadioBoxList<>(); + for(ServerType serverType : serverTypes) { + type.addItem(serverType.getName()); + } + + if(Config.get().getServerType() == null) { + Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER); + } + type.setCheckedItem(Config.get().getServerType().getName()); + type.addListener((selectedIndex, previousSelection) -> { + if(selectedIndex != previousSelection) { + Config.get().setServerType(serverTypes[selectedIndex]); + } + }); + mainPanel.addComponent(type); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(1).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Continue", this::onContinue)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + } + + private void onContinue() { + close(); + + if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) { + PublicElectrumDialog publicElectrumServer = new PublicElectrumDialog(); + publicElectrumServer.showDialog(SparrowTerminal.get().getGui()); + } else if(Config.get().getServerType() == ServerType.BITCOIN_CORE) { + BitcoinCoreDialog bitcoinCoreDialog = new BitcoinCoreDialog(); + bitcoinCoreDialog.showDialog(SparrowTerminal.get().getGui()); + } else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) { + PrivateElectrumDialog privateElectrumDialog = new PrivateElectrumDialog(); + privateElectrumDialog.showDialog(SparrowTerminal.get().getGui()); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerUrlDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerUrlDialog.java new file mode 100644 index 00000000..b2d40b20 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/preferences/ServerUrlDialog.java @@ -0,0 +1,126 @@ +package com.sparrowwallet.sparrow.terminal.preferences; + +import com.google.common.net.HostAndPort; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.sparrowwallet.sparrow.io.Server; +import com.sparrowwallet.sparrow.net.Protocol; + +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +public abstract class ServerUrlDialog extends ServerProxyDialog { + private ComboBox host; + private TextBox port; + private TextBox alias; + + public ServerUrlDialog(String title) { + super(title); + } + + protected void addUrlComponents(Panel mainPanel, List recentServers, Server configuredServer) { + mainPanel.addComponent(new Label("URL")); + host = new ComboBox<>(); + host.setPreferredSize(new TerminalSize(30,1)); + host.setReadOnly(false); + mainPanel.addComponent(host, GridLayout.createLayoutData(GridLayout.Alignment.BEGINNING, GridLayout.Alignment.CENTER, true, false)); + port = new TextBox(new TerminalSize(6,1)).setValidationPattern(Pattern.compile("[0-9]*")); + mainPanel.addComponent(port); + + mainPanel.addComponent(new Label("Alias (optional)")); + alias = new TextBox(new TerminalSize(30,1)); + mainPanel.addComponent(alias); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + if(configuredServer != null) { + HostAndPort hostAndPort = configuredServer.getHostAndPort(); + recentServers.stream().map(ServerItem::new).forEach(host::addItem); + host.setSelectedItem(new ServerItem(configuredServer)); + if(host.getItemCount() == 0) { + host.addItem(new ServerItem(configuredServer)); + } + if(hostAndPort.hasPort()) { + port.setText(Integer.toString(hostAndPort.getPort())); + } + + if(configuredServer.getAlias() != null) { + alias.setText(configuredServer.getAlias()); + } + } + + host.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + Optional optServer = recentServers.stream().filter(server -> server.equals(host.getSelectedItem().getServer())).findFirst(); + if(optServer.isPresent()) { + Server server = optServer.get(); + port.setText(server.getHostAndPort().hasPort() ? Integer.toString(server.getHostAndPort().getPort()) : ""); + alias.setText(server.getAlias() == null ? "" : server.getAlias()); + setProtocol(server.getProtocol()); + } + setServerConfig(); + }); + port.setTextChangeListener((newText, changedByUserInteraction) -> { + setServerConfig(); + }); + alias.setTextChangeListener((newText, changedByUserInteraction) -> { + Server currentServer = getCurrentServer(); + if(currentServer != null && host.getSelectedItem() != null && currentServer.equals(host.getSelectedItem().getServer())) { + setServerAlias(currentServer); + } + setServerConfig(); + }); + } + + @Override + protected void onDone() { + setServerConfig(); + super.onDone(); + } + + @Override + protected void onTest() { + setServerConfig(); + super.onTest(); + } + + protected abstract void setServerConfig(); + + protected abstract void setServerAlias(Server server); + + protected abstract Protocol getProtocol(); + + protected abstract void setProtocol(Protocol protocol); + + protected Server getCurrentServer() { + String hostAsString = getHost(host.getText()); + Integer portAsInteger = getPort(port.getText()); + if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) { + return new Server(getProtocol().toUrlString(hostAsString, portAsInteger), getAlias()); + } else if(hostAsString != null) { + return new Server(getProtocol().toUrlString(hostAsString), getAlias()); + } + + return null; + } + + private String getAlias() { + return alias.getText().isEmpty() ? null : alias.getText(); + } + + protected static class ServerItem { + private final Server server; + + public ServerItem(Server server) { + this.server = server; + } + + public Server getServer() { + return server; + } + + @Override + public String toString() { + return server.getHost(); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AddressesDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AddressesDialog.java new file mode 100644 index 00000000..c877033d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AddressesDialog.java @@ -0,0 +1,116 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.google.common.eventbus.Subscribe; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.table.Table; +import com.googlecode.lanterna.gui2.table.TableModel; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.sparrow.event.UnitFormatChangedEvent; +import com.sparrowwallet.sparrow.event.WalletHistoryChangedEvent; +import com.sparrowwallet.sparrow.event.WalletNodesChangedEvent; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.terminal.wallet.table.AddressTableCell; +import com.sparrowwallet.sparrow.terminal.wallet.table.CoinTableCell; +import com.sparrowwallet.sparrow.terminal.wallet.table.TableCell; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.Function; +import com.sparrowwallet.sparrow.wallet.NodeEntry; +import com.sparrowwallet.sparrow.wallet.WalletForm; + +import java.util.List; + +public class AddressesDialog extends WalletDialog { + private final Table receiveTable; + private final Table changeTable; + + public AddressesDialog(WalletForm walletForm) { + super(walletForm.getWallet().getFullDisplayName() + " Addresses", walletForm); + + setHints(List.of(Hint.CENTERED, Hint.EXPANDED)); + + String[] tableColumns = getTableColumns(); + receiveTable = new Table<>(tableColumns); + receiveTable.setTableCellRenderer(new EntryTableCellRenderer()); + + changeTable = new Table<>(tableColumns); + changeTable.setTableCellRenderer(new EntryTableCellRenderer()); + + updateAddresses(); + + Panel buttonPanel = new Panel(new GridLayout(4).setHorizontalSpacing(2).setVerticalSpacing(0)); + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + buttonPanel.addComponent(new Button("Back", () -> onBack(Function.ADDRESSES))); + buttonPanel.addComponent(new Button("Refresh", this::onRefresh)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new LinearLayout(Direction.VERTICAL).setSpacing(1)); + mainPanel.addComponent(receiveTable.withBorder(new EmptyBorder("Receive"))); + mainPanel.addComponent(changeTable.withBorder(new EmptyBorder("Change"))); + mainPanel.addComponent(buttonPanel); + + setComponent(mainPanel); + + configureTable(receiveTable, KeyPurpose.RECEIVE); + configureTable(changeTable, KeyPurpose.CHANGE); + } + + private void configureTable(Table table, KeyPurpose keyPurpose) { + table.setSelectAction(() -> { + NodeEntry nodeEntry = (NodeEntry)getWalletForm().getNodeEntry(keyPurpose).getChildren().get(table.getSelectedRow()); + close(); + WalletData walletData = SparrowTerminal.get().getWalletData().get(getWalletForm().getWallet()); + ReceiveDialog receiveDialog = walletData.getReceiveDialog(); + receiveDialog.setNodeEntry(nodeEntry); + receiveDialog.showDialog(SparrowTerminal.get().getGui()); + }); + + Integer highestUsedReceiveIndex = getWalletForm().getNodeEntry(keyPurpose).getNode().getHighestUsedIndex(); + if(highestUsedReceiveIndex != null) { + table.setSelectedRow(highestUsedReceiveIndex + 1); + } + } + + private String[] getTableColumns() { + String address = getWalletForm().getNodeEntry(KeyPurpose.RECEIVE).getAddress().toString(); + return new String[] {centerPad("Address", address.length()), centerPad("Value", CoinTableCell.TRANSACTION_WIDTH)}; + } + + private void updateAddressesLater() { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(this::updateAddresses); + } + + private void updateAddresses() { + receiveTable.setTableModel(getTableModel(getWalletForm().getNodeEntry(KeyPurpose.RECEIVE))); + changeTable.setTableModel(getTableModel(getWalletForm().getNodeEntry(KeyPurpose.CHANGE))); + } + + private TableModel getTableModel(NodeEntry nodeEntry) { + TableModel tableModel = new TableModel<>(getTableColumns()); + for(Entry addressEntry : nodeEntry.getChildren()) { + tableModel.addRow(new AddressTableCell(addressEntry), new CoinTableCell(addressEntry, false)); + } + + return tableModel; + } + + @Subscribe + public void walletNodesChanged(WalletNodesChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + updateAddressesLater(); + } + } + + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + updateAddressesLater(); + } + } + + @Subscribe + public void unitFormatChanged(UnitFormatChangedEvent event) { + updateAddressesLater(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/EmptyBorder.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/EmptyBorder.java new file mode 100644 index 00000000..be54064b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/EmptyBorder.java @@ -0,0 +1,118 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalPosition; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.TerminalTextUtils; +import com.googlecode.lanterna.graphics.ThemeDefinition; +import com.googlecode.lanterna.gui2.*; + +public class EmptyBorder extends AbstractBorder { + private final String title; + + protected EmptyBorder(String title) { + if (title == null) { + throw new IllegalArgumentException("Cannot create a border with null title"); + } + this.title = title; + } + + public String getTitle() { + return title; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{" + title + "}"; + } + + @Override + protected BorderRenderer createDefaultRenderer() { + return new EmptyBorderRenderer(); + } + + private static class EmptyBorderRenderer implements Border.BorderRenderer { + @Override + public TerminalSize getPreferredSize(Border component) { + EmptyBorder border = (EmptyBorder)component; + Component wrappedComponent = border.getComponent(); + TerminalSize preferredSize; + if (wrappedComponent == null) { + preferredSize = TerminalSize.ZERO; + } else { + preferredSize = wrappedComponent.getPreferredSize(); + } + preferredSize = preferredSize.withRelativeColumns(2).withRelativeRows(2); + String borderTitle = border.getTitle(); + return preferredSize.max(new TerminalSize((borderTitle.isEmpty() ? 2 : TerminalTextUtils.getColumnWidth(borderTitle) + 4), 2)); + } + + @Override + public TerminalPosition getWrappedComponentTopLeftOffset() { + return TerminalPosition.OFFSET_1x1; + } + + @Override + public TerminalSize getWrappedComponentSize(TerminalSize borderSize) { + return borderSize + .withRelativeColumns(-Math.min(2, borderSize.getColumns())) + .withRelativeRows(-Math.min(2, borderSize.getRows())); + } + + @Override + public void drawComponent(TextGUIGraphics graphics, Border component) { + EmptyBorder border = (EmptyBorder)component; + Component wrappedComponent = border.getComponent(); + if(wrappedComponent == null) { + return; + } + TerminalSize drawableArea = graphics.getSize(); + + char horizontalLine = ' '; + char verticalLine = ' '; + char bottomLeftCorner = ' '; + char topLeftCorner = ' '; + char bottomRightCorner = ' '; + char topRightCorner = ' '; + char titleLeft = ' '; + char titleRight = ' '; + + ThemeDefinition themeDefinition = component.getTheme().getDefinition(AbstractBorder.class); + graphics.applyThemeStyle(themeDefinition.getNormal()); + graphics.setCharacter(0, drawableArea.getRows() - 1, bottomLeftCorner); + if(drawableArea.getRows() > 2) { + graphics.drawLine(new TerminalPosition(0, drawableArea.getRows() - 2), new TerminalPosition(0, 1), verticalLine); + } + graphics.setCharacter(0, 0, topLeftCorner); + if(drawableArea.getColumns() > 2) { + graphics.drawLine(new TerminalPosition(1, 0), new TerminalPosition(drawableArea.getColumns() - 2, 0), horizontalLine); + } + + graphics.applyThemeStyle(themeDefinition.getNormal()); + graphics.setCharacter(drawableArea.getColumns() - 1, 0, topRightCorner); + if(drawableArea.getRows() > 2) { + graphics.drawLine(new TerminalPosition(drawableArea.getColumns() - 1, 1), + new TerminalPosition(drawableArea.getColumns() - 1, drawableArea.getRows() - 2), + verticalLine); + } + graphics.setCharacter(drawableArea.getColumns() - 1, drawableArea.getRows() - 1, bottomRightCorner); + if(drawableArea.getColumns() > 2) { + graphics.drawLine(new TerminalPosition(1, drawableArea.getRows() - 1), + new TerminalPosition(drawableArea.getColumns() - 2, drawableArea.getRows() - 1), + horizontalLine); + } + + + if(border.getTitle() != null && !border.getTitle().isEmpty() && + drawableArea.getColumns() >= TerminalTextUtils.getColumnWidth(border.getTitle()) + 4) { + graphics.applyThemeStyle(themeDefinition.getActive()); + graphics.putString(2, 0, border.getTitle()); + + graphics.applyThemeStyle(themeDefinition.getNormal()); + graphics.setCharacter(1, 0, titleLeft); + graphics.setCharacter(2 + TerminalTextUtils.getColumnWidth(border.getTitle()), 0, titleRight); + } + + wrappedComponent.draw(graphics.newTextGraphics(getWrappedComponentTopLeftOffset(), getWrappedComponentSize(drawableArea))); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/EntryTableCellRenderer.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/EntryTableCellRenderer.java new file mode 100644 index 00000000..d7e21c2e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/EntryTableCellRenderer.java @@ -0,0 +1,18 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.gui2.table.DefaultTableCellRenderer; +import com.sparrowwallet.sparrow.terminal.wallet.table.TableCell; + +public class EntryTableCellRenderer extends DefaultTableCellRenderer { + @Override + protected String[] getContent(TableCell cell) { + String[] lines; + if(cell == null) { + lines = new String[] { "" }; + } else { + lines = new String[] { cell.formatCell() }; + } + + return lines; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/LoadWallet.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/LoadWallet.java new file mode 100644 index 00000000..b6a7b9bc --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/LoadWallet.java @@ -0,0 +1,190 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.*; +import com.sparrowwallet.drongo.SecureString; +import com.sparrowwallet.drongo.crypto.InvalidPasswordException; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.MainApp; +import com.sparrowwallet.sparrow.TabData; +import com.sparrowwallet.sparrow.WalletTabData; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.io.StorageException; +import com.sparrowwallet.sparrow.io.WalletAndKey; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import javafx.application.Platform; +import javafx.scene.control.ButtonType; +import javafx.stage.Window; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; +import static com.sparrowwallet.sparrow.terminal.MasterActionListBox.MAX_RECENT_WALLETS; + +public class LoadWallet implements Runnable { + private static final Logger log = LoggerFactory.getLogger(LoadWallet.class); + private final Storage storage; + private final LoadingDialog loadingDialog; + + public LoadWallet(Storage storage) { + this.storage = storage; + this.loadingDialog = new LoadingDialog(storage); + } + + @Override + public void run() { + SparrowTerminal.get().getGui().addWindow(loadingDialog); + + try { + if(!storage.isEncrypted()) { + Platform.runLater(() -> { + Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage); + loadWalletService.setExecutor(Storage.LoadWalletService.getSingleThreadedExecutor()); + loadWalletService.setOnSucceeded(workerStateEvent -> { + WalletAndKey walletAndKey = loadWalletService.getValue(); + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> openWallet(storage, walletAndKey)); + }); + loadWalletService.setOnFailed(workerStateEvent -> { + Throwable exception = workerStateEvent.getSource().getException(); + if(exception instanceof StorageException) { + showErrorDialog("Error Opening Wallet", exception.getMessage()); + } + }); + loadWalletService.start(); + }); + } else { + TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Wallet Password"); + builder.setDescription("Enter the password for\n" + storage.getWalletName(null)); + builder.setPasswordInput(true); + + String password = builder.build().showDialog(SparrowTerminal.get().getGui()); + if(password == null) { + return; + } + + Platform.runLater(() -> { + Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage, new SecureString(password)); + loadWalletService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Done")); + WalletAndKey walletAndKey = loadWalletService.getValue(); + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> openWallet(storage, walletAndKey)); + }); + loadWalletService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Failed")); + Throwable exception = loadWalletService.getException(); + if(exception instanceof InvalidPasswordException) { + Optional optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK); + if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) { + run(); + } + } else { + if(exception instanceof StorageException) { + showErrorDialog("Error Opening Wallet", exception.getMessage()); + } + } + }); + EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.START, "Decrypting wallet...")); + loadWalletService.start(); + }); + } + } catch(Exception e) { + if(e instanceof IOException && e.getMessage().startsWith("The process cannot access the file because another process has locked")) { + showErrorDialog("Error Opening Wallet", "The wallet file is locked. Is another instance of " + MainApp.APP_NAME + " already running?"); + } else { + log.error("Error opening wallet", e); + showErrorDialog("Error Opening Wallet", e.getMessage() == null ? "Unsupported file format" : e.getMessage()); + } + } + } + + private void openWallet(Storage storage, WalletAndKey walletAndKey) { + SparrowTerminal.get().getGui().removeWindow(loadingDialog); + + try { + storage.restorePublicKeysFromSeed(walletAndKey.getWallet(), walletAndKey.getKey()); + if(!walletAndKey.getWallet().isValid()) { + throw new IllegalStateException("Wallet file is not valid."); + } + addWallet(storage, walletAndKey.getWallet()); + for(Map.Entry entry : walletAndKey.getChildWallets().entrySet()) { + openWallet(entry.getValue(), entry.getKey()); + } + if(walletAndKey.getWallet().isMasterWallet()) { + getOpeningDialog(walletAndKey.getWallet()).showDialog(SparrowTerminal.get().getGui()); + } + } catch(Exception e) { + log.error("Wallet Error", e); + showErrorDialog("Wallet Error", e.getMessage()); + } finally { + walletAndKey.clear(); + } + } + + private void addWallet(Storage storage, Wallet wallet) { + Platform.runLater(() -> { + if(wallet.isNested()) { + WalletData walletData = SparrowTerminal.get().getWalletData().get(wallet.getMasterWallet()); + WalletForm walletForm = new WalletForm(storage, wallet); + EventManager.get().register(walletForm); + walletData.getWalletForm().getNestedWalletForms().add(walletForm); + } else { + EventManager.get().post(new WalletOpeningEvent(storage, wallet)); + + WalletForm walletForm = new WalletForm(storage, wallet); + EventManager.get().register(walletForm); + SparrowTerminal.get().getWalletData().put(wallet, new WalletData(walletForm)); + + List walletTabDataList = SparrowTerminal.get().getWalletData().values().stream() + .map(data -> new WalletTabData(TabData.TabType.WALLET, data.getWalletForm())).collect(Collectors.toList()); + EventManager.get().post(new OpenWalletsEvent(DEFAULT_WINDOW, walletTabDataList)); + + Set walletFiles = new LinkedHashSet<>(); + walletFiles.add(storage.getWalletFile()); + walletFiles.addAll(Config.get().getRecentWalletFiles().stream().limit(MAX_RECENT_WALLETS - 1).collect(Collectors.toList())); + Config.get().setRecentWalletFiles(Config.get().isLoadRecentWallets() ? new ArrayList<>(walletFiles) : Collections.emptyList()); + } + + EventManager.get().post(new WalletOpenedEvent(storage, wallet)); + }); + } + + public static DialogWindow getOpeningDialog(Wallet masterWallet) { + if(masterWallet.getChildWallets().stream().anyMatch(childWallet -> !childWallet.isNested())) { + return new WalletAccountsDialog(masterWallet); + } else { + return new WalletActionsDialog(masterWallet); + } + } + + private static final javafx.stage.Window DEFAULT_WINDOW = new Window() { }; + + private static final class LoadingDialog extends DialogWindow { + public LoadingDialog(Storage storage) { + super(storage.getWalletName(null)); + + setHints(List.of(Hint.CENTERED)); + setFixedSize(new TerminalSize(30, 5)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new LinearLayout()); + mainPanel.addComponent(new EmptySpace(), LinearLayout.createLayoutData(LinearLayout.Alignment.Beginning, LinearLayout.GrowPolicy.CanGrow)); + + Label label = new Label("Loading..."); + mainPanel.addComponent(label, LinearLayout.createLayoutData(LinearLayout.Alignment.Center)); + + mainPanel.addComponent(new EmptySpace(), LinearLayout.createLayoutData(LinearLayout.Alignment.Beginning, LinearLayout.GrowPolicy.CanGrow)); + + setComponent(mainPanel); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixToDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixToDialog.java new file mode 100644 index 00000000..142dc5ec --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/MixToDialog.java @@ -0,0 +1,222 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.samourai.whirlpool.client.wallet.beans.IndexRange; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.MixConfig; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; + +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static com.sparrowwallet.drongo.wallet.StandardAccount.WHIRLPOOL_BADBANK; +import static com.sparrowwallet.drongo.wallet.StandardAccount.WHIRLPOOL_PREMIX; + +public class MixToDialog extends WalletDialog { + private static final DisplayWallet NONE_DISPLAY_WALLET = new DisplayWallet(null); + + private MixConfig mixConfig; + + private final ComboBox mixToWallet; + private final TextBox minimumMixes; + private final ComboBox indexRange; + private final Button apply; + + public MixToDialog(WalletForm walletForm) { + super(walletForm.getWallet().getFullDisplayName() + " Mix To", walletForm); + + setHints(List.of(Hint.CENTERED)); + + Wallet wallet = getWalletForm().getWallet(); + this.mixConfig = wallet.getMasterMixConfig().copy(); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(5).setVerticalSpacing(1)); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + mainPanel.addComponent(new Label("Mix to wallet")); + mixToWallet = new ComboBox<>(); + mainPanel.addComponent(mixToWallet); + + mainPanel.addComponent(new Label("Minimum mixes")); + minimumMixes = new TextBox().setValidationPattern(Pattern.compile("[0-9]*")); + mainPanel.addComponent(minimumMixes); + + mainPanel.addComponent(new Label("Postmix index range")); + indexRange = new ComboBox<>(); + mainPanel.addComponent(indexRange); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Cancel", this::onCancel)); + apply = new Button("Apply", this::onApply).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)); + apply.setEnabled(false); + buttonPanel.addComponent(apply); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + + List allWallets = new ArrayList<>(); + allWallets.add(NONE_DISPLAY_WALLET); + List destinationWallets = AppServices.get().getOpenWallets().keySet().stream().filter(openWallet -> openWallet.isValid() + && (openWallet.getScriptType() == ScriptType.P2WPKH || openWallet.getScriptType() == ScriptType.P2WSH) + && openWallet != wallet && openWallet != wallet.getMasterWallet() + && (openWallet.getStandardAccountType() == null || !List.of(WHIRLPOOL_PREMIX, WHIRLPOOL_BADBANK).contains(openWallet.getStandardAccountType()))).collect(Collectors.toList()); + allWallets.addAll(destinationWallets.stream().map(DisplayWallet::new).collect(Collectors.toList())); + allWallets.forEach(mixToWallet::addItem); + + String mixToWalletId = null; + try { + mixToWalletId = AppServices.getWhirlpoolServices().getWhirlpoolMixToWalletId(mixConfig); + } catch(NoSuchElementException e) { + //ignore, mix to wallet is not open + } + + if(mixToWalletId != null) { + mixToWallet.setSelectedItem(new DisplayWallet(AppServices.get().getWallet(mixToWalletId))); + } else { + mixToWallet.setSelectedItem(NONE_DISPLAY_WALLET); + } + + int initialMinMixes = mixConfig.getMinMixes() == null ? Whirlpool.DEFAULT_MIXTO_MIN_MIXES : mixConfig.getMinMixes(); + minimumMixes.setText(Integer.toString(initialMinMixes)); + + List indexRanges = Arrays.stream(IndexRange.values()).map(DisplayIndexRange::new).collect(Collectors.toList()); + indexRanges.forEach(indexRange::addItem); + + indexRange.setSelectedItem(new DisplayIndexRange(IndexRange.FULL)); + if(mixConfig.getIndexRange() != null) { + try { + indexRange.setSelectedItem(new DisplayIndexRange(IndexRange.valueOf(mixConfig.getIndexRange()))); + } catch(Exception e) { + //ignore + } + } + + mixToWallet.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + DisplayWallet newValue = mixToWallet.getSelectedItem(); + if(newValue == NONE_DISPLAY_WALLET) { + mixConfig.setMixToWalletName(null); + mixConfig.setMixToWalletFile(null); + } else { + mixConfig.setMixToWalletName(newValue.getWallet().getName()); + mixConfig.setMixToWalletFile(AppServices.get().getOpenWallets().get(newValue.getWallet()).getWalletFile()); + } + apply.setEnabled(apply.isEnabled() || selectedIndex != previousSelection); + }); + + minimumMixes.setTextChangeListener((newText, changedByUserInteraction) -> { + try { + int newValue = Integer.parseInt(newText); + if(newValue < 2 || newValue > 10000) { + return; + } + + mixConfig.setMinMixes(newValue); + apply.setEnabled(true); + } catch(NumberFormatException e) { + return; + } + }); + + indexRange.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> { + DisplayIndexRange newValue = indexRange.getSelectedItem(); + mixConfig.setIndexRange(newValue.getIndexRange().toString()); + apply.setEnabled(apply.isEnabled() || selectedIndex != previousSelection); + }); + } + + private void onCancel() { + mixConfig = null; + close(); + } + + private void onApply() { + close(); + } + + @Override + public Object showDialog(WindowBasedTextGUI textGUI) { + super.showDialog(textGUI); + return mixConfig; + } + + private static class DisplayWallet { + private final Wallet wallet; + + public DisplayWallet(Wallet wallet) { + this.wallet = wallet; + } + + public Wallet getWallet() { + return wallet; + } + + @Override + public String toString() { + return wallet == null ? "None" : wallet.getFullDisplayName(); + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + + DisplayWallet that = (DisplayWallet) o; + return Objects.equals(wallet, that.wallet); + } + + @Override + public int hashCode() { + return wallet != null ? wallet.hashCode() : 0; + } + } + + private static class DisplayIndexRange { + private final IndexRange indexRange; + + public DisplayIndexRange(IndexRange indexRange) { + this.indexRange = indexRange; + } + + public IndexRange getIndexRange() { + return indexRange; + } + + @Override + public String toString() { + return indexRange.toString().charAt(0) + indexRange.toString().substring(1).toLowerCase(Locale.ROOT); + } + + @Override + public boolean equals(Object o) { + if(this == o) { + return true; + } + if(o == null || getClass() != o.getClass()) { + return false; + } + + DisplayIndexRange that = (DisplayIndexRange) o; + return indexRange == that.indexRange; + } + + @Override + public int hashCode() { + return indexRange.hashCode(); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java new file mode 100644 index 00000000..27d55df8 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java @@ -0,0 +1,138 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.google.common.eventbus.Subscribe; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.wallet.Function; +import com.sparrowwallet.sparrow.wallet.NodeEntry; +import com.sparrowwallet.sparrow.wallet.WalletForm; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Set; + +public class ReceiveDialog extends WalletDialog { + private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + private final Label address; + private final Label derivation; + private final Label lastUsed; + + private NodeEntry currentEntry; + + public ReceiveDialog(WalletForm walletForm) { + super(walletForm.getWallet().getFullDisplayName() + " Receive", walletForm); + + setHints(List.of(Hint.CENTERED)); + + Panel mainPanel = new Panel(new GridLayout(2).setHorizontalSpacing(2).setVerticalSpacing(1).setTopMarginSize(1)); + + mainPanel.addComponent(new Label("Address")); + address = new Label("").addTo(mainPanel); + + mainPanel.addComponent(new Label("Derivation")); + derivation = new Label("").addTo(mainPanel); + + mainPanel.addComponent(new Label("Last Used")); + lastUsed = new Label("").addTo(mainPanel); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Back", () -> onBack(Function.RECEIVE))); + buttonPanel.addComponent(new Button("Get Fresh Address", this::refreshAddress).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel); + setComponent(mainPanel); + + refreshAddress(); + } + + public void refreshAddress() { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + NodeEntry freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, currentEntry); + setNodeEntry(freshEntry); + }); + } + + public void setNodeEntry(NodeEntry nodeEntry) { + this.currentEntry = nodeEntry; + address.setText(nodeEntry.getAddress().toString()); + derivation.setText(getDerivationPath(nodeEntry.getNode())); + updateLastUsed(); + } + + protected String getDerivationPath(WalletNode node) { + if(isSingleDerivationPath()) { + KeyDerivation firstDerivation = getWalletForm().getWallet().getKeystores().get(0).getKeyDerivation(); + return firstDerivation.extend(node.getDerivation()).getDerivationPath(); + } + + return node.getDerivationPath().replace("m", "multi"); + } + + protected boolean isSingleDerivationPath() { + KeyDerivation firstDerivation = getWalletForm().getWallet().getKeystores().get(0).getKeyDerivation(); + for(Keystore keystore : getWalletForm().getWallet().getKeystores()) { + if(!keystore.getKeyDerivation().getDerivationPath().equals(firstDerivation.getDerivationPath())) { + return false; + } + } + + return true; + } + + private void updateLastUsed() { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + Set currentOutputs = currentEntry.getNode().getTransactionOutputs(); + if(AppServices.onlineProperty().get() && currentOutputs.isEmpty()) { + lastUsed.setText("Never"); + } else if(!currentOutputs.isEmpty()) { + long count = currentOutputs.size(); + BlockTransactionHashIndex lastUsedReference = currentOutputs.stream().skip(count - 1).findFirst().get(); + lastUsed.setText(lastUsedReference.getHeight() <= 0 ? "Unconfirmed Transaction" : (lastUsedReference.getDate() == null ? "Unknown" : DATE_FORMAT.format(lastUsedReference.getDate()))); + } else { + lastUsed.setText("Unknown"); + } + }); + } + + @Subscribe + public void walletNodesChanged(WalletNodesChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + if(currentEntry != null) { + currentEntry = null; + } + refreshAddress(); + } + } + + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + if(currentEntry != null && event.getHistoryChangedNodes().contains(currentEntry.getNode())) { + refreshAddress(); + } + } + } + + @Subscribe + public void connection(ConnectionEvent event) { + updateLastUsed(); + } + + @Subscribe + public void disconnection(DisconnectionEvent event) { + updateLastUsed(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/TransactionsDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/TransactionsDialog.java new file mode 100644 index 00000000..a8b72a27 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/TransactionsDialog.java @@ -0,0 +1,155 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.google.common.collect.Lists; +import com.google.common.eventbus.Subscribe; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.table.Table; +import com.googlecode.lanterna.gui2.table.TableModel; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.net.ExchangeSource; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.terminal.wallet.table.CoinTableCell; +import com.sparrowwallet.sparrow.terminal.wallet.table.DateTableCell; +import com.sparrowwallet.sparrow.terminal.wallet.table.TableCell; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.Function; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry; + +import java.util.List; + +public class TransactionsDialog extends WalletDialog { + private final Label balance; + private final Label fiatBalance; + private final Label mempoolBalance; + private final Label fiatMempoolBalance; + private final Label transactionCount; + private final Table transactions; + + private final String[] tableColumns = {centerPad("Date", DateTableCell.TRANSACTION_WIDTH), centerPad("Value", CoinTableCell.TRANSACTION_WIDTH), centerPad("Balance", CoinTableCell.TRANSACTION_WIDTH)}; + + public TransactionsDialog(WalletForm walletForm) { + super(walletForm.getWallet().getFullDisplayName() + " Transactions", walletForm); + + setHints(List.of(Hint.CENTERED, Hint.EXPANDED)); + Panel labelPanel = new Panel(new GridLayout(3).setHorizontalSpacing(5).setVerticalSpacing(0)); + + WalletTransactionsEntry walletTransactionsEntry = getWalletForm().getWalletTransactionsEntry(); + + labelPanel.addComponent(new Label("Balance")); + balance = new Label("").addTo(labelPanel); + fiatBalance = new Label("").addTo(labelPanel); + + labelPanel.addComponent(new Label("Mempool")); + mempoolBalance = new Label("").addTo(labelPanel); + fiatMempoolBalance = new Label("").addTo(labelPanel); + + labelPanel.addComponent(new Label("Transactions")); + transactionCount = new Label("").addTo(labelPanel); + labelPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + transactions = new Table<>(tableColumns); + transactions.setTableCellRenderer(new EntryTableCellRenderer()); + + updateLabels(walletTransactionsEntry); + updateHistory(getWalletForm().getWalletTransactionsEntry()); + + Panel buttonPanel = new Panel(new GridLayout(4).setHorizontalSpacing(2).setVerticalSpacing(0)); + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + buttonPanel.addComponent(new Button("Back", () -> onBack(Function.TRANSACTIONS))); + buttonPanel.addComponent(new Button("Refresh", this::onRefresh)); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new LinearLayout(Direction.VERTICAL).setSpacing(1)); + mainPanel.addComponent(labelPanel); + mainPanel.addComponent(transactions); + mainPanel.addComponent(buttonPanel); + + setComponent(mainPanel); + } + + private void updateHistory(WalletTransactionsEntry walletTransactionsEntry) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + TableModel tableModel = getTableModel(walletTransactionsEntry); + transactions.setTableModel(tableModel); + }); + } + + private TableModel getTableModel(WalletTransactionsEntry walletTransactionsEntry) { + TableModel tableModel = new TableModel<>(tableColumns); + for(Entry entry : Lists.reverse(walletTransactionsEntry.getChildren())) { + tableModel.addRow(new DateTableCell(entry), new CoinTableCell(entry, false), new CoinTableCell(entry, true)); + } + + return tableModel; + } + + private void updateLabels(WalletTransactionsEntry walletTransactionsEntry) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + balance.setText(formatBitcoinValue(walletTransactionsEntry.getBalance(), true)); + mempoolBalance.setText(formatBitcoinValue(walletTransactionsEntry.getMempoolBalance(), true)); + + if(AppServices.getFiatCurrencyExchangeRate() != null) { + fiatBalance.setText(formatFiatValue(getFiatValue(walletTransactionsEntry.getBalance(), AppServices.getFiatCurrencyExchangeRate().getBtcRate()))); + fiatMempoolBalance.setText(formatFiatValue(getFiatValue(walletTransactionsEntry.getMempoolBalance(), AppServices.getFiatCurrencyExchangeRate().getBtcRate()))); + } else { + fiatBalance.setText(""); + fiatMempoolBalance.setText(""); + } + + setTransactionCount(walletTransactionsEntry); + }); + } + + private void setTransactionCount(WalletTransactionsEntry walletTransactionsEntry) { + transactionCount.setText(walletTransactionsEntry.getChildren() != null ? Integer.toString(walletTransactionsEntry.getChildren().size()) : "0"); + } + + @Subscribe + public void walletNodesChanged(WalletNodesChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + WalletTransactionsEntry walletTransactionsEntry = getWalletForm().getWalletTransactionsEntry(); + updateHistory(walletTransactionsEntry); + updateLabels(walletTransactionsEntry); + } + } + + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + WalletTransactionsEntry walletTransactionsEntry = getWalletForm().getWalletTransactionsEntry(); + walletTransactionsEntry.updateTransactions(); + updateHistory(walletTransactionsEntry); + updateLabels(walletTransactionsEntry); + } + } + + @Subscribe + public void unitFormatChanged(UnitFormatChangedEvent event) { + updateHistory(getWalletForm().getWalletTransactionsEntry()); + updateLabels(getWalletForm().getWalletTransactionsEntry()); + } + + @Subscribe + public void fiatCurrencySelected(FiatCurrencySelectedEvent event) { + if(event.getExchangeSource() == ExchangeSource.NONE) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + fiatBalance.setText(""); + fiatMempoolBalance.setText(""); + }); + } + } + + @Subscribe + public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + WalletTransactionsEntry walletTransactionsEntry = getWalletForm().getWalletTransactionsEntry(); + fiatBalance.setText(formatFiatValue(getFiatValue(walletTransactionsEntry.getBalance(), event.getBtcRate()))); + fiatMempoolBalance.setText(formatFiatValue(getFiatValue(walletTransactionsEntry.getMempoolBalance(), event.getBtcRate()))); + }); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java new file mode 100644 index 00000000..4495859d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/UtxosDialog.java @@ -0,0 +1,410 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.google.common.eventbus.Subscribe; +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.table.Table; +import com.googlecode.lanterna.gui2.table.TableModel; +import com.samourai.whirlpool.client.wallet.beans.MixProgress; +import com.sparrowwallet.drongo.wallet.MixConfig; +import com.sparrowwallet.drongo.wallet.StandardAccount; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.net.ExchangeSource; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.terminal.wallet.table.*; +import com.sparrowwallet.sparrow.wallet.*; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.WeakChangeListener; + +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; + +public class UtxosDialog extends WalletDialog { + private final Label balance; + private final Label fiatBalance; + private final Label mempoolBalance; + private final Label fiatMempoolBalance; + private final Label utxoCount; + private final Table utxos; + + private Button startMix; + private Button mixTo; + + private final ChangeListener mixingOnlineListener = (observable, oldValue, newValue) -> { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> startMix.setEnabled(newValue)); + }; + + private final ChangeListener mixingStartingListener = (observable, oldValue, newValue) -> { + try { + SparrowTerminal.get().getGui().getGUIThread().invokeAndWait(() -> { + startMix.setEnabled(!newValue && AppServices.onlineProperty().get()); + startMix.setLabel(newValue && AppServices.onlineProperty().get() ? "Starting Mixing..." : isMixing() ? "Stop Mixing" : "Start Mixing"); + mixTo.setEnabled(!newValue); + }); + } catch(InterruptedException e) { + //ignore + } + }; + + private final ChangeListener mixingStoppingListener = (observable, oldValue, newValue) -> { + try { + SparrowTerminal.get().getGui().getGUIThread().invokeAndWait(() -> { + startMix.setEnabled(!newValue && AppServices.onlineProperty().get()); + startMix.setLabel(newValue ? "Stopping Mixing..." : isMixing() ? "Stop Mixing" : "Start Mixing"); + mixTo.setEnabled(!newValue); + }); + } catch(InterruptedException e) { + //ignore + } + }; + + private final ChangeListener mixingListener = (observable, oldValue, newValue) -> { + if(!newValue) { + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + for(Entry entry : walletUtxosEntry.getChildren()) { + UtxoEntry utxoEntry = (UtxoEntry)entry; + if(utxoEntry.getMixStatus() != null && utxoEntry.getMixStatus().getMixProgress() != null + && utxoEntry.getMixStatus().getMixProgress().getMixStep() != null + && utxoEntry.getMixStatus().getMixProgress().getMixStep().isInterruptable()) { + whirlpoolMix(new WhirlpoolMixEvent(getWalletForm().getWallet(), utxoEntry.getHashIndex(), (MixProgress)null)); + } + } + } + }; + + public UtxosDialog(WalletForm walletForm) { + super(walletForm.getWallet().getFullDisplayName() + " UTXOs", walletForm); + + setHints(List.of(Hint.CENTERED, Hint.EXPANDED)); + Panel labelPanel = new Panel(new GridLayout(3).setHorizontalSpacing(5).setVerticalSpacing(0)); + + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + + labelPanel.addComponent(new Label("Balance")); + balance = new Label("").addTo(labelPanel); + fiatBalance = new Label("").addTo(labelPanel); + + labelPanel.addComponent(new Label("Mempool")); + mempoolBalance = new Label("").addTo(labelPanel); + fiatMempoolBalance = new Label("").addTo(labelPanel); + + labelPanel.addComponent(new Label("UTXOs")); + utxoCount = new Label("").addTo(labelPanel); + labelPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + utxos = new Table<>(getTableColumns()); + utxos.setTableCellRenderer(new EntryTableCellRenderer()); + + updateLabels(walletUtxosEntry); + updateHistory(getWalletForm().getWalletUtxosEntry()); + + Panel buttonPanel = new Panel(new GridLayout(4).setHorizontalSpacing(2).setVerticalSpacing(0)); + if(getWalletForm().getWallet().isWhirlpoolMixWallet()) { + startMix = new Button("Start Mixing", this::toggleMixing).setSize(new TerminalSize(20, 1)).addTo(buttonPanel); + startMix.setEnabled(AppServices.onlineProperty().get()); + + mixTo = new Button("Mix to...", this::showMixToDialog).addTo(buttonPanel); + if(getWalletForm().getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX) { + buttonPanel.addComponent(mixTo); + } else { + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + } + + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool != null) { + startMix.setLabel(whirlpool.isMixing() ? "Stop Mixing" : "Start Mixing"); + if(whirlpool.startingProperty().getValue()) { + mixingStartingListener.changed(whirlpool.startingProperty(), null, whirlpool.startingProperty().getValue()); + } + whirlpool.startingProperty().addListener(new WeakChangeListener<>(mixingStartingListener)); + if(whirlpool.stoppingProperty().getValue()) { + mixingStoppingListener.changed(whirlpool.stoppingProperty(), null, whirlpool.stoppingProperty().getValue()); + } + whirlpool.stoppingProperty().addListener(new WeakChangeListener<>(mixingStoppingListener)); + whirlpool.mixingProperty().addListener(new WeakChangeListener<>(mixingListener)); + updateMixToButton(); + } + + AppServices.onlineProperty().addListener(new WeakChangeListener<>(mixingOnlineListener)); + + buttonPanel.addComponent(new Button("Back", () -> onBack(Function.UTXOS))); + buttonPanel.addComponent(new Button("Refresh", this::onRefresh)); + } else { + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + buttonPanel.addComponent(new EmptySpace(new TerminalSize(15, 1))); + buttonPanel.addComponent(new Button("Back", () -> onBack(Function.UTXOS))); + buttonPanel.addComponent(new Button("Refresh", this::onRefresh)); + } + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new LinearLayout(Direction.VERTICAL).setSpacing(1)); + mainPanel.addComponent(labelPanel); + mainPanel.addComponent(utxos); + mainPanel.addComponent(buttonPanel); + + setComponent(mainPanel); + } + + private String[] getTableColumns() { + if(getWalletForm().getWallet().isWhirlpoolMixWallet()) { + return new String[] {centerPad("Date", DateTableCell.UTXO_WIDTH), centerPad("Output", OutputTableCell.WIDTH), + centerPad("Mixes", MixTableCell.WIDTH), centerPad("Value", CoinTableCell.UTXO_WIDTH)}; + } + + return new String[] {centerPad("Date", DateTableCell.UTXO_WIDTH), centerPad("Output", OutputTableCell.WIDTH), + centerPad("Address", AddressTableCell.UTXO_WIDTH), centerPad("Value", CoinTableCell.UTXO_WIDTH)}; + } + + private void updateHistory(WalletUtxosEntry walletUtxosEntry) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + TableModel tableModel = getTableModel(walletUtxosEntry); + utxos.setTableModel(tableModel); + }); + } + + private TableModel getTableModel(WalletUtxosEntry walletUtxosEntry) { + TableModel tableModel = new TableModel<>(getTableColumns()); + List utxoList = new ArrayList<>(walletUtxosEntry.getChildren()); + utxoList.sort((o1, o2) -> Long.compare(o2.getValue(), o1.getValue())); + + for(Entry entry : utxoList) { + if(walletUtxosEntry.getWallet().isWhirlpoolMixWallet()) { + tableModel.addRow(new DateTableCell(entry), new OutputTableCell(entry), new MixTableCell(entry), new CoinTableCell(entry, false)); + } else { + tableModel.addRow(new DateTableCell(entry), new OutputTableCell(entry), new AddressTableCell(entry), new CoinTableCell(entry, false)); + } + } + + return tableModel; + } + + private void updateLabels(WalletUtxosEntry walletUtxosEntry) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + balance.setText(formatBitcoinValue(walletUtxosEntry.getBalance(), true)); + mempoolBalance.setText(formatBitcoinValue(walletUtxosEntry.getMempoolBalance(), true)); + + if(AppServices.getFiatCurrencyExchangeRate() != null) { + fiatBalance.setText(formatFiatValue(getFiatValue(walletUtxosEntry.getBalance(), AppServices.getFiatCurrencyExchangeRate().getBtcRate()))); + fiatMempoolBalance.setText(formatFiatValue(getFiatValue(walletUtxosEntry.getMempoolBalance(), AppServices.getFiatCurrencyExchangeRate().getBtcRate()))); + } else { + fiatBalance.setText(""); + fiatMempoolBalance.setText(""); + } + + setUtxoCount(walletUtxosEntry); + }); + } + + private void setUtxoCount(WalletUtxosEntry walletUtxosEntry) { + utxoCount.setText(walletUtxosEntry.getChildren() != null ? Integer.toString(walletUtxosEntry.getChildren().size()) : "0"); + } + + private boolean isMixing() { + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet()); + return whirlpool != null && whirlpool.isMixing(); + } + + public void toggleMixing() { + if(isMixing()) { + stopMixing(); + } else { + startMixing(); + } + } + + public void startMixing() { + startMix.setEnabled(false); + + Platform.runLater(() -> { + getWalletForm().getWallet().getMasterMixConfig().setMixOnStartup(Boolean.TRUE); + EventManager.get().post(new WalletMasterMixConfigChangedEvent(getWalletForm().getWallet())); + + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool != null && !whirlpool.isStarted() && AppServices.isConnected()) { + AppServices.getWhirlpoolServices().startWhirlpool(getWalletForm().getWallet(), whirlpool, true); + } + }); + } + + public void stopMixing() { + startMix.setEnabled(AppServices.onlineProperty().get()); + + Platform.runLater(() -> { + getWalletForm().getWallet().getMasterMixConfig().setMixOnStartup(Boolean.FALSE); + EventManager.get().post(new WalletMasterMixConfigChangedEvent(getWalletForm().getWallet())); + + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool.isStarted()) { + AppServices.getWhirlpoolServices().stopWhirlpool(whirlpool, true); + } else { + //Ensure http clients are shutdown + whirlpool.shutdown(); + } + }); + } + + public void showMixToDialog() { + MixToDialog mixToDialog = new MixToDialog(getWalletForm()); + MixConfig changedMixConfig = (MixConfig)mixToDialog.showDialog(SparrowTerminal.get().getGui()); + + if(changedMixConfig != null) { + MixConfig mixConfig = getWalletForm().getWallet().getMasterMixConfig(); + + mixConfig.setMixToWalletName(changedMixConfig.getMixToWalletName()); + mixConfig.setMixToWalletFile(changedMixConfig.getMixToWalletFile()); + mixConfig.setMinMixes(changedMixConfig.getMinMixes()); + mixConfig.setIndexRange(changedMixConfig.getIndexRange()); + + Platform.runLater(() -> { + EventManager.get().post(new WalletMasterMixConfigChangedEvent(getWalletForm().getWallet())); + + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet()); + whirlpool.setPostmixIndexRange(mixConfig.getIndexRange()); + try { + String mixToWalletId = AppServices.getWhirlpoolServices().getWhirlpoolMixToWalletId(mixConfig); + whirlpool.setMixToWallet(mixToWalletId, mixConfig.getMinMixes()); + } catch(NoSuchElementException e) { + mixConfig.setMixToWalletName(null); + mixConfig.setMixToWalletFile(null); + EventManager.get().post(new WalletMasterMixConfigChangedEvent(getWalletForm().getWallet())); + whirlpool.setMixToWallet(null, null); + } + + SparrowTerminal.get().getGui().getGUIThread().invokeLater(this::updateMixToButton); + if(whirlpool.isStarted()) { + //Will automatically restart + AppServices.getWhirlpoolServices().stopWhirlpool(whirlpool, false); + } + }); + } + } + + private void updateMixToButton() { + if(mixTo == null) { + return; + } + + MixConfig mixConfig = getWalletForm().getWallet().getMasterMixConfig(); + if(mixConfig != null && mixConfig.getMixToWalletName() != null) { + mixTo.setLabel("Mixing to " + mixConfig.getMixToWalletName()); + try { + String mixToWalletId = AppServices.getWhirlpoolServices().getWhirlpoolMixToWalletId(mixConfig); + String mixToName = AppServices.get().getWallet(mixToWalletId).getFullDisplayName(); + mixTo.setLabel("Mixing to " + mixToName); + } catch(NoSuchElementException e) { + mixTo.setLabel("! Not Open"); + } + } else { + mixTo.setLabel("Mix to..."); + } + } + + @Subscribe + public void walletNodesChanged(WalletNodesChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + updateHistory(walletUtxosEntry); + updateLabels(walletUtxosEntry); + } + } + + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + walletUtxosEntry.updateUtxos(); + updateHistory(walletUtxosEntry); + updateLabels(walletUtxosEntry); + } + } + + @Subscribe + public void whirlpoolMix(WhirlpoolMixEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + for(Entry entry : walletUtxosEntry.getChildren()) { + UtxoEntry utxoEntry = (UtxoEntry)entry; + if(utxoEntry.getHashIndex().equals(event.getUtxo())) { + if(event.getNextUtxo() != null) { + utxoEntry.setNextMixUtxo(event.getNextUtxo()); + } else if(event.getMixFailReason() != null) { + utxoEntry.setMixFailReason(event.getMixFailReason(), event.getMixError()); + } else { + utxoEntry.setMixProgress(event.getMixProgress()); + } + + TableModel tableModel = utxos.getTableModel(); + for(int row = 0; row < tableModel.getRowCount(); row++) { + UtxoEntry tableEntry = (UtxoEntry)tableModel.getRow(row).get(0).getEntry(); + if(tableEntry.getHashIndex().equals(event.getUtxo())) { + final int utxoRow = row; + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + tableModel.setCell(2, utxoRow, new MixTableCell(utxoEntry)); + }); + } + } + } + } + } + } + + @Subscribe + public void newBlock(NewBlockEvent event) { + Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool != null) { + for(Entry entry : getWalletForm().getWalletUtxosEntry().getChildren()) { + UtxoEntry utxoEntry = (UtxoEntry)entry; + MixProgress mixProgress = whirlpool.getMixProgress(utxoEntry.getHashIndex()); + if(mixProgress != null || utxoEntry.getMixStatus() == null || (utxoEntry.getMixStatus().getMixFailReason() == null && utxoEntry.getMixStatus().getNextMixUtxo() == null)) { + whirlpoolMix(new WhirlpoolMixEvent(getWalletForm().getWallet(), utxoEntry.getHashIndex(), mixProgress)); + } + } + } + } + + @Subscribe + public void openWallets(OpenWalletsEvent event) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(this::updateMixToButton); + } + + @Subscribe + public void walletLabelChanged(WalletLabelChangedEvent event) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(this::updateMixToButton); + } + + @Subscribe + public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) { + updateHistory(getWalletForm().getWalletUtxosEntry()); + updateLabels(getWalletForm().getWalletUtxosEntry()); + } + + @Subscribe + public void unitFormatChanged(UnitFormatChangedEvent event) { + updateHistory(getWalletForm().getWalletUtxosEntry()); + updateLabels(getWalletForm().getWalletUtxosEntry()); + } + + @Subscribe + public void fiatCurrencySelected(FiatCurrencySelectedEvent event) { + if(event.getExchangeSource() == ExchangeSource.NONE) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + fiatBalance.setText(""); + fiatMempoolBalance.setText(""); + }); + } + } + + @Subscribe + public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) { + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + fiatBalance.setText(formatFiatValue(getFiatValue(walletUtxosEntry.getBalance(), event.getBtcRate()))); + fiatMempoolBalance.setText(formatFiatValue(getFiatValue(walletUtxosEntry.getMempoolBalance(), event.getBtcRate()))); + }); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletAccountsDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletAccountsDialog.java new file mode 100644 index 00000000..928e4b30 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletAccountsDialog.java @@ -0,0 +1,55 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; + +import java.util.List; + +public class WalletAccountsDialog extends DialogWindow { + private final Wallet masterWallet; + private final ActionListBox actions; + + public WalletAccountsDialog(Wallet masterWallet) { + super(masterWallet.getFullDisplayName()); + + setHints(List.of(Hint.CENTERED)); + + this.masterWallet = masterWallet; + + actions = new ActionListBox(); + + for(Wallet wallet : masterWallet.getAllWallets()) { + actions.addItem(wallet.getDisplayName(), () -> { + close(); + SparrowTerminal.get().getGui().getGUIThread().invokeLater(() -> { + WalletActionsDialog walletActionsDialog = new WalletActionsDialog(wallet); + walletActionsDialog.showDialog(SparrowTerminal.get().getGui()); + }); + }); + } + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(1).setLeftMarginSize(1).setRightMarginSize(1)); + actions.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.FILL, GridLayout.Alignment.CENTER, true, false)).addTo(mainPanel); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + buttonPanel.addComponent(new Button("Cancel", this::onCancel).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, false, false)).addTo(mainPanel); + + setComponent(mainPanel); + } + + private void onCancel() { + close(); + } + + public void setWalletAccount(Wallet wallet) { + Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + actions.setSelectedIndex(masterWallet.getAllWallets().indexOf(wallet)); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletActionsDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletActionsDialog.java new file mode 100644 index 00000000..d8c30bd9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletActionsDialog.java @@ -0,0 +1,96 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.wallet.Function; + +import java.util.List; + +public class WalletActionsDialog extends DialogWindow { + private final Wallet wallet; + private final ActionListBox actions; + + public WalletActionsDialog(Wallet wallet) { + super(wallet.getFullDisplayName()); + + setHints(List.of(Hint.CENTERED)); + + this.wallet = wallet; + + actions = new ActionListBox(); + actions.addItem("Transactions", () -> { + close(); + TransactionsDialog transactionsDialog = getWalletData().getTransactionsDialog(); + transactionsDialog.showDialog(SparrowTerminal.get().getGui()); + }); + if(!getWalletData().getWalletForm().getWallet().isWhirlpoolChildWallet()) { + actions.addItem("Receive", () -> { + close(); + ReceiveDialog receiveDialog = getWalletData().getReceiveDialog(); + receiveDialog.showDialog(SparrowTerminal.get().getGui()); + }); + } + actions.addItem("Addresses", () -> { + close(); + AddressesDialog addressesDialog = getWalletData().getAddressesDialog(); + addressesDialog.showDialog(SparrowTerminal.get().getGui()); + }); + actions.addItem("UTXOs", () -> { + close(); + UtxosDialog utxosDialog = getWalletData().getUtxosDialog(); + utxosDialog.showDialog(SparrowTerminal.get().getGui()); + }); + + Panel mainPanel = new Panel(); + mainPanel.setLayoutManager(new GridLayout(1).setLeftMarginSize(1).setRightMarginSize(1)); + actions.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.FILL, GridLayout.Alignment.CENTER, true, false)).addTo(mainPanel); + mainPanel.addComponent(new EmptySpace(TerminalSize.ONE)); + + Panel buttonPanel = new Panel(); + buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1)); + Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + if(masterWallet.getChildWallets().stream().anyMatch(childWallet -> !childWallet.isNested())) { + buttonPanel.addComponent(new Button("Accounts", this::onAccounts).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + } + buttonPanel.addComponent(new Button("Cancel", this::onCancel).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false))); + buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, false, false)).addTo(mainPanel); + + setComponent(mainPanel); + } + + public void setFunction(Function function) { + int isWhirlpoolWallet = getWalletData().getWalletForm().getWallet().isWhirlpoolChildWallet() ? 1 : 0; + if(function == Function.TRANSACTIONS) { + actions.setSelectedIndex(0); + } else if(function == Function.RECEIVE) { + actions.setSelectedIndex(1); + } else if(function == Function.ADDRESSES) { + actions.setSelectedIndex(2 - isWhirlpoolWallet); + } else if(function == Function.UTXOS) { + actions.setSelectedIndex(3 - isWhirlpoolWallet); + } + } + + private void onCancel() { + close(); + } + + private void onAccounts() { + close(); + WalletAccountsDialog walletAccountsDialog = new WalletAccountsDialog(wallet.isMasterWallet() ? wallet : wallet.getMasterWallet()); + walletAccountsDialog.setWalletAccount(wallet); + walletAccountsDialog.showDialog(SparrowTerminal.get().getGui()); + } + + private WalletData getWalletData() { + WalletData walletData = SparrowTerminal.get().getWalletData().get(wallet); + if(walletData == null) { + throw new IllegalStateException("Wallet data is null for " + wallet.getFullDisplayName()); + } + + return walletData; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java new file mode 100644 index 00000000..1145b857 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletData.java @@ -0,0 +1,56 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.wallet.WalletForm; + +public class WalletData { + private final WalletForm walletForm; + private TransactionsDialog transactionsDialog; + private ReceiveDialog receiveDialog; + private AddressesDialog addressesDialog; + private UtxosDialog utxosDialog; + + public WalletData(WalletForm walletForm) { + this.walletForm = walletForm; + } + + public WalletForm getWalletForm() { + return walletForm; + } + + public TransactionsDialog getTransactionsDialog() { + if(transactionsDialog == null) { + transactionsDialog = new TransactionsDialog(walletForm); + EventManager.get().register(transactionsDialog); + } + + return transactionsDialog; + } + + public ReceiveDialog getReceiveDialog() { + if(receiveDialog == null) { + receiveDialog = new ReceiveDialog(walletForm); + EventManager.get().register(receiveDialog); + } + + return receiveDialog; + } + + public AddressesDialog getAddressesDialog() { + if(addressesDialog == null) { + addressesDialog = new AddressesDialog(walletForm); + EventManager.get().register(addressesDialog); + } + + return addressesDialog; + } + + public UtxosDialog getUtxosDialog() { + if(utxosDialog == null) { + utxosDialog = new UtxosDialog(walletForm); + EventManager.get().register(utxosDialog); + } + + return utxosDialog; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java new file mode 100644 index 00000000..8c9ecb93 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/WalletDialog.java @@ -0,0 +1,99 @@ +package com.sparrowwallet.sparrow.terminal.wallet; + +import com.google.common.base.Strings; +import com.googlecode.lanterna.gui2.dialogs.DialogWindow; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.CurrencyRate; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.UnitFormat; +import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.terminal.SparrowTerminal; +import com.sparrowwallet.sparrow.wallet.Function; +import com.sparrowwallet.sparrow.wallet.WalletForm; +import javafx.application.Platform; + +import java.util.Currency; + +public class WalletDialog extends DialogWindow { + private final WalletForm walletForm; + + public WalletDialog(String title, WalletForm walletForm) { + super(title); + this.walletForm = walletForm; + } + + public WalletForm getWalletForm() { + return walletForm; + } + + protected void onBack(Function function) { + close(); + WalletActionsDialog walletActionsDialog = new WalletActionsDialog(getWalletForm().getWallet()); + walletActionsDialog.setFunction(function); + walletActionsDialog.showDialog(SparrowTerminal.get().getGui()); + } + + protected void onRefresh() { + Wallet wallet = getWalletForm().getWallet(); + Wallet pastWallet = wallet.copy(); + wallet.clearHistory(); + AppServices.clearTransactionHistoryCache(wallet); + Platform.runLater(() -> EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, getWalletForm().getWalletId()))); + } + + @Override + public void close() { + if(getTextGUI() != null) { + getTextGUI().removeWindow(this); + } + } + + protected String formatBitcoinValue(long value, boolean appendUnit) { + BitcoinUnit unit = Config.get().getBitcoinUnit(); + if(unit == null || unit.equals(BitcoinUnit.AUTO)) { + unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS); + } + + UnitFormat format = Config.get().getUnitFormat(); + if(format == null) { + format = UnitFormat.DOT; + } + + return unit == BitcoinUnit.SATOSHIS ? format.formatSatsValue(value) + (appendUnit ? " sats" : "") : format.formatBtcValue(value) + (appendUnit ? " BTC" : ""); + } + + protected String formatFiatValue(Double value) { + UnitFormat format = Config.get().getUnitFormat(); + if(format == null) { + format = UnitFormat.DOT; + } + + CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate(); + if(currencyRate != null && currencyRate.isAvailable() && value > 0) { + Currency currency = currencyRate.getCurrency(); + return currency.getSymbol() + " " + format.formatCurrencyValue(value); + } else { + return ""; + } + } + + protected double getFiatValue(long satsValue, Double btcRate) { + return satsValue * btcRate / Transaction.SATOSHIS_PER_BITCOIN; + } + + protected static String centerPad(String text, int length) { + if(text.length() >= length) { + return text; + } + + int excess = length - text.length(); + int half = excess / 2; + int extra = excess % 2; + + return Strings.repeat(" ", half) + text + Strings.repeat(" ", half) + Strings.repeat(" ", extra); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/AddressTableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/AddressTableCell.java new file mode 100644 index 00000000..dbe6d63e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/AddressTableCell.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.sparrow.terminal.wallet.table; + +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.NodeEntry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; + +public class AddressTableCell extends TableCell { + public static final int UTXO_WIDTH = 18; + + public AddressTableCell(Entry entry) { + super(entry); + } + + @Override + public String formatCell() { + if(entry instanceof NodeEntry nodeEntry) { + return nodeEntry.getAddress().toString(); + } else if(entry instanceof UtxoEntry utxoEntry) { + return utxoEntry.getNode().getAddress().toString().substring(0, 10) + ".."; + } + + return ""; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/CoinTableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/CoinTableCell.java new file mode 100644 index 00000000..b175002f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/CoinTableCell.java @@ -0,0 +1,51 @@ +package com.sparrowwallet.sparrow.terminal.wallet.table; + +import com.google.common.base.Strings; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.sparrow.UnitFormat; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.TransactionEntry; + +public class CoinTableCell extends TableCell { + public static final int TRANSACTION_WIDTH = 20; + public static final int UTXO_WIDTH = 18; + + private final boolean balance; + + public CoinTableCell(Entry entry, boolean balance) { + super(entry); + this.balance = balance; + } + + @Override + public String formatCell() { + Long value = null; + if(balance && entry instanceof TransactionEntry transactionEntry) { + value = transactionEntry.getBalance(); + } else { + value = entry.getValue(); + } + + if(value == null) { + value = 0L; + } + + BitcoinUnit unit = Config.get().getBitcoinUnit(); + if(unit == null || unit.equals(BitcoinUnit.AUTO)) { + unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS); + } + + UnitFormat format = Config.get().getUnitFormat(); + if(format == null) { + format = UnitFormat.DOT; + } + + String formattedValue = unit == BitcoinUnit.SATOSHIS ? format.formatSatsValue(value) : format.formatBtcValue(value); + return Strings.padStart(formattedValue, getWidth(entry), ' '); + } + + private int getWidth(Entry entry) { + return entry instanceof TransactionEntry ? TRANSACTION_WIDTH : UTXO_WIDTH; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java new file mode 100644 index 00000000..325869ec --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/DateTableCell.java @@ -0,0 +1,41 @@ +package com.sparrowwallet.sparrow.terminal.wallet.table; + +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.TransactionEntry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; + +public class DateTableCell extends TableCell { + private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + public static final int TRANSACTION_WIDTH = 20; + public static final int UTXO_WIDTH = 18; + + public DateTableCell(Entry entry) { + super(entry); + } + + @Override + public String formatCell() { + if(entry instanceof TransactionEntry transactionEntry && transactionEntry.getBlockTransaction() != null) { + if(transactionEntry.getBlockTransaction().getHeight() == -1) { + return "Unconfirmed Parent"; + } else if(transactionEntry.getBlockTransaction().getHeight() == 0) { + return "Unconfirmed"; + } else { + return DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate()); + } + } else if(entry instanceof UtxoEntry utxoEntry && utxoEntry.getBlockTransaction() != null) { + if(utxoEntry.getBlockTransaction().getHeight() == -1) { + return "Unconfirmed Parent"; + } else if(utxoEntry.getBlockTransaction().getHeight() == 0) { + return "Unconfirmed"; + } else { + return DATE_FORMAT.format(utxoEntry.getBlockTransaction().getDate()); + } + } + + return ""; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/MixTableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/MixTableCell.java new file mode 100644 index 00000000..39f648fa --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/MixTableCell.java @@ -0,0 +1,64 @@ +package com.sparrowwallet.sparrow.terminal.wallet.table; + +import com.google.common.base.Strings; +import com.googlecode.lanterna.Symbols; +import com.samourai.whirlpool.client.mix.listener.MixFailReason; +import com.samourai.whirlpool.client.wallet.beans.MixProgress; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; + +public class MixTableCell extends TableCell { + public static final int WIDTH = 18; + + public MixTableCell(Entry entry) { + super(entry); + } + + @Override + public String formatCell() { + if(entry instanceof UtxoEntry utxoEntry) { + if(utxoEntry.getMixStatus() != null) { + UtxoEntry.MixStatus mixStatus = utxoEntry.getMixStatus(); + if(mixStatus.getNextMixUtxo() != null) { + return getMixSuccess(mixStatus); + } else if(mixStatus.getMixFailReason() != null) { + return getMixFail(mixStatus); + } else if(mixStatus.getMixProgress() != null) { + return getMixProgress(mixStatus, mixStatus.getMixProgress()); + } + } + + return getMixCountOnly(utxoEntry.getMixStatus()); + } + + return ""; + } + + private String getMixSuccess(UtxoEntry.MixStatus mixStatus) { + String msg = "Success!"; + String mixesDone = Strings.padStart(Integer.toString(mixStatus.getMixesDone()), WIDTH - msg.length(), ' '); + return msg + mixesDone; + } + + private String getMixFail(UtxoEntry.MixStatus mixStatus) { + if(mixStatus.getMixFailReason() == MixFailReason.CANCEL) { + return getMixCountOnly(mixStatus); + } + + String msg = mixStatus.getMixFailReason().getMessage(); + msg = msg.length() > 14 ? msg.substring(0, 14) : msg; + String mixesDone = Strings.padStart(Integer.toString(mixStatus.getMixesDone()), WIDTH - msg.length(), ' '); + return msg + mixesDone; + } + + private String getMixProgress(UtxoEntry.MixStatus mixStatus, MixProgress mixProgress) { + int progress = mixProgress.getMixStep().getProgressPercent(); + String progressBar = Strings.padEnd(Strings.repeat(Character.toString(Symbols.BLOCK_SOLID), progress / 10), 10, ' '); + String mixesDone = Strings.padStart(Integer.toString(mixStatus.getMixesDone()), WIDTH - 10, ' '); + return progressBar + mixesDone; + } + + private String getMixCountOnly(UtxoEntry.MixStatus mixStatus) { + return Strings.padStart(Integer.toString(mixStatus == null ? 0 : mixStatus.getMixesDone()), WIDTH, ' '); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/OutputTableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/OutputTableCell.java new file mode 100644 index 00000000..468ed5f3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/OutputTableCell.java @@ -0,0 +1,21 @@ +package com.sparrowwallet.sparrow.terminal.wallet.table; + +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; + +public class OutputTableCell extends TableCell { + public static final int WIDTH = 16; + + public OutputTableCell(Entry entry) { + super(entry); + } + + @Override + public String formatCell() { + if(entry instanceof UtxoEntry utxoEntry) { + return utxoEntry.getDescription(); + } + + return ""; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java new file mode 100644 index 00000000..6a9fce3a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/table/TableCell.java @@ -0,0 +1,17 @@ +package com.sparrowwallet.sparrow.terminal.wallet.table; + +import com.sparrowwallet.sparrow.wallet.Entry; + +public abstract class TableCell { + protected final Entry entry; + + public TableCell(Entry entry) { + this.entry = entry; + } + + public Entry getEntry() { + return entry; + } + + public abstract String formatCell(); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolServices.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolServices.java index ea22aad2..4ad9c6db 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolServices.java @@ -66,7 +66,7 @@ public class WhirlpoolServices { List windows = whirlpoolMap.keySet().stream().map(walletId -> AppServices.get().getWindowForWallet(walletId)).filter(Objects::nonNull).distinct().collect(Collectors.toList()); for(Window window : windows) { KeyCombination keyCombination = new KeyCodeCombination(KeyCode.W, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN, KeyCombination.ALT_DOWN); - if(!window.getScene().getAccelerators().containsKey(keyCombination)) { + if(window.getScene() != null && !window.getScene().getAccelerators().containsKey(keyCombination)) { window.getScene().getAccelerators().put(keyCombination, () -> { for(Whirlpool whirlpool : whirlpoolMap.values()) { whirlpool.logDebug(); diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index d24f4122..305ce10e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -45,4 +45,5 @@ open module com.sparrowwallet.sparrow { requires net.sourceforge.streamsupport; requires co.nstant.in.cbor; requires com.github.librepdf.openpdf; + requires com.googlecode.lanterna; } \ No newline at end of file