mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-30 19:46:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c7a0b757 | ||
|
|
55181165c8 | ||
|
|
b6f7483bf9 | ||
|
|
0593c764b6 | ||
|
|
11a3bfd893 | ||
|
|
f1d7fc656c | ||
|
|
ec918b78ed | ||
|
|
6f5ee7c695 | ||
|
|
e4264d0199 | ||
|
|
9feefe8203 | ||
|
|
93f9539f7b | ||
|
|
8865f4946a | ||
|
|
65e13d7b50 | ||
|
|
11189bc605 | ||
|
|
40faaec31e | ||
|
|
b5b384f5da | ||
|
|
23c802bb5f | ||
|
|
4567f62ef8 | ||
|
|
cd00535212 | ||
|
|
3ff626e2aa | ||
|
|
deee63c701 | ||
|
|
b465b9fdaf | ||
|
|
f377f97931 | ||
|
|
58bee33383 | ||
|
|
18075e0686 | ||
|
|
b836774289 | ||
|
|
f2f15208bf | ||
|
|
164742387f | ||
|
|
e62b44bbbe | ||
|
|
e9d0491b42 | ||
|
|
3fc2127337 | ||
|
|
702d92d4f2 | ||
|
|
a440c22455 | ||
|
|
1c2e635650 | ||
|
|
7b856f32a2 | ||
|
|
42b96e042b | ||
|
|
f8023e9a72 | ||
|
|
7b9b78684c | ||
|
|
10d6fb8b2b | ||
|
|
1556b8930c | ||
|
|
d52bade085 | ||
|
|
2c98c8606c | ||
|
|
a8430c79eb | ||
|
|
3fe5fd89c4 | ||
|
|
78406b3110 | ||
|
|
2dfdbd6d78 | ||
|
|
2e86840e92 | ||
|
|
9600d5707c | ||
|
|
497cf333b0 | ||
|
|
eb55b9420a | ||
|
|
3b3e46983f | ||
|
|
31fb527218 | ||
|
|
685fef6b76 | ||
|
|
9a4eb22396 | ||
|
|
57eb3a77e7 | ||
|
|
dcc63046bd | ||
|
|
8388a7fed5 | ||
|
|
d5830399b7 | ||
|
|
38b8fa3b86 | ||
|
|
6f26c7e138 | ||
|
|
9e869ae69b | ||
|
|
4a7b0b20ad | ||
|
|
8fc971c07c | ||
|
|
cb884d97cb | ||
|
|
942db9df48 | ||
|
|
193f88b88f | ||
|
|
6785bccf0e | ||
|
|
579b9a685b | ||
|
|
57b3fd99bc | ||
|
|
c3ae98f3d1 | ||
|
|
d635815607 | ||
|
|
e524396aaf | ||
|
|
08edc04c6d | ||
|
|
e88d6265b4 | ||
|
|
5d91f033c0 | ||
|
|
771bd1545c | ||
|
|
f65fec66bb | ||
|
|
9c6dbeec69 | ||
|
|
563af71ed2 | ||
|
|
a4579c953a | ||
|
|
6a3c3de67c | ||
|
|
f8fa929166 | ||
|
|
6aaf532051 | ||
|
|
fb59bdfff4 | ||
|
|
c8aa1b0a19 | ||
|
|
11a201b3f5 | ||
|
|
b74741bccb | ||
|
|
b8739ace4f | ||
|
|
cd674ee9be | ||
|
|
8b3ed8171b | ||
|
|
28ee16279c | ||
|
|
ff43db0842 | ||
|
|
8ddac9acba | ||
|
|
0c2554da72 | ||
|
|
1274d56fce | ||
|
|
8d9e253f93 | ||
|
|
9166160008 | ||
|
|
35eb8fcdf9 | ||
|
|
d34c8b8bde | ||
|
|
80a079cbee | ||
|
|
5d181d1abb | ||
|
|
6fbd332b4d | ||
|
|
211e5952aa | ||
|
|
4078c61d6b | ||
|
|
8b7d1e6888 | ||
|
|
7c6daf2e11 | ||
|
|
e1f405d886 | ||
|
|
1d73956853 | ||
|
|
205f78151a | ||
|
|
5853eb01f5 | ||
|
|
1a5e71f852 | ||
|
|
7053e7b225 | ||
|
|
5bee26a8cc | ||
|
|
b9caff352b | ||
|
|
73089ce99c | ||
|
|
34080ae8b3 | ||
|
|
307c7d53f8 | ||
|
|
a6d2a6563b | ||
|
|
577e570c99 | ||
|
|
06411d4e2f | ||
|
|
3e287bfb2d | ||
|
|
9872fc9221 | ||
|
|
84d2db427c | ||
|
|
cfbf9187e6 | ||
|
|
5478ac05b8 | ||
|
|
836bd6bc40 | ||
|
|
1bab07fc5f | ||
|
|
4b3de044c3 | ||
|
|
f2c2fc6071 | ||
|
|
4dd52eb9dd | ||
|
|
e45fdc72a0 | ||
|
|
d67dd515c4 | ||
|
|
ff962aa5f0 | ||
|
|
c0e44b2c55 | ||
|
|
f0c239d625 | ||
|
|
58a31f435e | ||
|
|
c49439d541 | ||
|
|
0070cbfb1e | ||
|
|
7ed75fc83d | ||
|
|
13d701b0a7 | ||
|
|
07ed9c857b | ||
|
|
32ab53ba03 | ||
|
|
68344e981c | ||
|
|
10c1574a4a | ||
|
|
3a853e2ab1 | ||
|
|
0f639dc2c2 | ||
|
|
a801773545 | ||
|
|
ffbf4bb0db | ||
|
|
11e46155a1 | ||
|
|
f369b3ad00 | ||
|
|
6068666415 | ||
|
|
6a58e8a799 | ||
|
|
3bd2f69157 |
@@ -32,8 +32,16 @@ jobs:
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew jpackage
|
||||
- name: Package zip distribution
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
run: ./gradlew packageZipDistribution
|
||||
- name: Package tar distribution
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: ./gradlew packageTarDistribution
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: Sparrow Build - ${{ runner.os }}
|
||||
path: build/jpackage/*
|
||||
path: |
|
||||
build/jpackage/*
|
||||
!build/jpackage/Sparrow/
|
||||
@@ -43,6 +43,9 @@ Usage: sparrow [options]
|
||||
Path to Sparrow home folder
|
||||
--help, -h
|
||||
Show usage
|
||||
--level, -l
|
||||
Set log level
|
||||
Possible Values: [ERROR, WARN, INFO, DEBUG, TRACE]
|
||||
--network, -n
|
||||
Network to use
|
||||
Possible Values: [mainnet, testnet, regtest, signet]
|
||||
@@ -54,7 +57,7 @@ As a fallback, the network (mainnet, testnet, regtest or signet) can also be set
|
||||
|
||||
A final fallback which can be useful when running the Sparrow binary is to create a file called ``network-testnet`` in the Sparrow home folder (see below) to configure the testnet network.
|
||||
|
||||
Note that if you are connecting to an Electrum server when using testnet, that server will need to running on testnet configuration as well.
|
||||
Note that if you are connecting to an Electrum server when using testnet, that server will need to be running on testnet configuration as well.
|
||||
|
||||
When not explicitly configured using the command line argument above, Sparrow stores its mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
|
||||
|
||||
|
||||
+29
-4
@@ -5,7 +5,7 @@ plugins {
|
||||
id 'org.beryx.jlink' version '2.22.0'
|
||||
}
|
||||
|
||||
def sparrowVersion = '1.2.0'
|
||||
def sparrowVersion = '1.4.0'
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
def osName = os.getFamilyName()
|
||||
if(os.macOsX) {
|
||||
@@ -20,7 +20,7 @@ repositories {
|
||||
maven { url 'https://oss.sonatype.org/content/groups/public' }
|
||||
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url 'http://maven.openimaj.org/' }
|
||||
maven { url 'https://maven.ecs.soton.ac.uk/content/groups/maven.openimaj.org/' }
|
||||
}
|
||||
|
||||
tasks.withType(AbstractArchiveTask) {
|
||||
@@ -51,7 +51,7 @@ dependencies {
|
||||
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
|
||||
exclude group: 'org.slf4j'
|
||||
}
|
||||
implementation('com.sparrowwallet:hummingbird:1.5.2')
|
||||
implementation('com.sparrowwallet:hummingbird:1.6.0')
|
||||
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
|
||||
exclude group: 'com.google.android.tools', module: 'dx'
|
||||
}
|
||||
@@ -71,6 +71,9 @@ dependencies {
|
||||
}
|
||||
implementation('dev.bwt:bwt-jni:0.1.7')
|
||||
implementation('net.sourceforge.javacsv:javacsv:2.0')
|
||||
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
|
||||
exclude group: 'org.slf4j'
|
||||
}
|
||||
testImplementation('junit:junit:4.12')
|
||||
}
|
||||
|
||||
@@ -112,7 +115,7 @@ run {
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow"]
|
||||
|
||||
if(os.macOsX) {
|
||||
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png",
|
||||
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
|
||||
}
|
||||
}
|
||||
@@ -178,3 +181,25 @@ jlink {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task removeGroupWritePermission(type: Exec) {
|
||||
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
|
||||
}
|
||||
|
||||
task packageZipDistribution(type: Zip) {
|
||||
archiveFileName = "Sparrow-${sparrowVersion}.zip"
|
||||
destinationDirectory = file("$buildDir/jpackage")
|
||||
from("$buildDir/jpackage/") {
|
||||
include "Sparrow/**"
|
||||
}
|
||||
}
|
||||
|
||||
task packageTarDistribution(type: Tar) {
|
||||
dependsOn removeGroupWritePermission
|
||||
archiveFileName = "sparrow-${sparrowVersion}.tar.gz"
|
||||
destinationDirectory = file("$buildDir/jpackage")
|
||||
compression = Compression.GZIP
|
||||
from("$buildDir/jpackage/") {
|
||||
include "Sparrow/**"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule drongo updated: 08acfe5ba1...db9617ee10
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.0</string>
|
||||
<string>1.4.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
||||
@@ -21,9 +21,11 @@ import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
|
||||
import com.sparrowwallet.sparrow.transaction.TransactionController;
|
||||
import com.sparrowwallet.sparrow.transaction.TransactionData;
|
||||
@@ -48,10 +50,15 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.*;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import javafx.stage.Window;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.control.Notifications;
|
||||
import org.controlsfx.control.StatusBar;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -60,7 +67,6 @@ import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.ParseException;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.*;
|
||||
@@ -69,16 +75,30 @@ public class AppController implements Initializable {
|
||||
private static final Logger log = LoggerFactory.getLogger(AppController.class);
|
||||
|
||||
public static final String DRAG_OVER_CLASS = "drag-over";
|
||||
public static final double TAB_LABEL_GRAPHIC_OPACITY_INACTIVE = 0.8;
|
||||
public static final double TAB_LABEL_GRAPHIC_OPACITY_ACTIVE = 0.95;
|
||||
public static final String LOADING_TRANSACTIONS_MESSAGE = "Loading wallet, select Transactions tab to view...";
|
||||
public static final String CONNECTION_FAILED_PREFIX = "Connection failed: ";
|
||||
public static final String TRYING_ANOTHER_SERVER_MESSAGE = "trying another server...";
|
||||
|
||||
@FXML
|
||||
private MenuItem saveTransaction;
|
||||
|
||||
@FXML
|
||||
private Menu savePSBT;
|
||||
|
||||
@FXML
|
||||
private MenuItem exportWallet;
|
||||
|
||||
@FXML
|
||||
private Menu fileMenu;
|
||||
|
||||
@FXML
|
||||
private Menu viewMenu;
|
||||
|
||||
@FXML
|
||||
private Menu toolsMenu;
|
||||
|
||||
@FXML
|
||||
private Menu helpMenu;
|
||||
|
||||
@@ -97,9 +117,15 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private CheckMenuItem hideEmptyUsedAddresses;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem useHdCameraResolution;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem showTxHex;
|
||||
|
||||
@FXML
|
||||
private MenuItem minimizeToTray;
|
||||
|
||||
@FXML
|
||||
private MenuItem refreshWallet;
|
||||
|
||||
@@ -119,13 +145,15 @@ public class AppController implements Initializable {
|
||||
|
||||
private Timeline statusTimeline;
|
||||
|
||||
private final Set<Wallet> loadingWallets = new LinkedHashSet<>();
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
}
|
||||
|
||||
void initializeView() {
|
||||
setOsxApplicationMenu();
|
||||
setPlatformApplicationMenu();
|
||||
|
||||
rootStack.setOnDragOver(event -> {
|
||||
if(event.getGestureSource() != rootStack && event.getDragboard().hasFiles()) {
|
||||
@@ -160,7 +188,11 @@ public class AppController implements Initializable {
|
||||
});
|
||||
|
||||
tabs.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedTab) -> {
|
||||
tabs.getTabs().forEach(tab -> ((Label)tab.getGraphic()).getGraphic().setOpacity(TAB_LABEL_GRAPHIC_OPACITY_INACTIVE));
|
||||
if(selectedTab != null) {
|
||||
Label tabLabel = (Label)selectedTab.getGraphic();
|
||||
tabLabel.getGraphic().setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
|
||||
|
||||
TabData tabData = (TabData)selectedTab.getUserData();
|
||||
if(tabData.getType() == TabData.TabType.TRANSACTION) {
|
||||
EventManager.get().post(new TransactionTabSelectedEvent(selectedTab));
|
||||
@@ -225,7 +257,9 @@ public class AppController implements Initializable {
|
||||
|
||||
openWalletsInNewWindows.setSelected(Config.get().isOpenWalletsInNewWindows());
|
||||
hideEmptyUsedAddresses.setSelected(Config.get().isHideEmptyUsedAddresses());
|
||||
useHdCameraResolution.setSelected(Config.get().isHdCapture());
|
||||
showTxHex.setSelected(Config.get().isShowTransactionHex());
|
||||
savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
|
||||
exportWallet.setDisable(true);
|
||||
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
|
||||
|
||||
@@ -243,8 +277,9 @@ public class AppController implements Initializable {
|
||||
openTransactionIdItem.disableProperty().bind(onlineProperty().not());
|
||||
}
|
||||
|
||||
private void setOsxApplicationMenu() {
|
||||
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX) {
|
||||
private void setPlatformApplicationMenu() {
|
||||
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
|
||||
if(platform == org.controlsfx.tools.Platform.OSX) {
|
||||
MenuToolkit tk = MenuToolkit.toolkit();
|
||||
MenuItem preferences = new MenuItem("Preferences...");
|
||||
preferences.setOnAction(this::openPreferences);
|
||||
@@ -256,8 +291,41 @@ public class AppController implements Initializable {
|
||||
tk.setApplicationMenu(defaultApplicationMenu);
|
||||
|
||||
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
toolsMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
helpMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
|
||||
toolsMenu.getItems().removeIf(item -> item.getStyleClass().contains("windowsHide"));
|
||||
}
|
||||
|
||||
if(platform == org.controlsfx.tools.Platform.UNIX || !TrayManager.isSupported()) {
|
||||
viewMenu.getItems().remove(minimizeToTray);
|
||||
}
|
||||
}
|
||||
|
||||
public void showIntroduction(ActionEvent event) {
|
||||
WelcomeDialog welcomeDialog = new WelcomeDialog();
|
||||
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
|
||||
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
|
||||
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
|
||||
preferencesDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
public void showDocumentation(ActionEvent event) throws IOException {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/docs");
|
||||
}
|
||||
|
||||
public void showLogFile(ActionEvent event) throws IOException {
|
||||
File logFile = new File(Storage.getSparrowHome(), "sparrow.log");
|
||||
if(logFile.exists()) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument(logFile.toPath().toUri().toString());
|
||||
} else {
|
||||
AppServices.showErrorDialog("Log file unavailable", "Cannot find log file at " + logFile.getCanonicalPath());
|
||||
}
|
||||
}
|
||||
|
||||
public void submitBugReport(ActionEvent event) throws IOException {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
|
||||
}
|
||||
|
||||
public void showAbout(ActionEvent event) {
|
||||
@@ -288,6 +356,24 @@ public class AppController implements Initializable {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void installUdevRules(ActionEvent event) {
|
||||
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
|
||||
enumerateService.setOnSucceeded(workerStateEvent -> {
|
||||
Platform.runLater(this::showInstallUdevMessage);
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
Platform.runLater(this::showInstallUdevMessage);
|
||||
});
|
||||
enumerateService.start();
|
||||
}
|
||||
|
||||
public void showInstallUdevMessage() {
|
||||
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules");
|
||||
dialog.setTitle("Install Udev Rules");
|
||||
dialog.getDialogPane().setHeaderText("Installing udev rules ensures devices can connect over USB.\nThis command requires root privileges.\nOpen a shell and enter the following:");
|
||||
dialog.showAndWait();
|
||||
}
|
||||
|
||||
public void openTransactionFromFile(ActionEvent event) {
|
||||
Stage window = new Stage();
|
||||
|
||||
@@ -299,6 +385,7 @@ public class AppController implements Initializable {
|
||||
new FileChooser.ExtensionFilter("TXN", "*.txn")
|
||||
);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if (file != null) {
|
||||
openTransactionFile(file);
|
||||
@@ -421,45 +508,28 @@ public class AppController implements Initializable {
|
||||
TransactionTabData transactionTabData = (TransactionTabData)tabData;
|
||||
Transaction transaction = transactionTabData.getTransaction();
|
||||
|
||||
//Save a transaction if the PSBT is null or transaction has already been extracted, otherwise save PSBT
|
||||
//The PSBT's transaction is not altered with transaction extraction, but the extracted transaction is stored in TransactionData
|
||||
boolean saveTx = (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transaction);
|
||||
|
||||
Stage window = new Stage();
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Save " + (saveTx ? "Transaction" : "PSBT"));
|
||||
fileChooser.setTitle("Save Transaction");
|
||||
|
||||
String fileName = selectedTab.getText();
|
||||
String fileName = ((Label)selectedTab.getGraphic()).getText();
|
||||
if(fileName != null && !fileName.isEmpty()) {
|
||||
if(transactionTabData.getPsbt() != null) {
|
||||
if(!fileName.endsWith(".psbt")) {
|
||||
fileName += ".psbt";
|
||||
}
|
||||
} else if(!fileName.endsWith(".txn")) {
|
||||
fileName += ".txn";
|
||||
}
|
||||
if(fileName.endsWith(".psbt")) {
|
||||
fileName = fileName.substring(0, fileName.length() - ".psbt".length());
|
||||
}
|
||||
|
||||
if(saveTx && fileName.endsWith(".psbt")) {
|
||||
fileName = fileName.replace(".psbt", "") + ".txn";
|
||||
}
|
||||
if(!fileName.endsWith(".txn")) {
|
||||
fileName += ".txn";
|
||||
}
|
||||
|
||||
fileChooser.setInitialFileName(fileName);
|
||||
fileChooser.setInitialFileName(fileName);
|
||||
}
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showSaveDialog(window);
|
||||
if(file != null) {
|
||||
if(!saveTx && !file.getName().toLowerCase().endsWith(".psbt")) {
|
||||
file = new File(file.getAbsolutePath() + ".psbt");
|
||||
}
|
||||
|
||||
try {
|
||||
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
|
||||
if(saveTx) {
|
||||
writer.print(Utils.bytesToHex(transaction.bitcoinSerialize()));
|
||||
} else {
|
||||
writer.print(transactionTabData.getPsbt().toBase64String());
|
||||
}
|
||||
}
|
||||
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
|
||||
writer.print(Utils.bytesToHex(transaction.bitcoinSerialize()));
|
||||
} catch(IOException e) {
|
||||
log.error("Error saving transaction", e);
|
||||
AppServices.showErrorDialog("Error saving transaction", "Cannot write to " + file.getAbsolutePath());
|
||||
@@ -468,6 +538,67 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
public void savePSBTBinary(ActionEvent event) {
|
||||
savePSBT(false, true);
|
||||
}
|
||||
|
||||
public void savePSBTText(ActionEvent event) {
|
||||
savePSBT(true, true);
|
||||
}
|
||||
|
||||
public void savePSBTBinaryNoXpubs(ActionEvent event) {
|
||||
savePSBT(false, false);
|
||||
}
|
||||
|
||||
public void savePSBTTextNoXpubs(ActionEvent event) {
|
||||
savePSBT(true, false);
|
||||
}
|
||||
|
||||
public void savePSBT(boolean asText, boolean includeXpubs) {
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
TabData tabData = (TabData)selectedTab.getUserData();
|
||||
if(tabData.getType() == TabData.TabType.TRANSACTION) {
|
||||
TransactionTabData transactionTabData = (TransactionTabData)tabData;
|
||||
|
||||
Stage window = new Stage();
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Save PSBT");
|
||||
|
||||
String fileName = ((Label)selectedTab.getGraphic()).getText();
|
||||
if(fileName != null && !fileName.isEmpty()) {
|
||||
if(!fileName.endsWith(".psbt")) {
|
||||
fileName += ".psbt";
|
||||
}
|
||||
|
||||
if(asText) {
|
||||
fileName += ".txt";
|
||||
}
|
||||
|
||||
fileChooser.setInitialFileName(fileName);
|
||||
}
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showSaveDialog(window);
|
||||
if(file != null) {
|
||||
if(!asText && !file.getName().toLowerCase().endsWith(".psbt")) {
|
||||
file = new File(file.getAbsolutePath() + ".psbt");
|
||||
}
|
||||
|
||||
try(FileOutputStream outputStream = new FileOutputStream(file)) {
|
||||
if(asText) {
|
||||
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
|
||||
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
|
||||
writer.flush();
|
||||
} else {
|
||||
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs));
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.error("Error saving PSBT", e);
|
||||
AppServices.showErrorDialog("Error saving PSBT", "Cannot write to " + file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<WalletTabData> getOpenWalletTabData() {
|
||||
List<WalletTabData> openWalletTabData = new ArrayList<>();
|
||||
@@ -528,6 +659,11 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new HideEmptyUsedAddressesStatusEvent(item.isSelected()));
|
||||
}
|
||||
|
||||
public void useHdCameraResolution(ActionEvent event) {
|
||||
CheckMenuItem item = (CheckMenuItem)event.getSource();
|
||||
Config.get().setHdCapture(item.isSelected());
|
||||
}
|
||||
|
||||
public void showTxHex(ActionEvent event) {
|
||||
CheckMenuItem item = (CheckMenuItem)event.getSource();
|
||||
Config.get().setShowTransactionHex(item.isSelected());
|
||||
@@ -569,7 +705,7 @@ public class AppController implements Initializable {
|
||||
File walletFile = Storage.getWalletFile(nameAndBirthDate.getName());
|
||||
Storage storage = new Storage(walletFile);
|
||||
Wallet wallet = new Wallet(nameAndBirthDate.getName(), PolicyType.SINGLE, ScriptType.P2WPKH, nameAndBirthDate.getBirthDate());
|
||||
addWalletTabOrWindow(storage, wallet, false);
|
||||
addWalletTabOrWindow(storage, wallet, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +719,7 @@ public class AppController implements Initializable {
|
||||
fileChooser.setTitle("Open Wallet");
|
||||
fileChooser.setInitialDirectory(Storage.getWalletsDir());
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if(file != null) {
|
||||
openWalletFile(file, forceSameWindow);
|
||||
@@ -594,13 +731,13 @@ public class AppController implements Initializable {
|
||||
Storage storage = new Storage(file);
|
||||
FileType fileType = IOUtils.getFileType(file);
|
||||
if(FileType.JSON.equals(fileType)) {
|
||||
Wallet wallet = storage.loadWallet();
|
||||
checkWalletNetwork(wallet);
|
||||
restorePublicKeysFromSeed(wallet, null);
|
||||
if(!wallet.isValid()) {
|
||||
Storage.WalletBackupAndKey walletBackupAndKey = storage.loadWallet();
|
||||
checkWalletNetwork(walletBackupAndKey.wallet);
|
||||
restorePublicKeysFromSeed(walletBackupAndKey.wallet, null);
|
||||
if(!walletBackupAndKey.wallet.isValid()) {
|
||||
throw new IllegalStateException("Wallet file is not valid.");
|
||||
}
|
||||
addWalletTabOrWindow(storage, wallet, forceSameWindow);
|
||||
addWalletTabOrWindow(storage, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
|
||||
} else if(FileType.BINARY.equals(fileType)) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
@@ -612,22 +749,25 @@ public class AppController implements Initializable {
|
||||
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage, password);
|
||||
loadWalletService.setOnSucceeded(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
|
||||
Storage.WalletAndKey walletAndKey = loadWalletService.getValue();
|
||||
Storage.WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
|
||||
try {
|
||||
checkWalletNetwork(walletAndKey.wallet);
|
||||
restorePublicKeysFromSeed(walletAndKey.wallet, walletAndKey.key);
|
||||
addWalletTabOrWindow(storage, walletAndKey.wallet, forceSameWindow);
|
||||
checkWalletNetwork(walletBackupAndKey.wallet);
|
||||
restorePublicKeysFromSeed(walletBackupAndKey.wallet, walletBackupAndKey.key);
|
||||
addWalletTabOrWindow(storage, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
|
||||
} catch(Exception e) {
|
||||
showErrorDialog("Error Opening Wallet", e.getMessage());
|
||||
} finally {
|
||||
walletAndKey.key.clear();
|
||||
walletBackupAndKey.key.clear();
|
||||
}
|
||||
});
|
||||
loadWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
|
||||
Throwable exception = loadWalletService.getException();
|
||||
if(exception instanceof InvalidPasswordException) {
|
||||
showErrorDialog("Invalid Password", "The wallet password was invalid.");
|
||||
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
|
||||
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
|
||||
Platform.runLater(() -> openWalletFile(file, forceSameWindow));
|
||||
}
|
||||
} else {
|
||||
if(!attemptImportWallet(file, password)) {
|
||||
log.error("Error Opening Wallet", exception);
|
||||
@@ -656,7 +796,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
private void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException {
|
||||
if(wallet.containsSeeds()) {
|
||||
if(wallet.containsPrivateKeys()) {
|
||||
//Derive xpub and master fingerprint from seed, potentially with passphrase
|
||||
Wallet copy = wallet.copy();
|
||||
for(Keystore copyKeystore : copy.getKeystores()) {
|
||||
@@ -692,6 +832,12 @@ public class AppController implements Initializable {
|
||||
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
|
||||
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
|
||||
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());
|
||||
copyKeystore.getMasterPrivateKey().clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -750,9 +896,11 @@ public class AppController implements Initializable {
|
||||
File walletFile = Storage.getExistingWallet(wallet.getName());
|
||||
if(walletFile != null) {
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
AppServices.setStageIcon(alert.getDialogPane().getScene().getWindow());
|
||||
alert.setTitle("Existing wallet found");
|
||||
alert.setHeaderText("Replace existing wallet?");
|
||||
alert.setContentText("Wallet file " + walletFile.getName() + " already exists.\n");
|
||||
AppServices.moveToActiveWindowScreen(alert);
|
||||
Optional<ButtonType> result = alert.showAndWait();
|
||||
if(result.isPresent() && result.get() == ButtonType.CANCEL) {
|
||||
return;
|
||||
@@ -774,12 +922,12 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
if(password.get().length() == 0) {
|
||||
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
|
||||
addWalletTabOrWindow(storage, wallet, false);
|
||||
addWalletTabOrWindow(storage, wallet, null, false);
|
||||
} else {
|
||||
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
|
||||
keyDerivationService.setOnSucceeded(workerStateEvent -> {
|
||||
@@ -792,7 +940,7 @@ public class AppController implements Initializable {
|
||||
key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
|
||||
wallet.encrypt(key);
|
||||
storage.setEncryptionPubKey(encryptionPubKey);
|
||||
addWalletTabOrWindow(storage, wallet, false);
|
||||
addWalletTabOrWindow(storage, wallet, null, false);
|
||||
} finally {
|
||||
encryptionFullKey.clear();
|
||||
if(key != null) {
|
||||
@@ -835,7 +983,7 @@ public class AppController implements Initializable {
|
||||
WalletTabData walletTabData = (WalletTabData)tab.getUserData();
|
||||
Wallet wallet = walletTabData.getWallet();
|
||||
if(wallet.getKeystores().size() == 1 &&
|
||||
(wallet.getKeystores().get(0).hasSeed() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
//Can sign and verify
|
||||
messageSignDialog = new MessageSignDialog(wallet);
|
||||
}
|
||||
@@ -849,6 +997,10 @@ public class AppController implements Initializable {
|
||||
messageSignDialog.showAndWait();
|
||||
}
|
||||
|
||||
public void minimizeToTray(ActionEvent event) {
|
||||
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
|
||||
}
|
||||
|
||||
public void refreshWallet(ActionEvent event) {
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
TabData tabData = (TabData)selectedTab.getUserData();
|
||||
@@ -856,12 +1008,14 @@ public class AppController implements Initializable {
|
||||
WalletTabData walletTabData = (WalletTabData) tabData;
|
||||
Wallet wallet = walletTabData.getWallet();
|
||||
Wallet pastWallet = wallet.copy();
|
||||
walletTabData.getStorage().backupTempWallet();
|
||||
wallet.clearHistory();
|
||||
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
|
||||
AppServices.clearTransactionHistoryCache(wallet);
|
||||
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
|
||||
}
|
||||
}
|
||||
|
||||
public void addWalletTabOrWindow(Storage storage, Wallet wallet, boolean forceSameWindow) {
|
||||
public void addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
|
||||
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage);
|
||||
if(existingWalletWindow instanceof Stage) {
|
||||
Stage existingWalletStage = (Stage)existingWalletWindow;
|
||||
@@ -876,13 +1030,13 @@ public class AppController implements Initializable {
|
||||
AppController appController = AppServices.newAppWindow(stage);
|
||||
stage.toFront();
|
||||
stage.setX(AppServices.get().getWalletWindowMaxX() + 30);
|
||||
appController.addWalletTab(storage, wallet);
|
||||
appController.addWalletTab(storage, wallet, backupWallet);
|
||||
} else {
|
||||
addWalletTab(storage, wallet);
|
||||
addWalletTab(storage, wallet, backupWallet);
|
||||
}
|
||||
}
|
||||
|
||||
public void addWalletTab(Storage storage, Wallet wallet) {
|
||||
public void addWalletTab(Storage storage, Wallet wallet, Wallet backupWallet) {
|
||||
try {
|
||||
String name = storage.getWalletFile().getName();
|
||||
if(name.endsWith(".json")) {
|
||||
@@ -891,7 +1045,14 @@ public class AppController implements Initializable {
|
||||
if(!name.equals(wallet.getName())) {
|
||||
wallet.setName(name);
|
||||
}
|
||||
Tab tab = new Tab(name);
|
||||
Tab tab = new Tab("");
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
|
||||
glyph.setFontSize(10.0);
|
||||
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
|
||||
Label tabLabel = new Label(name);
|
||||
tabLabel.setGraphic(glyph);
|
||||
tabLabel.setGraphicTextGap(5.0);
|
||||
tab.setGraphic(tabLabel);
|
||||
tab.setContextMenu(getTabContextMenu(tab));
|
||||
tab.setClosable(true);
|
||||
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
|
||||
@@ -901,7 +1062,7 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new WalletOpeningEvent(storage, wallet));
|
||||
|
||||
//Note that only one WalletForm is created per wallet tab, and registered to listen for events. All wallet controllers (except SettingsController) share this instance.
|
||||
WalletForm walletForm = new WalletForm(storage, wallet);
|
||||
WalletForm walletForm = new WalletForm(storage, wallet, backupWallet);
|
||||
EventManager.get().register(walletForm);
|
||||
controller.setWalletForm(walletForm);
|
||||
|
||||
@@ -993,7 +1154,7 @@ public class AppController implements Initializable {
|
||||
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
|
||||
transactionTabData.getPsbt().combine(psbt);
|
||||
if(name != null && !name.isEmpty()) {
|
||||
tab.setText(name);
|
||||
((Label)tab.getGraphic()).setText(name);
|
||||
}
|
||||
|
||||
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
|
||||
@@ -1008,7 +1169,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
if(name != null && !name.isEmpty()) {
|
||||
tab.setText(name);
|
||||
((Label)tab.getGraphic()).setText(name);
|
||||
}
|
||||
|
||||
EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
|
||||
@@ -1028,7 +1189,14 @@ public class AppController implements Initializable {
|
||||
tabName = "[" + transaction.getTxId().toString().substring(0, 6) + "]";
|
||||
}
|
||||
|
||||
Tab tab = new Tab(tabName);
|
||||
Tab tab = new Tab("");
|
||||
Glyph glyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
|
||||
glyph.setFontSize(10.0);
|
||||
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
|
||||
Label tabLabel = new Label(tabName);
|
||||
tabLabel.setGraphic(glyph);
|
||||
tabLabel.setGraphicTextGap(5.0);
|
||||
tab.setGraphic(tabLabel);
|
||||
tab.setContextMenu(getTabContextMenu(tab));
|
||||
tab.setClosable(true);
|
||||
FXMLLoader transactionLoader = new FXMLLoader(getClass().getResource("transaction/transaction.fxml"));
|
||||
@@ -1109,6 +1277,57 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new ThemeChangedEvent(selectedTheme));
|
||||
}
|
||||
|
||||
private void tabLabelStartAnimation(Wallet wallet) {
|
||||
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == wallet).forEach(this::tabLabelStartAnimation);
|
||||
}
|
||||
|
||||
private void tabLabelStartAnimation(Transaction transaction) {
|
||||
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof TransactionTabData && ((TransactionTabData)tab.getUserData()).getTransaction().getTxId().equals(transaction.getTxId())).forEach(this::tabLabelStartAnimation);
|
||||
}
|
||||
|
||||
private void tabLabelStartAnimation(Tab tab) {
|
||||
Label tabLabel = (Label) tab.getGraphic();
|
||||
if(tabLabel.getUserData() == null) {
|
||||
FadeTransition fadeTransition = new FadeTransition(Duration.millis(1000), tabLabel.getGraphic());
|
||||
fadeTransition.setFromValue(tabLabel.getGraphic().getOpacity());
|
||||
fadeTransition.setToValue(0.1);
|
||||
fadeTransition.setAutoReverse(true);
|
||||
fadeTransition.setCycleCount(Animation.INDEFINITE);
|
||||
fadeTransition.play();
|
||||
tabLabel.setUserData(fadeTransition);
|
||||
}
|
||||
}
|
||||
|
||||
private void tabLabelAddFailure(Tab tab) {
|
||||
Label tabLabel = (Label)tab.getGraphic();
|
||||
if(!tabLabel.getStyleClass().contains("failure")) {
|
||||
tabLabel.getGraphic().getStyleClass().add("failure");
|
||||
}
|
||||
}
|
||||
|
||||
private void tabLabelStopAnimation(Wallet wallet) {
|
||||
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == wallet).forEach(this::tabLabelStopAnimation);
|
||||
}
|
||||
|
||||
private void tabLabelStopAnimation(Transaction transaction) {
|
||||
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof TransactionTabData && ((TransactionTabData)tab.getUserData()).getTransaction().getTxId().equals(transaction.getTxId())).forEach(this::tabLabelStopAnimation);
|
||||
}
|
||||
|
||||
private void tabLabelStopAnimation(Tab tab) {
|
||||
Label tabLabel = (Label) tab.getGraphic();
|
||||
if(tabLabel.getUserData() != null) {
|
||||
FadeTransition fadeTransition = (FadeTransition)tabLabel.getUserData();
|
||||
fadeTransition.stop();
|
||||
tabLabel.setUserData(null);
|
||||
tabLabel.getGraphic().setOpacity(tab.isSelected() ? TAB_LABEL_GRAPHIC_OPACITY_ACTIVE : TAB_LABEL_GRAPHIC_OPACITY_INACTIVE);
|
||||
}
|
||||
}
|
||||
|
||||
private void tabLabelRemoveFailure(Tab tab) {
|
||||
Label tabLabel = (Label)tab.getGraphic();
|
||||
tabLabel.getGraphic().getStyleClass().remove("failure");
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void themeChanged(ThemeChangedEvent event) {
|
||||
String darkCss = getClass().getResource("darktheme.css").toExternalForm();
|
||||
@@ -1138,15 +1357,19 @@ public class AppController implements Initializable {
|
||||
if(event instanceof TransactionTabSelectedEvent) {
|
||||
TransactionTabSelectedEvent txTabEvent = (TransactionTabSelectedEvent)event;
|
||||
TransactionTabData transactionTabData = txTabEvent.getTransactionTabData();
|
||||
saveTransaction.setDisable(false);
|
||||
saveTransaction.setText("Save " + (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction() ? "Transaction..." : "PSBT..."));
|
||||
if(transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction()) {
|
||||
saveTransaction.setVisible(true);
|
||||
saveTransaction.setDisable(false);
|
||||
} else {
|
||||
saveTransaction.setVisible(false);
|
||||
}
|
||||
exportWallet.setDisable(true);
|
||||
showTxHex.setDisable(false);
|
||||
} else if(event instanceof WalletTabSelectedEvent) {
|
||||
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
|
||||
WalletTabData walletTabData = walletTabEvent.getWalletTabData();
|
||||
saveTransaction.setVisible(true);
|
||||
saveTransaction.setDisable(true);
|
||||
saveTransaction.setText("Save Transaction...");
|
||||
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid());
|
||||
showTxHex.setDisable(true);
|
||||
}
|
||||
@@ -1160,14 +1383,15 @@ public class AppController implements Initializable {
|
||||
if(tabData instanceof TransactionTabData) {
|
||||
TransactionTabData transactionTabData = (TransactionTabData)tabData;
|
||||
if(transactionTabData.getTransaction() == event.getFinalTransaction()) {
|
||||
saveTransaction.setText("Save Transaction...");
|
||||
saveTransaction.setVisible(true);
|
||||
saveTransaction.setDisable(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
|
||||
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
|
||||
Tab tab = tabs.getSelectionModel().getSelectedItem();
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
@@ -1253,6 +1477,7 @@ public class AppController implements Initializable {
|
||||
@Subscribe
|
||||
public void versionUpdated(VersionUpdatedEvent event) {
|
||||
Hyperlink versionUpdateLabel = new Hyperlink("Sparrow " + event.getVersion() + " available");
|
||||
versionUpdateLabel.getStyleClass().add("version-hyperlink");
|
||||
versionUpdateLabel.setOnAction(event1 -> {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://www.sparrowwallet.com/download");
|
||||
});
|
||||
@@ -1316,13 +1541,14 @@ public class AppController implements Initializable {
|
||||
|
||||
@Subscribe
|
||||
public void connectionStart(ConnectionStartEvent event) {
|
||||
statusUpdated(new StatusEvent(event.getStatus(), 120));
|
||||
if(!statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
|
||||
statusUpdated(new StatusEvent(event.getStatus(), 120));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void connectionFailed(ConnectionFailedEvent event) {
|
||||
String reason = event.getException().getCause() != null ? event.getException().getCause().getMessage() : event.getException().getMessage();
|
||||
String status = "Connection failed: " + reason;
|
||||
String status = CONNECTION_FAILED_PREFIX + event.getMessage();
|
||||
statusUpdated(new StatusEvent(status));
|
||||
}
|
||||
|
||||
@@ -1330,17 +1556,82 @@ public class AppController implements Initializable {
|
||||
public void connection(ConnectionEvent event) {
|
||||
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
|
||||
statusUpdated(new StatusEvent(status));
|
||||
setServerToggleTooltip(event.getBlockHeight());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void disconnection(DisconnectionEvent event) {
|
||||
serverToggle.setDisable(false);
|
||||
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith("Connection error")) {
|
||||
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith(CONNECTION_FAILED_PREFIX) && !statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
|
||||
statusUpdated(new StatusEvent("Disconnected"));
|
||||
}
|
||||
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
|
||||
statusBar.setProgress(0);
|
||||
}
|
||||
for(Wallet wallet : getOpenWallets().keySet()) {
|
||||
tabLabelStopAnimation(wallet);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletTabsClosed(WalletTabsClosedEvent event) {
|
||||
if(event.getClosedWalletTabData().stream().map(WalletTabData::getWallet).anyMatch(loadingWallets::remove) && loadingWallets.isEmpty()) {
|
||||
if(statusBar.getText().equals(LOADING_TRANSACTIONS_MESSAGE)) {
|
||||
statusBar.setText("");
|
||||
}
|
||||
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
|
||||
statusBar.setProgress(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void transactionReferences(TransactionReferencesEvent event) {
|
||||
if(AppServices.isConnected() && event instanceof TransactionReferencesStartedEvent) {
|
||||
tabLabelStartAnimation(event.getTransaction());
|
||||
} else {
|
||||
tabLabelStopAnimation(event.getTransaction());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryStarted(WalletHistoryStartedEvent event) {
|
||||
if(AppServices.isConnected() && getOpenWallets().containsKey(event.getWallet())) {
|
||||
if(event.getWalletNode() == null && event.getWallet().getTransactions().isEmpty()) {
|
||||
statusUpdated(new StatusEvent(LOADING_TRANSACTIONS_MESSAGE, 120));
|
||||
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
|
||||
statusBar.setProgress(-1);
|
||||
loadingWallets.add(event.getWallet());
|
||||
}
|
||||
}
|
||||
tabLabelStartAnimation(event.getWallet());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryFinished(WalletHistoryFinishedEvent event) {
|
||||
if(getOpenWallets().containsKey(event.getWallet())) {
|
||||
if(statusBar.getText().equals(LOADING_TRANSACTIONS_MESSAGE)) {
|
||||
statusBar.setText("");
|
||||
}
|
||||
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
|
||||
statusBar.setProgress(0);
|
||||
}
|
||||
tabLabelStopAnimation(event.getWallet());
|
||||
loadingWallets.remove(event.getWallet());
|
||||
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == event.getWallet()).forEach(this::tabLabelRemoveFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
|
||||
walletHistoryFinished(new WalletHistoryFinishedEvent(event.getWallet()));
|
||||
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData) tab.getUserData()).getWallet() == event.getWallet()).forEach(this::tabLabelAddFailure);
|
||||
if(getOpenWallets().containsKey(event.getWallet())) {
|
||||
if(AppServices.isConnected()) {
|
||||
statusUpdated(new StatusEvent("Error retrieving wallet history" + (Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? ", " + TRYING_ANOTHER_SERVER_MESSAGE : "")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
@@ -1402,6 +1693,12 @@ public class AppController implements Initializable {
|
||||
statusUpdated(new StatusEvent(event.getStatus()));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void torExternalStatus(TorExternalStatusEvent event) {
|
||||
serverToggle.setDisable(false);
|
||||
statusUpdated(new StatusEvent(event.getStatus()));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void newBlock(NewBlockEvent event) {
|
||||
setServerToggleTooltip(event.getHeight());
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.control.TrayManager;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
@@ -27,8 +29,11 @@ import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.DialogPane;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.Window;
|
||||
import javafx.util.Duration;
|
||||
@@ -63,6 +68,8 @@ public class AppServices {
|
||||
|
||||
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
|
||||
|
||||
private TrayManager trayManager;
|
||||
|
||||
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
|
||||
|
||||
private ExchangeSource.RatesService ratesService;
|
||||
@@ -93,7 +100,7 @@ public class AppServices {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean online) {
|
||||
if(online) {
|
||||
if(Config.get().requiresTor() && !isTorRunning()) {
|
||||
if(Config.get().requiresInternalTor() && !isTorRunning()) {
|
||||
torService.start();
|
||||
} else {
|
||||
restartServices();
|
||||
@@ -121,7 +128,7 @@ public class AppServices {
|
||||
onlineProperty.addListener(onlineServicesListener);
|
||||
|
||||
if(config.getMode() == Mode.ONLINE) {
|
||||
if(config.requiresTor()) {
|
||||
if(config.requiresInternalTor()) {
|
||||
torService.start();
|
||||
} else {
|
||||
restartServices();
|
||||
@@ -139,7 +146,7 @@ public class AppServices {
|
||||
restartService(ratesService);
|
||||
}
|
||||
|
||||
if(config.isCheckNewVersions()) {
|
||||
if(config.isCheckNewVersions() && Network.get() == Network.MAINNET) {
|
||||
restartService(versionCheckService);
|
||||
}
|
||||
}
|
||||
@@ -207,13 +214,43 @@ public class AppServices {
|
||||
connectionService.setRestartOnFailure(false);
|
||||
}
|
||||
|
||||
if(failEvent.getSource().getException() instanceof TlsServerException && failEvent.getSource().getException().getCause() != null) {
|
||||
TlsServerException tlsServerException = (TlsServerException)failEvent.getSource().getException();
|
||||
connectionService.setRestartOnFailure(false);
|
||||
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
|
||||
File crtFile = Config.get().getElectrumServerCert();
|
||||
if(crtFile != null) {
|
||||
AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." +
|
||||
"\n\nThis may indicate a man-in-the-middle attack!" +
|
||||
"\n\nChange the configured server certificate if you would like to proceed.");
|
||||
} else {
|
||||
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
|
||||
if(crtFile != null) {
|
||||
Optional<ButtonType> 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) {
|
||||
crtFile.delete();
|
||||
Platform.runLater(() -> restartService(connectionService));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().requiresTor()) {
|
||||
Config.get().setUseProxy(false);
|
||||
Platform.runLater(() -> restartService(torService));
|
||||
return;
|
||||
}
|
||||
|
||||
onlineProperty.removeListener(onlineServicesListener);
|
||||
onlineProperty.setValue(false);
|
||||
onlineProperty.addListener(onlineServicesListener);
|
||||
|
||||
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
|
||||
List<String> otherServers = Arrays.stream(PublicElectrumServer.values()).map(PublicElectrumServer::getUrl).filter(url -> !url.equals(Config.get().getPublicElectrumServer())).collect(Collectors.toList());
|
||||
Config.get().setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
|
||||
Config.get().changePublicServer();
|
||||
connectionService.setPeriod(Duration.seconds(PUBLIC_SERVER_RETRY_PERIOD_SECS));
|
||||
}
|
||||
|
||||
@@ -288,6 +325,23 @@ public class AppServices {
|
||||
EventManager.get().post(new TorReadyStatusEvent());
|
||||
});
|
||||
torService.setOnFailed(workerStateEvent -> {
|
||||
Throwable exception = workerStateEvent.getSource().getException();
|
||||
if(exception instanceof TorServerAlreadyBoundException) {
|
||||
String proxyServer = Config.get().getProxyServer();
|
||||
if(proxyServer == null || proxyServer.equals("")) {
|
||||
proxyServer = "localhost:9050";
|
||||
Config.get().setProxyServer(proxyServer);
|
||||
}
|
||||
|
||||
if(proxyServer.equals("localhost:9050") || proxyServer.equals("127.0.0.1:9050")) {
|
||||
Config.get().setUseProxy(true);
|
||||
torService.cancel();
|
||||
restartServices();
|
||||
EventManager.get().post(new TorExternalStatusEvent());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
|
||||
});
|
||||
|
||||
@@ -333,7 +387,7 @@ public class AppServices {
|
||||
stage.setMinWidth(650);
|
||||
stage.setMinHeight(730);
|
||||
stage.setScene(scene);
|
||||
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow.png")));
|
||||
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow-large.png")));
|
||||
|
||||
appController.initializeView();
|
||||
stage.show();
|
||||
@@ -352,6 +406,15 @@ public class AppServices {
|
||||
return application;
|
||||
}
|
||||
|
||||
public void minimizeStage(Stage stage) {
|
||||
if(trayManager == null) {
|
||||
trayManager = new TrayManager();
|
||||
}
|
||||
|
||||
trayManager.addStage(stage);
|
||||
stage.hide();
|
||||
}
|
||||
|
||||
public Map<Wallet, Storage> getOpenWallets() {
|
||||
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
|
||||
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
|
||||
@@ -433,6 +496,10 @@ public class AppServices {
|
||||
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
|
||||
}
|
||||
|
||||
public static void clearTransactionHistoryCache(Wallet wallet) {
|
||||
ElectrumServer.clearRetrievedScriptHashes(wallet);
|
||||
}
|
||||
|
||||
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
|
||||
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
|
||||
}
|
||||
@@ -447,18 +514,59 @@ public class AppServices {
|
||||
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
alert.setTitle(title);
|
||||
alert.setHeaderText(title);
|
||||
moveToActiveWindowScreen(alert);
|
||||
return alert.showAndWait();
|
||||
}
|
||||
|
||||
public static void setStageIcon(Window window) {
|
||||
Stage stage = (Stage)window;
|
||||
stage.getIcons().add(new Image(AppServices.class.getResourceAsStream("/image/sparrow.png")));
|
||||
stage.getIcons().add(new Image(AppServices.class.getResourceAsStream("/image/sparrow-large.png")));
|
||||
|
||||
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
|
||||
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
|
||||
}
|
||||
}
|
||||
|
||||
public static Window getActiveWindow() {
|
||||
return Stage.getWindows().stream().filter(Window::isFocused).findFirst().orElse(get().walletWindows.keySet().iterator().hasNext() ? get().walletWindows.keySet().iterator().next() : null);
|
||||
}
|
||||
|
||||
public static void moveToActiveWindowScreen(Dialog<?> dialog) {
|
||||
Window activeWindow = getActiveWindow();
|
||||
if(activeWindow != null) {
|
||||
moveToWindowScreen(activeWindow, dialog);
|
||||
}
|
||||
}
|
||||
|
||||
public static void moveToActiveWindowScreen(Window newWindow, double newWindowWidth, double newWindowHeight) {
|
||||
Window activeWindow = getActiveWindow();
|
||||
if(activeWindow != null) {
|
||||
moveToWindowScreen(activeWindow, newWindow, newWindowWidth, newWindowHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public void moveToWalletWindowScreen(Storage storage, Dialog<?> dialog) {
|
||||
moveToWindowScreen(getWindowForWallet(storage), dialog);
|
||||
}
|
||||
|
||||
public static void moveToWindowScreen(Window currentWindow, Dialog<?> dialog) {
|
||||
Window newWindow = dialog.getDialogPane().getScene().getWindow();
|
||||
DialogPane dialogPane = dialog.getDialogPane();
|
||||
double dialogWidth = dialogPane.getPrefWidth() > 0.0 ? dialogPane.getPrefWidth() : (dialogPane.getWidth() > 0.0 ? dialogPane.getWidth() : 360);
|
||||
double dialogHeight = dialogPane.getPrefHeight() > 0.0 ? dialogPane.getPrefHeight() : (dialogPane.getHeight() > 0.0 ? dialogPane.getHeight() : 200);
|
||||
moveToWindowScreen(currentWindow, newWindow, dialogWidth, dialogHeight);
|
||||
}
|
||||
|
||||
public static void moveToWindowScreen(Window currentWindow, Window newWindow, double newWindowWidth, double newWindowHeight) {
|
||||
Screen currentScreen = Screen.getScreens().stream().filter(screen -> screen.getVisualBounds().contains(currentWindow.getX(), currentWindow.getY())).findFirst().orElse(null);
|
||||
if(currentScreen != null && !Screen.getPrimary().getVisualBounds().contains(currentWindow.getX(), currentWindow.getY()) && !currentScreen.getVisualBounds().contains(newWindow.getX(), newWindow.getY())) {
|
||||
double x = currentWindow.getX() + (currentWindow.getWidth() / 2) - (newWindowWidth / 2);
|
||||
double y = currentWindow.getY() + (currentWindow.getHeight() / 2.2) - (newWindowHeight / 2);
|
||||
newWindow.setX(x);
|
||||
newWindow.setY(y);
|
||||
}
|
||||
}
|
||||
|
||||
public static Font getMonospaceFont() {
|
||||
return Font.font("Roboto Mono", 13);
|
||||
}
|
||||
@@ -520,7 +628,7 @@ public class AppServices {
|
||||
public void versionCheckStatus(VersionCheckStatusEvent event) {
|
||||
versionCheckService.cancel();
|
||||
|
||||
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled()) {
|
||||
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled() && Network.get() == Network.MAINNET) {
|
||||
versionCheckService = createVersionCheckService();
|
||||
versionCheckService.start();
|
||||
}
|
||||
@@ -539,7 +647,7 @@ public class AppServices {
|
||||
Platform.runLater(() -> {
|
||||
if(!Window.getWindows().isEmpty()) {
|
||||
List<File> walletFiles = allWallets.stream().map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
|
||||
Config.get().setRecentWalletFiles(walletFiles);
|
||||
Config.get().setRecentWalletFiles(Config.get().isLoadRecentWallets() ? walletFiles : Collections.emptyList());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -555,6 +663,10 @@ public class AppServices {
|
||||
deviceEnumerateService = createDeviceEnumerateService();
|
||||
}
|
||||
|
||||
if(deviceEnumerateService.isRunning()) {
|
||||
deviceEnumerateService.cancel();
|
||||
}
|
||||
|
||||
if(deviceEnumerateService.getState() == Worker.State.CANCELLED) {
|
||||
deviceEnumerateService.reset();
|
||||
}
|
||||
@@ -584,7 +696,7 @@ public class AppServices {
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
|
||||
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
|
||||
restartBwt(event.getWallet());
|
||||
}
|
||||
|
||||
@@ -606,4 +718,14 @@ public class AppServices {
|
||||
connectionService.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
|
||||
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && isConnected()) {
|
||||
onlineProperty.set(false);
|
||||
log.info("Failed to fetch wallet history from " + Config.get().getServerAddress() + ", reconnecting to another server...");
|
||||
Config.get().changePublicServer();
|
||||
onlineProperty.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.Parameter;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import org.slf4j.event.Level;
|
||||
|
||||
public class Args {
|
||||
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
|
||||
@@ -10,6 +11,9 @@ public class Args {
|
||||
@Parameter(names = { "--network", "-n" }, description = "Network to use")
|
||||
public Network network;
|
||||
|
||||
@Parameter(names = { "--level", "-l" }, description = "Set log level")
|
||||
public Level level;
|
||||
|
||||
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
|
||||
public boolean help;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.JCommander;
|
||||
import com.sparrowwallet.drongo.Drongo;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
@@ -18,8 +19,10 @@ import javafx.application.Application;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.glyphfont.GlyphFontRegistry;
|
||||
import org.controlsfx.tools.Platform;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.bridge.SLF4JBridgeHandler;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
@@ -27,7 +30,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
public class MainApp extends Application {
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "1.2.0";
|
||||
public static final String APP_VERSION = "1.4.0";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
@@ -77,6 +80,10 @@ public class MainApp extends Application {
|
||||
Config.get().setCoreWallet("");
|
||||
}
|
||||
|
||||
if(Config.get().getHdCapture() == null && Platform.getCurrent() == Platform.OSX) {
|
||||
Config.get().setHdCapture(Boolean.TRUE);
|
||||
}
|
||||
|
||||
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
|
||||
|
||||
AppController appController = AppServices.newAppWindow(stage);
|
||||
@@ -119,6 +126,10 @@ public class MainApp extends Application {
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
if(args.level != null) {
|
||||
Drongo.setRootLogLevel(args.level);
|
||||
}
|
||||
|
||||
if(args.dir != null) {
|
||||
System.setProperty(APP_HOME_PROPERTY, args.dir);
|
||||
getLogger().info("Using configured Sparrow home folder of " + args.dir);
|
||||
@@ -151,6 +162,8 @@ public class MainApp extends Application {
|
||||
getLogger().info("Using " + Network.get() + " configuration");
|
||||
}
|
||||
|
||||
SLF4JBridgeHandler.removeHandlersForRootLogger();
|
||||
SLF4JBridgeHandler.install();
|
||||
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.LogHandler;
|
||||
import com.sparrowwallet.sparrow.event.TorStatusEvent;
|
||||
import org.slf4j.event.Level;
|
||||
|
||||
public class TorLogHandler implements LogHandler {
|
||||
@Override
|
||||
public void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData) {
|
||||
EventManager.get().post(new TorStatusEvent(message));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.event.EventType;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -48,8 +51,12 @@ public class WelcomeController {
|
||||
|
||||
welcomeBox.getStyleClass().add("offline");
|
||||
serverStatus.setText("Offline");
|
||||
serverToggle.addEventFilter(MouseEvent.MOUSE_RELEASED, Event::consume);
|
||||
Tooltip tooltip = new Tooltip("Demonstration only - you are not connected!");
|
||||
tooltip.setShowDelay(Duration.ZERO);
|
||||
serverToggle.setTooltip(tooltip);
|
||||
serverToggle.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
serverStatus.setText(newValue ? "Connected" : "Offline");
|
||||
serverStatus.setText(newValue ? "Connected (demonstration only)" : "Offline");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,7 +69,7 @@ public class WelcomeController {
|
||||
PauseTransition wait = new PauseTransition(Duration.millis(200));
|
||||
wait.setOnFinished((e) -> {
|
||||
serverToggle.setSelected(true);
|
||||
serverStatus.setText("Connected to a Public Server");
|
||||
serverStatus.setText("Connected to a Public Server (demonstration only)");
|
||||
});
|
||||
wait.play();
|
||||
return true;
|
||||
@@ -74,7 +81,7 @@ public class WelcomeController {
|
||||
welcomeBox.getStyleClass().clear();
|
||||
welcomeBox.getStyleClass().add("bitcoin-core");
|
||||
serverToggle.setSelected(true);
|
||||
serverStatus.setText("Connected to Bitcoin Core");
|
||||
serverStatus.setText("Connected to Bitcoin Core (demonstration only)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -84,7 +91,7 @@ public class WelcomeController {
|
||||
welcomeBox.getStyleClass().clear();
|
||||
welcomeBox.getStyleClass().add("private-electrum");
|
||||
serverToggle.setSelected(true);
|
||||
serverStatus.setText("Connected to a Private Electrum Server");
|
||||
serverStatus.setText("Connected to a Private Electrum Server (demonstration only)");
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -111,7 +118,7 @@ public class WelcomeController {
|
||||
welcomeBox.getStyleClass().clear();
|
||||
welcomeBox.getStyleClass().add("public-electrum");
|
||||
serverToggle.setSelected(true);
|
||||
serverStatus.setText("Connected to a Public Server");
|
||||
serverStatus.setText("Connected to a Public Server (demonstration only)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,7 +128,7 @@ public class WelcomeController {
|
||||
welcomeBox.getStyleClass().clear();
|
||||
welcomeBox.getStyleClass().add("bitcoin-core");
|
||||
serverToggle.setSelected(true);
|
||||
serverStatus.setText("Connected to Bitcoin Core");
|
||||
serverStatus.setText("Connected to Bitcoin Core (demonstration only)");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,13 +21,14 @@ public class WelcomeDialog extends Dialog<Mode> {
|
||||
|
||||
dialogPane.setPrefWidth(600);
|
||||
dialogPane.setPrefHeight(520);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
|
||||
|
||||
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
|
||||
final ButtonType backButtonType = new javafx.scene.control.ButtonType("Back", ButtonBar.ButtonData.LEFT);
|
||||
final ButtonType onlineButtonType = new javafx.scene.control.ButtonType("Configure Server", ButtonBar.ButtonData.APPLY);
|
||||
final ButtonType offlineButtonType = new javafx.scene.control.ButtonType("Later or Offline Mode", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
final ButtonType offlineButtonType = new javafx.scene.control.ButtonType(AppServices.isConnected() ? "Done" : "Later or Offline Mode", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(nextButtonType, backButtonType, onlineButtonType, offlineButtonType);
|
||||
|
||||
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.wallet.SendController;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.Axis;
|
||||
@@ -27,9 +28,11 @@ public class BlockTargetFeeRatesChart extends LineChart<String, Number> {
|
||||
|
||||
for(Iterator<Integer> targetBlocksIter = targetBlocksFeeRates.keySet().iterator(); targetBlocksIter.hasNext(); ) {
|
||||
Integer targetBlocks = targetBlocksIter.next();
|
||||
String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+");
|
||||
XYChart.Data<String, Number> data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks));
|
||||
feeRateSeries.getData().add(data);
|
||||
if(SendController.TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
|
||||
String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+");
|
||||
XYChart.Data<String, Number> data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks));
|
||||
feeRateSeries.getData().add(data);
|
||||
}
|
||||
}
|
||||
|
||||
if(selectedTargetBlocks != null) {
|
||||
|
||||
@@ -66,6 +66,10 @@ class CoinCell extends TreeTableCell<Entry, Number> {
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
|
||||
if(amount.longValue() < 0) {
|
||||
getStyleClass().add("negative-amount");
|
||||
}
|
||||
} else if(entry instanceof UtxoEntry) {
|
||||
setGraphic(null);
|
||||
} else if(entry instanceof HashIndexEntry) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
@@ -84,16 +84,15 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate());
|
||||
Optional<Date> optDate = dlg.showAndWait();
|
||||
if(optDate.isPresent()) {
|
||||
Wallet pastWallet = wallet.copy();
|
||||
wallet.setBirthDate(optDate.get());
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
if(storage != null) {
|
||||
//Trigger background save of birthdate
|
||||
EventManager.get().post(new WalletDataChangedEvent(wallet));
|
||||
//Trigger full wallet rescan
|
||||
wallet.clearHistory();
|
||||
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, storage.getWalletFile()));
|
||||
}
|
||||
Wallet pastWallet = wallet.copy();
|
||||
storage.backupTempWallet();
|
||||
wallet.setBirthDate(optDate.get());
|
||||
//Trigger background save of birthdate
|
||||
EventManager.get().post(new WalletDataChangedEvent(wallet));
|
||||
//Trigger full wallet rescan
|
||||
wallet.clearHistory();
|
||||
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletFile()));
|
||||
}
|
||||
});
|
||||
if(wallet.getBirthDate() == null) {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DeviceAddressDialog extends DeviceDialog<String> {
|
||||
private final Wallet wallet;
|
||||
private final KeyDerivation keyDerivation;
|
||||
private final OutputDescriptor outputDescriptor;
|
||||
|
||||
public DeviceAddressDialog(List<String> operationFingerprints, Wallet wallet, KeyDerivation keyDerivation) {
|
||||
super(operationFingerprints);
|
||||
public DeviceAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
|
||||
super(outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList()));
|
||||
this.wallet = wallet;
|
||||
this.keyDerivation = keyDerivation;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
@@ -26,7 +26,7 @@ public class DeviceAddressDialog extends DeviceDialog<String> {
|
||||
|
||||
@Override
|
||||
protected DevicePane getDevicePane(Device device) {
|
||||
return new DevicePane(wallet, keyDerivation, device);
|
||||
return new DevicePane(wallet, outputDescriptor, device);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
|
||||
@@ -89,6 +89,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(360);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
|
||||
}
|
||||
@@ -101,6 +102,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
|
||||
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
deviceAccordion.getPanes().clear();
|
||||
scanBox.setVisible(true);
|
||||
scanLabel.setText(workerStateEvent.getSource().getException().getMessage());
|
||||
});
|
||||
@@ -113,7 +115,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
|
||||
|
||||
if(operationFingerprints != null) {
|
||||
dialogDevices.removeIf(device -> {
|
||||
return device.getFingerprint() != null && !operationFingerprints.contains(device.getFingerprint()) && !(device.getNeedsPinSent() || device.getNeedsPassphraseSent());
|
||||
return device.getFingerprint() != null && !operationFingerprints.contains(device.getFingerprint()) && !(device.isNeedsPinSent() || device.isNeedsPassphraseSent());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,19 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
|
||||
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.io.Hwi;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
@@ -25,7 +24,6 @@ import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
@@ -35,7 +33,9 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DevicePane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
|
||||
@@ -43,6 +43,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
private final DeviceOperation deviceOperation;
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
private final OutputDescriptor outputDescriptor;
|
||||
private final KeyDerivation keyDerivation;
|
||||
private final String message;
|
||||
private final Device device;
|
||||
@@ -63,6 +64,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.deviceOperation = DeviceOperation.IMPORT;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
@@ -83,6 +85,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.deviceOperation = DeviceOperation.SIGN;
|
||||
this.wallet = null;
|
||||
this.psbt = psbt;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
@@ -98,12 +101,13 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
|
||||
}
|
||||
|
||||
public DevicePane(Wallet wallet, KeyDerivation keyDerivation, Device device) {
|
||||
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device) {
|
||||
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
|
||||
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.keyDerivation = keyDerivation;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
this.keyDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
|
||||
@@ -123,6 +127,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.outputDescriptor = null;
|
||||
this.keyDerivation = keyDerivation;
|
||||
this.message = message;
|
||||
this.device = device;
|
||||
@@ -139,9 +144,9 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void initialise(Device device) {
|
||||
if(device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
|
||||
if(device.isNeedsPinSent()) {
|
||||
unlockButton.setVisible(true);
|
||||
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
|
||||
} else if(device.isNeedsPassphraseSent()) {
|
||||
setPassphraseButton.setVisible(true);
|
||||
} else if(device.getError() != null) {
|
||||
setError("Error", device.getError());
|
||||
@@ -160,7 +165,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void setDefaultStatus() {
|
||||
setDescription(device.getNeedsPinSent() ? "Locked" : device.getNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
|
||||
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
|
||||
}
|
||||
|
||||
private void createUnlockButton() {
|
||||
@@ -192,18 +197,31 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation());
|
||||
importKeystore(wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation());
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
if(wallet.getScriptType() == null) {
|
||||
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH};
|
||||
for(ScriptType scriptType : scriptTypes) {
|
||||
MenuItem item = new MenuItem(scriptType.getDescription());
|
||||
final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
}
|
||||
} else {
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
}
|
||||
}
|
||||
importButton.managedProperty().bind(importButton.visibleProperty());
|
||||
importButton.setVisible(false);
|
||||
@@ -233,7 +251,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
displayAddressButton.managedProperty().bind(displayAddressButton.visibleProperty());
|
||||
displayAddressButton.setVisible(false);
|
||||
|
||||
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
|
||||
List<String> fingerprints = outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList());
|
||||
if(device.getFingerprint() != null && !fingerprints.contains(device.getFingerprint())) {
|
||||
displayAddressButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
@@ -306,12 +325,22 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
passphrase.bind(passphraseField.textProperty());
|
||||
HBox.setHgrow(passphraseField, Priority.ALWAYS);
|
||||
|
||||
Button sendPassphraseButton = new Button("Send Passphrase");
|
||||
SplitMenuButton sendPassphraseButton = new SplitMenuButton();
|
||||
sendPassphraseButton.setText("Send Passphrase");
|
||||
sendPassphraseButton.setOnAction(event -> {
|
||||
setExpanded(false);
|
||||
setDescription("Confirm passphrase on device...");
|
||||
sendPassphrase(passphrase.get());
|
||||
});
|
||||
|
||||
MenuItem removePassphrase = new MenuItem("Toggle Passphrase Off");
|
||||
removePassphrase.setOnAction(event -> {
|
||||
setExpanded(false);
|
||||
setDescription("Toggling passphrase off, check device...");
|
||||
togglePassphraseOff();
|
||||
});
|
||||
sendPassphraseButton.getItems().add(removePassphrase);
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
@@ -324,6 +353,35 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getTogglePassphraseOn() {
|
||||
CopyableLabel label = new CopyableLabel("Passphrase is currently disabled");
|
||||
HBox.setHgrow(label, Priority.ALWAYS);
|
||||
|
||||
Button togglePassphraseOn = new Button("Toggle Passphrase On");
|
||||
togglePassphraseOn.setOnAction(event -> {
|
||||
setExpanded(false);
|
||||
hideButtons(importButton, signButton, displayAddressButton, signMessageButton);
|
||||
setDescription("Toggling passphrase on, check device...");
|
||||
togglePassphraseOn();
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.setAlignment(Pos.CENTER_LEFT);
|
||||
contentBox.getChildren().addAll(label, togglePassphraseOn);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private void hideButtons(Node... buttons) {
|
||||
for(Node button : buttons) {
|
||||
if(button != null) {
|
||||
button.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void promptPin() {
|
||||
Hwi.PromptPinService promptPinService = new Hwi.PromptPinService(device);
|
||||
promptPinService.setOnSucceeded(workerStateEvent -> {
|
||||
@@ -337,7 +395,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
});
|
||||
promptPinService.setOnFailed(workerStateEvent -> {
|
||||
setError(promptPinService.getException().getMessage(), null);
|
||||
setError("Error", promptPinService.getException().getMessage());
|
||||
unlockButton.setDisable(false);
|
||||
});
|
||||
promptPinService.start();
|
||||
@@ -353,13 +411,14 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
setExpanded(false);
|
||||
unlockButton.setVisible(false);
|
||||
|
||||
if(device.getNeedsPassphraseSent()) {
|
||||
if(device.isNeedsPassphraseSent()) {
|
||||
setPassphraseButton.setVisible(true);
|
||||
setPassphraseButton.setDisable(true);
|
||||
setContent(getPassphraseEntry());
|
||||
setExpanded(true);
|
||||
} else {
|
||||
showOperationButton();
|
||||
setContent(getTogglePassphraseOn());
|
||||
}
|
||||
} else {
|
||||
setError("Incorrect PIN", null);
|
||||
@@ -370,7 +429,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
});
|
||||
sendPinService.setOnFailed(workerStateEvent -> {
|
||||
setError(sendPinService.getException().getMessage(), null);
|
||||
setError("Error", sendPinService.getException().getMessage());
|
||||
enterPinButton.setDisable(false);
|
||||
});
|
||||
setDescription("Unlocking...");
|
||||
@@ -399,13 +458,43 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
setError(enumerateService.getException().getMessage(), null);
|
||||
setError("Error", enumerateService.getException().getMessage());
|
||||
setPassphraseButton.setDisable(false);
|
||||
setPassphraseButton.setVisible(true);
|
||||
});
|
||||
enumerateService.start();
|
||||
}
|
||||
|
||||
private void togglePassphraseOff() {
|
||||
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
|
||||
togglePassphraseService.setOnSucceeded(workerStateEvent -> {
|
||||
device.setNeedsPassphraseSent(false);
|
||||
setPassphraseButton.setVisible(false);
|
||||
setDescription("Unlocked");
|
||||
showOperationButton();
|
||||
});
|
||||
togglePassphraseService.setOnFailed(workerStateEvent -> {
|
||||
setError("Error", togglePassphraseService.getException().getMessage());
|
||||
});
|
||||
togglePassphraseService.start();
|
||||
}
|
||||
|
||||
private void togglePassphraseOn() {
|
||||
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
|
||||
togglePassphraseService.setOnSucceeded(workerStateEvent -> {
|
||||
device.setNeedsPassphraseSent(true);
|
||||
setPassphraseButton.setVisible(true);
|
||||
setPassphraseButton.setDisable(true);
|
||||
setDescription("Enter passphrase");
|
||||
setContent(getPassphraseEntry());
|
||||
setExpanded(true);
|
||||
});
|
||||
togglePassphraseService.setOnFailed(workerStateEvent -> {
|
||||
setError("Error", togglePassphraseService.getException().getMessage());
|
||||
});
|
||||
togglePassphraseService.start();
|
||||
}
|
||||
|
||||
private void importKeystore(List<ChildNumber> derivation) {
|
||||
if(device.getFingerprint() == null) {
|
||||
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get());
|
||||
@@ -420,7 +509,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
importXpub(derivation);
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
setError(enumerateService.getException().getMessage(), null);
|
||||
setError("Error", enumerateService.getException().getMessage());
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
enumerateService.start();
|
||||
@@ -436,14 +525,29 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
getXpubService.setOnSucceeded(workerStateEvent -> {
|
||||
String xpub = getXpubService.getValue();
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(device.getModel().toDisplayString());
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setWalletModel(device.getModel());
|
||||
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
|
||||
try {
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(device.getModel().toDisplayString());
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setWalletModel(device.getModel());
|
||||
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
|
||||
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
if(wallet.getScriptType() == null) {
|
||||
ScriptType scriptType = Arrays.stream(ScriptType.SINGLE_HASH_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
|
||||
wallet.setName(device.getModel().toDisplayString());
|
||||
wallet.setPolicyType(PolicyType.SINGLE);
|
||||
wallet.setScriptType(scriptType);
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
|
||||
|
||||
EventManager.get().post(new WalletImportEvent(wallet));
|
||||
} else {
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
setError("Could not retrieve xpub", e.getMessage());
|
||||
}
|
||||
});
|
||||
getXpubService.setOnFailed(workerStateEvent -> {
|
||||
setError("Could not retrieve xpub", getXpubService.getException().getMessage());
|
||||
@@ -471,7 +575,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void displayAddress() {
|
||||
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), keyDerivation.getDerivationPath());
|
||||
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), outputDescriptor);
|
||||
displayAddressService.setOnSucceeded(successEvent -> {
|
||||
String address = displayAddressService.getValue();
|
||||
EventManager.get().post(new AddressDisplayedEvent(address));
|
||||
@@ -503,7 +607,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
importButton.setVisible(true);
|
||||
showHideLink.setText("Show derivation...");
|
||||
showHideLink.setVisible(true);
|
||||
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
|
||||
setContent(getDerivationEntry(wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation()));
|
||||
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
|
||||
signButton.setVisible(true);
|
||||
showHideLink.setVisible(false);
|
||||
@@ -552,6 +656,10 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
public Device getDevice() {
|
||||
return device;
|
||||
}
|
||||
|
||||
public enum DeviceOperation {
|
||||
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE;
|
||||
}
|
||||
|
||||
@@ -19,14 +19,21 @@ import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
|
||||
|
||||
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
private static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*)\\(Replaced By Fee( #)?(\\d+)?\\).*");
|
||||
|
||||
public EntryCell() {
|
||||
super();
|
||||
@@ -85,6 +92,17 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
actionBox.getChildren().add(increaseFeeButton);
|
||||
}
|
||||
|
||||
if(blockTransaction.getHeight() <= 0 && containsWalletOutputs(transactionEntry)) {
|
||||
Button cpfpButton = new Button("");
|
||||
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
|
||||
cpfpGlyph.setFontSize(12);
|
||||
cpfpButton.setGraphic(cpfpGlyph);
|
||||
cpfpButton.setOnAction(event -> {
|
||||
createCpfp(transactionEntry);
|
||||
});
|
||||
actionBox.getChildren().add(cpfpButton);
|
||||
}
|
||||
|
||||
setGraphic(actionBox);
|
||||
} else if(entry instanceof NodeEntry) {
|
||||
NodeEntry nodeEntry = (NodeEntry)entry;
|
||||
@@ -108,7 +126,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
actionBox.getChildren().add(receiveButton);
|
||||
|
||||
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
|
||||
(nodeEntry.getWallet().getKeystores().get(0).hasSeed() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
(nodeEntry.getWallet().getKeystores().get(0).hasPrivateKey() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
Button signMessageButton = new Button("");
|
||||
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
|
||||
signMessageGlyph.setFontSize(12);
|
||||
@@ -183,7 +201,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
|
||||
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
|
||||
Transaction tx = blockTransaction.getTransaction();
|
||||
int vSize = tx.getVirtualSize();
|
||||
double vSize = tx.getVirtualSize();
|
||||
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
|
||||
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
|
||||
Collections.shuffle(walletUtxos);
|
||||
@@ -200,12 +218,32 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
externalOutputs.addAll(consolidationOutputs);
|
||||
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
|
||||
try {
|
||||
return new Payment(txOutput.getScript().getToAddresses()[0], transactionEntry.getLabel(), txOutput.getValue(), false);
|
||||
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
|
||||
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(label);
|
||||
if(matcher.matches()) {
|
||||
String base = matcher.group(1);
|
||||
if(matcher.groupCount() > 2 && matcher.group(3) != null) {
|
||||
int count = Integer.parseInt(matcher.group(3)) + 1;
|
||||
label = base + "(Replaced By Fee #" + count + ")";
|
||||
} else {
|
||||
label = base + "(Replaced By Fee #2)";
|
||||
}
|
||||
} else {
|
||||
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
|
||||
}
|
||||
|
||||
return new Payment(txOutput.getScript().getToAddresses()[0], label, txOutput.getValue(), false);
|
||||
} catch(Exception e) {
|
||||
log.error("Error creating RBF payment", e);
|
||||
return null;
|
||||
}
|
||||
}).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
|
||||
if(payments.isEmpty()) {
|
||||
AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction, check log for details");
|
||||
return;
|
||||
}
|
||||
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true)));
|
||||
}
|
||||
@@ -218,6 +256,37 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
return AppServices.getTargetBlockFeeRates().values().iterator().next();
|
||||
}
|
||||
|
||||
private static void createCpfp(TransactionEntry transactionEntry) {
|
||||
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
|
||||
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
|
||||
.map(HashIndexEntry::getHashIndex)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if(ourOutputs.isEmpty()) {
|
||||
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend");
|
||||
}
|
||||
|
||||
BlockTransactionHashIndex utxo = ourOutputs.get(0);
|
||||
|
||||
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
|
||||
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
|
||||
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
|
||||
Payment payment = new Payment(transactionEntry.getWallet().getAddress(freshNode), label, utxo.getValue(), true);
|
||||
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), blockTransaction.getFee(), false)));
|
||||
}
|
||||
|
||||
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
|
||||
return transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.anyMatch(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT));
|
||||
}
|
||||
|
||||
private static void sendSelectedUtxos(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
|
||||
List<HashIndexEntry> utxoEntries = treeTableView.getSelectionModel().getSelectedCells().stream()
|
||||
.map(tp -> tp.getTreeItem().getValue())
|
||||
@@ -259,7 +328,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
getItems().add(copyTxid);
|
||||
|
||||
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
MenuItem increaseFee = new MenuItem("Increase Fee");
|
||||
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
|
||||
increaseFee.setOnAction(AE -> {
|
||||
hide();
|
||||
increaseFee(transactionEntry);
|
||||
@@ -267,6 +336,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
|
||||
getItems().add(increaseFee);
|
||||
}
|
||||
|
||||
if(containsWalletOutputs(transactionEntry)) {
|
||||
MenuItem createCpfp = new MenuItem("Increase Effective Fee (CPFP)");
|
||||
createCpfp.setOnAction(AE -> {
|
||||
hide();
|
||||
createCpfp(transactionEntry);
|
||||
});
|
||||
|
||||
getItems().add(createCpfp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +468,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
cell.getStyleClass().remove("address-cell");
|
||||
cell.getStyleClass().remove("hashindex-row");
|
||||
cell.getStyleClass().remove("confirming");
|
||||
cell.getStyleClass().remove("negative-amount");
|
||||
cell.getStyleClass().remove("spent");
|
||||
cell.getStyleClass().remove("unspendable");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.FileImport;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
@@ -95,6 +96,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
new FileChooser.ExtensionFilter("TXT", "*.txt")
|
||||
);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if(file != null) {
|
||||
importFile(file, null);
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletExportEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.CoboVaultMultisig;
|
||||
import com.sparrowwallet.sparrow.io.PassportMultisig;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.io.WalletExport;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -81,8 +82,12 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
|
||||
fileChooser.setInitialFileName(wallet.getName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase() + "." + exporter.getExportFileExtension());
|
||||
String extension = exporter.getExportFileExtension(wallet);
|
||||
fileChooser.setInitialFileName(wallet.getName() + "-" +
|
||||
exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "") +
|
||||
(extension == null || extension.isEmpty() ? "" : "." + extension));
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showSaveDialog(window);
|
||||
if(file != null) {
|
||||
exportWallet(file);
|
||||
@@ -90,10 +95,9 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void exportWallet(File file) {
|
||||
Wallet copy = wallet.copy();
|
||||
|
||||
if(copy.isEncrypted()) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
final File walletFile = AppServices.get().getOpenWallets().get(wallet).getWalletFile();
|
||||
@@ -101,7 +105,12 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
decryptWalletService.setOnSucceeded(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Done"));
|
||||
Wallet decryptedWallet = decryptWalletService.getValue();
|
||||
exportWallet(file, decryptedWallet);
|
||||
|
||||
try {
|
||||
exportWallet(file, decryptedWallet);
|
||||
} finally {
|
||||
decryptedWallet.clearPrivate();
|
||||
}
|
||||
});
|
||||
decryptWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Failed"));
|
||||
@@ -111,22 +120,24 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
decryptWalletService.start();
|
||||
}
|
||||
} else {
|
||||
exportWallet(file, copy);
|
||||
exportWallet(file, wallet);
|
||||
}
|
||||
}
|
||||
|
||||
private void exportWallet(File file, Wallet decryptedWallet) {
|
||||
private void exportWallet(File file, Wallet exportWallet) {
|
||||
try {
|
||||
if(file != null) {
|
||||
OutputStream outputStream = new FileOutputStream(file);
|
||||
exporter.exportWallet(decryptedWallet, outputStream);
|
||||
EventManager.get().post(new WalletExportEvent(decryptedWallet));
|
||||
exporter.exportWallet(exportWallet, outputStream);
|
||||
EventManager.get().post(new WalletExportEvent(exportWallet));
|
||||
} else {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
exporter.exportWallet(decryptedWallet, outputStream);
|
||||
exporter.exportWallet(exportWallet, outputStream);
|
||||
QRDisplayDialog qrDisplayDialog;
|
||||
if(exporter instanceof CoboVaultMultisig) {
|
||||
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
|
||||
} else if(exporter instanceof PassportMultisig) {
|
||||
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
@@ -138,8 +149,6 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Export Error", errorMessage);
|
||||
} finally {
|
||||
decryptedWallet.clearPrivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ public class KeystorePassphraseDialog extends Dialog<String> {
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK);
|
||||
dialogPane.setPrefWidth(380);
|
||||
dialogPane.setPrefHeight(200);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Glyph key = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.KEY);
|
||||
key.setFontSize(50);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
public class MasterKeyDisplayDialog extends Dialog<Void> {
|
||||
public MasterKeyDisplayDialog(Keystore decryptedKeystore) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
AnchorPane anchorPane = new AnchorPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.getStyleClass().add("edge-to-edge");
|
||||
scrollPane.setPrefHeight(200);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
Accordion keystoreAccordion = new Accordion();
|
||||
scrollPane.setContent(keystoreAccordion);
|
||||
|
||||
XprvKeystoreImportPane keystorePane = new XprvKeystoreImportPane(decryptedKeystore);
|
||||
keystorePane.setAnimated(false);
|
||||
keystoreAccordion.getPanes().add(keystorePane);
|
||||
|
||||
stackPane.getChildren().addAll(anchorPane);
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(260);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
if(wallet.getKeystores().size() != 1) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
|
||||
}
|
||||
if(!wallet.getKeystores().get(0).hasSeed() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
|
||||
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore");
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
|
||||
}
|
||||
|
||||
@@ -211,7 +212,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
return;
|
||||
}
|
||||
|
||||
if(wallet.containsSeeds()) {
|
||||
if(wallet.containsPrivateKeys()) {
|
||||
if(wallet.isEncrypted()) {
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
} else {
|
||||
@@ -230,6 +231,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
|
||||
signature.clear();
|
||||
signature.appendText(signatureText);
|
||||
privKey.clear();
|
||||
} catch(Exception e) {
|
||||
log.error("Could not sign message", e);
|
||||
AppServices.showErrorDialog("Could not sign message", e.getMessage());
|
||||
@@ -274,6 +276,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
alert.setTitle("Verification Succeeded");
|
||||
alert.setHeaderText("Verification Succeeded");
|
||||
alert.setContentText("The signature verified against the message.");
|
||||
AppServices.moveToActiveWindowScreen(alert);
|
||||
alert.showAndWait();
|
||||
} else {
|
||||
AppServices.showErrorDialog("Verification failed", "The provided signature did not match the message for this address.");
|
||||
@@ -308,7 +311,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
return;
|
||||
}
|
||||
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
|
||||
@@ -316,6 +319,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
|
||||
Wallet decryptedWallet = decryptWalletService.getValue();
|
||||
signUnencryptedKeystore(decryptedWallet);
|
||||
decryptedWallet.clearPrivate();
|
||||
});
|
||||
decryptWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -30,6 +32,7 @@ import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -55,6 +58,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
|
||||
private SimpleListProperty<String> wordEntriesProperty;
|
||||
private final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
|
||||
private IntegerProperty defaultWordSizeProperty;
|
||||
|
||||
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer) {
|
||||
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
@@ -66,7 +70,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
public MnemonicKeystoreImportPane(Keystore keystore) {
|
||||
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase enabled" : "Passphrase disabled", "", "image/" + WalletModel.SEED.getType() + ".png");
|
||||
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase entered" : "No passphrase", "", "image/" + WalletModel.SEED.getType() + ".png");
|
||||
this.wallet = null;
|
||||
this.importer = null;
|
||||
showHideLink.setVisible(false);
|
||||
@@ -84,15 +88,20 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
private void createEnterMnemonicButton() {
|
||||
enterMnemonicButton = new SplitMenuButton();
|
||||
enterMnemonicButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
enterMnemonicButton.setText("Set Words Size");
|
||||
enterMnemonicButton.setText("Enter 24 Words");
|
||||
defaultWordSizeProperty = new SimpleIntegerProperty(24);
|
||||
defaultWordSizeProperty.addListener((observable, oldValue, newValue) -> {
|
||||
enterMnemonicButton.setText("Enter " + newValue + " Words");
|
||||
});
|
||||
enterMnemonicButton.setOnAction(event -> {
|
||||
enterMnemonic(24);
|
||||
enterMnemonic(defaultWordSizeProperty.get());
|
||||
});
|
||||
int[] numberWords = new int[] {24, 21, 18, 15, 12};
|
||||
for(int i = 0; i < numberWords.length; i++) {
|
||||
MenuItem item = new MenuItem(numberWords[i] + " words");
|
||||
MenuItem item = new MenuItem("Enter " + numberWords[i] + " Words");
|
||||
final int words = numberWords[i];
|
||||
item.setOnAction(event -> {
|
||||
defaultWordSizeProperty.set(words);
|
||||
enterMnemonic(words);
|
||||
});
|
||||
enterMnemonicButton.getItems().add(item);
|
||||
@@ -109,7 +118,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
@@ -201,14 +210,14 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
confirmButton.setDefaultButton(true);
|
||||
confirmButton.setTooltip(new Tooltip("Re-enter the generated word list to confirm your backup is correct"));
|
||||
|
||||
calculateButton = new Button("Calculate Seed");
|
||||
calculateButton = new Button("Create Keystore");
|
||||
calculateButton.setDisable(true);
|
||||
calculateButton.setDefaultButton(true);
|
||||
calculateButton.setOnAction(event -> {
|
||||
prepareImport();
|
||||
});
|
||||
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
|
||||
calculateButton.setTooltip(new Tooltip("Calculate the seed from the provided word list"));
|
||||
calculateButton.setTooltip(new Tooltip("Create the keystore from the provided word list"));
|
||||
|
||||
backButton = new Button("Back");
|
||||
backButton.setOnAction(event -> {
|
||||
@@ -275,7 +284,14 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
|
||||
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
|
||||
|
||||
DeterministicSeed deterministicSeed = new DeterministicSeed(new SecureRandom(), entropyLength, "");
|
||||
SecureRandom secureRandom;
|
||||
try {
|
||||
secureRandom = SecureRandom.getInstanceStrong();
|
||||
} catch(NoSuchAlgorithmException e) {
|
||||
secureRandom = new SecureRandom();
|
||||
}
|
||||
|
||||
DeterministicSeed deterministicSeed = new DeterministicSeed(secureRandom, entropyLength, "");
|
||||
generatedMnemonicCode = deterministicSeed.getMnemonicCode();
|
||||
|
||||
displayMnemonicCode();
|
||||
@@ -312,7 +328,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
|
||||
StackPane wordsPane = (StackPane)getContent();
|
||||
StackPane confirmPane = new StackPane();
|
||||
confirmPane.setMaxWidth(300);
|
||||
confirmPane.setMaxWidth(350);
|
||||
confirmPane.setMaxHeight(100);
|
||||
Region region = new Region();
|
||||
region.setMinWidth(confirmPane.getMaxWidth());
|
||||
@@ -352,7 +368,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
importButton.setDisable(false);
|
||||
setDescription("Ready to import");
|
||||
showHideLink.setText("Show Derivation...");
|
||||
showHideLink.setVisible(true);
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
|
||||
}
|
||||
}
|
||||
@@ -392,6 +408,19 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
label.setAlignment(Pos.CENTER_RIGHT);
|
||||
wordField = new TextField();
|
||||
wordField.setMaxWidth(100);
|
||||
TextFormatter<?> formatter = new TextFormatter<>((TextFormatter.Change change) -> {
|
||||
String text = change.getText();
|
||||
// if text was added, fix the text to fit the requirements
|
||||
if(!text.isEmpty()) {
|
||||
String newText = text.replace(" ", "").toLowerCase();
|
||||
int carretPos = change.getCaretPosition() - text.length() + newText.length();
|
||||
change.setText(newText);
|
||||
// fix caret position based on difference in originally added text and fixed text
|
||||
change.selectRange(carretPos, carretPos);
|
||||
}
|
||||
return change;
|
||||
});
|
||||
wordField.setTextFormatter(formatter);
|
||||
|
||||
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
|
||||
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
|
||||
@@ -478,7 +507,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
|
||||
Button importDerivationButton = new Button("Import");
|
||||
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
|
||||
importDerivationButton.setDisable(true);
|
||||
importDerivationButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
@@ -487,7 +517,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
});
|
||||
|
||||
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue));
|
||||
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
|
||||
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
|
||||
@@ -37,6 +37,9 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
private static final int ANIMATION_PERIOD_MILLIS = 400;
|
||||
|
||||
private static final int QR_WIDTH = 480;
|
||||
private static final int QR_HEIGHT = 480;
|
||||
|
||||
private final UR ur;
|
||||
private final UREncoder encoder;
|
||||
|
||||
@@ -91,8 +94,9 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(550);
|
||||
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
|
||||
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
}
|
||||
@@ -113,8 +117,9 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(550);
|
||||
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
|
||||
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
}
|
||||
@@ -135,7 +140,7 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
private Image getQrCode(String fragment) {
|
||||
try {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, 480, 480);
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
import com.github.sarxos.webcam.WebcamEvent;
|
||||
import com.github.sarxos.webcam.WebcamListener;
|
||||
import com.github.sarxos.webcam.WebcamResolution;
|
||||
import com.github.sarxos.webcam.WebcamUpdater;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
@@ -16,6 +17,7 @@ import com.sparrowwallet.drongo.protocol.Base43;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
@@ -28,13 +30,17 @@ import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Borders;
|
||||
import org.slf4j.Logger;
|
||||
@@ -59,13 +65,15 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
private final WebcamService webcamService;
|
||||
private List<String> parts;
|
||||
|
||||
private boolean isUr;
|
||||
private QRScanDialog.Result result;
|
||||
|
||||
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
|
||||
|
||||
private static final int SCAN_PERIOD_MILLIS = 100;
|
||||
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA);
|
||||
|
||||
private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0);
|
||||
|
||||
public QRScanDialog() {
|
||||
this.decoder = new URDecoder();
|
||||
this.legacyDecoder = new LegacyURDecoder();
|
||||
@@ -74,7 +82,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
webcamResolutionProperty.set(WebcamResolution.HD);
|
||||
}
|
||||
|
||||
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener());
|
||||
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener(), new ScanDelayCalculator());
|
||||
webcamService.setPeriod(Duration.millis(SCAN_PERIOD_MILLIS));
|
||||
webcamService.setRestartOnFailure(false);
|
||||
WebcamView webcamView = new WebcamView(webcamService);
|
||||
|
||||
final DialogPane dialogPane = new QRScanDialogPane();
|
||||
@@ -83,8 +93,23 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().add(webcamView.getView());
|
||||
Node wrappedView = Borders.wrap(stackPane).lineBorder().buildAll();
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
ProgressBar progressBar = new ProgressBar();
|
||||
progressBar.setMinHeight(20);
|
||||
progressBar.setPadding(new Insets(0, 10, 0, 10));
|
||||
progressBar.setPrefWidth(Integer.MAX_VALUE);
|
||||
progressBar.progressProperty().bind(percentComplete);
|
||||
webcamService.openingProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(percentComplete.get() <= 0.0) {
|
||||
Platform.runLater(() -> percentComplete.set(newValue ? 0.0 : -1.0));
|
||||
}
|
||||
});
|
||||
|
||||
VBox vBox = new VBox(20);
|
||||
vBox.getChildren().addAll(wrappedView, progressBar);
|
||||
|
||||
dialogPane.setContent(vBox);
|
||||
|
||||
webcamService.resultProperty().addListener(new QRResultListener());
|
||||
webcamService.setOnFailed(failedEvent -> {
|
||||
@@ -111,7 +136,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().addAll(hdButtonType, cancelButtonType);
|
||||
dialogPane.setPrefWidth(646);
|
||||
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 450 : 550);
|
||||
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
|
||||
}
|
||||
@@ -127,11 +153,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
String qrtext = qrResult.getText();
|
||||
Matcher partMatcher = PART_PATTERN.matcher(qrtext);
|
||||
|
||||
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
|
||||
isUr = true;
|
||||
|
||||
if(qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
|
||||
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
|
||||
legacyDecoder.receivePart(qrtext.toLowerCase());
|
||||
legacyDecoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete()));
|
||||
|
||||
if(legacyDecoder.isComplete()) {
|
||||
try {
|
||||
@@ -143,6 +168,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
}
|
||||
} else {
|
||||
decoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(decoder.getEstimatedPercentComplete()));
|
||||
|
||||
if(decoder.getResult() != null) {
|
||||
URDecoder.Result urResult = decoder.getResult();
|
||||
@@ -164,12 +190,20 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
}
|
||||
parts.set(m - 1, payload);
|
||||
|
||||
if(n > 0) {
|
||||
Platform.runLater(() -> percentComplete.setValue((double)parts.stream().filter(Objects::nonNull).count() / n));
|
||||
}
|
||||
|
||||
if(parts.stream().filter(Objects::nonNull).count() == n) {
|
||||
String complete = String.join("", parts);
|
||||
try {
|
||||
PSBT psbt = PSBT.fromString(complete);
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(PSBTParseException e) {
|
||||
if(PSBT.isPSBT(complete)) {
|
||||
log.error("Error parsing PSBT", e);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
@@ -218,6 +252,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
psbt = PSBT.fromString(qrtext);
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(PSBTParseException e) {
|
||||
if(PSBT.isPSBT(qrtext)) {
|
||||
log.error("Error parsing PSBT", e);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as Base64 or hex
|
||||
}
|
||||
@@ -276,6 +314,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
try {
|
||||
PSBT psbt = new PSBT(urBytes);
|
||||
return new Result(psbt);
|
||||
} catch(PSBTParseException e) {
|
||||
if(PSBT.isPSBT(urBytes)) {
|
||||
log.error("Error parsing PSBT", e);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
@@ -424,7 +466,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
return wallets;
|
||||
}
|
||||
|
||||
private ScriptType getScriptType(List<ScriptExpression> expressions) {
|
||||
private ScriptType getScriptType(List<ScriptExpression> scriptExpressions) {
|
||||
List<ScriptExpression> expressions = new ArrayList<>(scriptExpressions);
|
||||
if(expressions.get(expressions.size() - 1) == ScriptExpression.MULTISIG || expressions.get(expressions.size() - 1) == ScriptExpression.SORTED_MULTISIG) {
|
||||
expressions.remove(expressions.size() - 1);
|
||||
}
|
||||
|
||||
if(List.of(ScriptExpression.PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2PKH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
@@ -484,6 +531,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
private class QRScanDialogPane extends DialogPane {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
Node button = null;
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
ToggleButton hd = new ToggleButton(buttonType.getText());
|
||||
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
|
||||
@@ -497,10 +545,13 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
setHdGraphic(hd, newValue);
|
||||
});
|
||||
|
||||
return hd;
|
||||
button = hd;
|
||||
} else {
|
||||
button = super.createButton(buttonType);
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
button.disableProperty().bind(webcamService.openingProperty());
|
||||
return button;
|
||||
}
|
||||
|
||||
private void setHdGraphic(ToggleButton hd, boolean isHd) {
|
||||
@@ -663,4 +714,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ScanDelayCalculator implements WebcamUpdater.DelayCalculator {
|
||||
public long calculateDelay(long snapshotDuration, double deviceFps) {
|
||||
return Math.max(SCAN_PERIOD_MILLIS - snapshotDuration, 0L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class SeedDisplayDialog extends Dialog<Void> {
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(150 + height);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class TextAreaDialog extends Dialog<String> {
|
||||
private final TextArea textArea;
|
||||
@@ -16,7 +24,11 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
}
|
||||
|
||||
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
final DialogPane dialogPane = new TextAreaDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
|
||||
Image image = new Image("/image/sparrow-small.png");
|
||||
dialogPane.setGraphic(new ImageView(image));
|
||||
|
||||
HBox hbox = new HBox();
|
||||
this.textArea = new TextArea(defaultValue);
|
||||
@@ -35,6 +47,9 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
dialogPane.getStyleClass().add("text-input-dialog");
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
|
||||
Platform.runLater(textArea::requestFocus);
|
||||
|
||||
setResultConverter((dialogButton) -> {
|
||||
@@ -44,6 +59,7 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
|
||||
dialogPane.setPrefWidth(700);
|
||||
dialogPane.setPrefHeight(400);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
public final TextArea getEditor() {
|
||||
@@ -53,4 +69,53 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
public final String getDefaultValue() {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private class TextAreaDialogPane extends DialogPane {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
Node button;
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
Button scanButton = new Button(buttonType.getText());
|
||||
scanButton.setGraphicTextGap(5);
|
||||
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
|
||||
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(scanButton, buttonData);
|
||||
scanButton.setOnAction(event -> {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.payload != null) {
|
||||
textArea.setText(result.payload);
|
||||
} else if(result.psbt != null) {
|
||||
textArea.setText(result.psbt.toBase64String());
|
||||
} else if(result.transaction != null) {
|
||||
textArea.setText(Utils.bytesToHex(result.transaction.bitcoinSerialize()));
|
||||
} else if(result.uri != null) {
|
||||
textArea.setText(result.uri.toString());
|
||||
} else if(result.extendedKey != null) {
|
||||
textArea.setText(result.extendedKey.getExtendedKey());
|
||||
} else if(result.outputDescriptor != null) {
|
||||
textArea.setText(result.outputDescriptor.toString(true));
|
||||
} else if(result.exception != null) {
|
||||
AppServices.showErrorDialog("Error scanning QR", result.exception.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
button = scanButton;
|
||||
} else {
|
||||
button = super.createButton(buttonType);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,30 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
public void update(String message) {
|
||||
setMinHeight(getDiagramHeight());
|
||||
setMaxHeight(getDiagramHeight());
|
||||
|
||||
getChildren().clear();
|
||||
|
||||
VBox messagePane = new VBox();
|
||||
messagePane.setPrefHeight(getDiagramHeight());
|
||||
messagePane.setPadding(new Insets(0, 10, 0, 280));
|
||||
messagePane.setAlignment(Pos.CENTER);
|
||||
messagePane.getChildren().add(createSpacer());
|
||||
|
||||
Label messageLabel = new Label(message);
|
||||
messagePane.getChildren().add(messageLabel);
|
||||
messagePane.getChildren().add(createSpacer());
|
||||
|
||||
GridPane.setConstraints(messagePane, 3, 0);
|
||||
getChildren().add(messagePane);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
getChildren().clear();
|
||||
}
|
||||
|
||||
public void update() {
|
||||
Map<BlockTransactionHashIndex, WalletNode> displayedUtxos = getDisplayedUtxos();
|
||||
|
||||
@@ -397,7 +421,7 @@ public class TransactionDiagram extends GridPane {
|
||||
|
||||
String txDesc = "Transaction";
|
||||
Label txLabel = new Label(txDesc);
|
||||
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + walletTx.getTransaction().getVirtualSize() + " vBytes");
|
||||
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes");
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
tooltip.getStyleClass().add("transaction-tooltip");
|
||||
txLabel.setTooltip(tooltip);
|
||||
|
||||
@@ -3,13 +3,19 @@ package com.sparrowwallet.sparrow.control;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.stage.Popup;
|
||||
import org.fxmisc.richtext.CodeArea;
|
||||
import org.fxmisc.richtext.event.MouseOverTextEvent;
|
||||
import org.fxmisc.richtext.model.StyleSpan;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
|
||||
|
||||
public class TransactionHexArea extends CodeArea {
|
||||
private static final int TRUNCATE_AT = 30000;
|
||||
@@ -17,6 +23,11 @@ public class TransactionHexArea extends CodeArea {
|
||||
|
||||
private List<TransactionSegment> previousSegmentList = new ArrayList<>();
|
||||
|
||||
public TransactionHexArea() {
|
||||
super();
|
||||
addPopupHandler();
|
||||
}
|
||||
|
||||
public void setTransaction(Transaction transaction) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@@ -44,12 +55,33 @@ public class TransactionHexArea extends CodeArea {
|
||||
previousSegmentList = segments;
|
||||
}
|
||||
|
||||
private void addPopupHandler() {
|
||||
Popup popup = new Popup();
|
||||
Label popupMsg = new Label();
|
||||
popupMsg.getStyleClass().add("tooltip");
|
||||
popup.getContent().add(popupMsg);
|
||||
|
||||
setMouseOverTextDelay(Duration.ofMillis(150));
|
||||
addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
|
||||
Position position = getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex() + 1, Backward);
|
||||
StyleSpan<Collection<String>> styleSpan = getParagraph(0).getStyleSpans().getStyleSpan(position.getMajor());
|
||||
Point2D pos = e.getScreenPosition();
|
||||
popupMsg.setText(describeTransactionPart(styleSpan.getStyle()));
|
||||
if(!popupMsg.getText().isEmpty()) {
|
||||
popup.show(this, pos.getX(), pos.getY() + 10);
|
||||
}
|
||||
});
|
||||
addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
|
||||
popup.hide();
|
||||
});
|
||||
}
|
||||
|
||||
private void applyHighlighting(int start, List<TransactionSegment> segments) {
|
||||
int end = Math.min(segments.size(), start + SEGMENTS_INTERVAL);
|
||||
for(int i = start; i < end; i++) {
|
||||
TransactionSegment segment = segments.get(i);
|
||||
if(segment.start < TRUNCATE_AT) {
|
||||
setStyleClass(segment.start, Math.min(TRUNCATE_AT, segment.start + segment.length), segment.style);
|
||||
setStyle(segment.start, Math.min(TRUNCATE_AT, segment.start + segment.length), getStyles(segment));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +92,18 @@ public class TransactionHexArea extends CodeArea {
|
||||
}
|
||||
}
|
||||
|
||||
private Collection<String> getStyles(TransactionSegment segment) {
|
||||
List<String> styles = new ArrayList<>();
|
||||
styles.add(segment.style);
|
||||
if(segment.index != null) {
|
||||
styles.add("index-" + segment.index);
|
||||
}
|
||||
if(segment.witnessIndex != null) {
|
||||
styles.add("witnessindex-" + segment.witnessIndex);
|
||||
}
|
||||
return Collections.unmodifiableList(styles);
|
||||
}
|
||||
|
||||
public List<TransactionSegment> getTransactionSegments(Transaction transaction, int selectedInputIndex, int selectedOutputIndex) {
|
||||
List<TransactionSegment> segments = new ArrayList<>();
|
||||
|
||||
@@ -82,12 +126,12 @@ public class TransactionHexArea extends CodeArea {
|
||||
//Inputs
|
||||
for(int i = 0; i < transaction.getInputs().size(); i++) {
|
||||
TransactionInput input = transaction.getInputs().get(i);
|
||||
cursor = addSegment(segments, cursor, 32 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "hash"));
|
||||
cursor = addSegment(segments, cursor, 4 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "index"));
|
||||
cursor = addSegment(segments, cursor, 32 * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "hash"));
|
||||
cursor = addSegment(segments, cursor, 4 * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "index"));
|
||||
VarInt scriptLen = new VarInt(input.getScriptBytes().length);
|
||||
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript-length"));
|
||||
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript"));
|
||||
cursor = addSegment(segments, cursor, 4 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sequence"));
|
||||
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript-length"));
|
||||
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript"));
|
||||
cursor = addSegment(segments, cursor, 4 * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sequence"));
|
||||
}
|
||||
|
||||
//Number of outputs
|
||||
@@ -97,10 +141,10 @@ public class TransactionHexArea extends CodeArea {
|
||||
//Outputs
|
||||
for(int i = 0; i < transaction.getOutputs().size(); i++) {
|
||||
TransactionOutput output = transaction.getOutputs().get(i);
|
||||
cursor = addSegment(segments, cursor, 8 * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "value"));
|
||||
cursor = addSegment(segments, cursor, 8 * 2, i, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "value"));
|
||||
VarInt scriptLen = new VarInt(output.getScriptBytes().length);
|
||||
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript-length"));
|
||||
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript"));
|
||||
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, i, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript-length"));
|
||||
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, i, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript"));
|
||||
}
|
||||
|
||||
if(transaction.hasWitnesses()) {
|
||||
@@ -109,11 +153,12 @@ public class TransactionHexArea extends CodeArea {
|
||||
if (input.hasWitness()) {
|
||||
TransactionWitness witness = input.getWitness();
|
||||
VarInt witnessCount = new VarInt(witness.getPushCount());
|
||||
cursor = addSegment(segments, cursor, witnessCount.getSizeInBytes() * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "count"));
|
||||
for (byte[] push : witness.getPushes()) {
|
||||
cursor = addSegment(segments, cursor, witnessCount.getSizeInBytes() * 2, i, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "count"));
|
||||
for(int j = 0; j < witness.getPushes().size(); j++) {
|
||||
byte[] push = witness.getPushes().get(j);
|
||||
VarInt witnessLen = new VarInt(push.length);
|
||||
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
|
||||
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
|
||||
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
|
||||
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +176,15 @@ public class TransactionHexArea extends CodeArea {
|
||||
}
|
||||
|
||||
private int addSegment(List<TransactionSegment> segments, int start, int length, String style) {
|
||||
segments.add(new TransactionSegment(start, length, style));
|
||||
return addSegment(segments, start, length, null, style);
|
||||
}
|
||||
|
||||
private int addSegment(List<TransactionSegment> segments, int start, int length, Integer index, String style) {
|
||||
return addSegment(segments, start, length, index, null, style);
|
||||
}
|
||||
|
||||
private int addSegment(List<TransactionSegment> segments, int start, int length, Integer index, Integer witnessIndex, String style) {
|
||||
segments.add(new TransactionSegment(start, length, index, witnessIndex, style));
|
||||
return start + length;
|
||||
}
|
||||
|
||||
@@ -143,15 +196,59 @@ public class TransactionHexArea extends CodeArea {
|
||||
return "other";
|
||||
}
|
||||
|
||||
private String describeTransactionPart(Collection<String> styles) {
|
||||
String style = "";
|
||||
Integer index = null;
|
||||
Integer witnessIndex = null;
|
||||
Iterator<String> iter = styles.iterator();
|
||||
if(iter.hasNext()) {
|
||||
style = iter.next();
|
||||
}
|
||||
while(iter.hasNext()) {
|
||||
String indexStyle = iter.next();
|
||||
if(indexStyle.startsWith("index-")) {
|
||||
index = Integer.parseInt(indexStyle.substring("index-".length()));
|
||||
}
|
||||
if(indexStyle.startsWith("witnessindex-")) {
|
||||
witnessIndex = Integer.parseInt(indexStyle.substring("witnessindex-".length()));
|
||||
}
|
||||
}
|
||||
|
||||
return switch(style) {
|
||||
case "version" -> "Transaction version";
|
||||
case "segwit-marker" -> "Segwit marker";
|
||||
case "segwit-flag" -> "Segwit flag";
|
||||
case "num-inputs" -> "Number of inputs";
|
||||
case "input-hash" -> "Input #" + index + " outpoint txid";
|
||||
case "input-index" -> "Input #" + index + " outpoint index";
|
||||
case "input-sigscript-length" -> "Input #" + index + " scriptSig length";
|
||||
case "input-sigscript" -> "Input #" + index + " scriptSig";
|
||||
case "input-sequence" -> "Input #" + index + " sequence";
|
||||
case "num-outputs" -> "Number of outputs";
|
||||
case "output-value" -> "Output #" + index + " value";
|
||||
case "output-pubkeyscript-length" -> "Output #" + index + " scriptPubKey length";
|
||||
case "output-pubkeyscript" -> "Output #" + index + " scriptPubKey";
|
||||
case "witness-count" -> "Input #" + index + " witness count";
|
||||
case "witness-length" -> "Input #" + index + " witness #" + witnessIndex + " length";
|
||||
case "witness-data" -> "Input #" + index + " witness #" + witnessIndex + " data";
|
||||
case "locktime" -> "Locktime";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
private static class TransactionSegment {
|
||||
public TransactionSegment(int start, int length, String style) {
|
||||
public TransactionSegment(int start, int length, Integer index, Integer witnessIndex, String style) {
|
||||
this.start = start;
|
||||
this.length = length;
|
||||
this.index = index;
|
||||
this.witnessIndex = witnessIndex;
|
||||
this.style = style;
|
||||
}
|
||||
|
||||
public int start;
|
||||
public int length;
|
||||
public Integer index;
|
||||
public Integer witnessIndex;
|
||||
public String style;
|
||||
|
||||
@Override
|
||||
|
||||
@@ -63,5 +63,6 @@ public class TransactionIdDialog extends Dialog<Sha256Hash> {
|
||||
txid.setPromptText("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16");
|
||||
Platform.runLater(txid::requestFocus);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? Sha256Hash.wrap(txid.getText()) : null);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BaseMultiResolutionImage;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TrayManager {
|
||||
private static final Logger log = LoggerFactory.getLogger(TrayManager.class);
|
||||
|
||||
private final SystemTray tray;
|
||||
private final TrayIcon trayIcon;
|
||||
private final PopupMenu popupMenu = new PopupMenu();
|
||||
|
||||
public TrayManager() {
|
||||
if(!SystemTray.isSupported()) {
|
||||
throw new UnsupportedOperationException("SystemTray icons are not supported by the current desktop environment.");
|
||||
}
|
||||
|
||||
tray = SystemTray.getSystemTray();
|
||||
|
||||
try {
|
||||
List<Image> imgList = new ArrayList<>();
|
||||
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
|
||||
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-black-small.png")));
|
||||
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-black-small@2x.png")));
|
||||
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-black-small@3x.png")));
|
||||
} else {
|
||||
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-white-small.png")));
|
||||
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-white-small@2x.png")));
|
||||
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-white-small@3x.png")));
|
||||
}
|
||||
|
||||
BaseMultiResolutionImage mrImage = new BaseMultiResolutionImage(imgList.toArray(new Image[0]));
|
||||
|
||||
this.trayIcon = new TrayIcon(mrImage, "Sparrow", popupMenu);
|
||||
|
||||
MenuItem miExit = new MenuItem("Quit Sparrow");
|
||||
miExit.addActionListener(e -> {
|
||||
SwingUtilities.invokeLater(() -> { tray.remove(this.trayIcon); });
|
||||
Platform.exit();
|
||||
});
|
||||
this.popupMenu.add(miExit);
|
||||
} catch(IOException e) {
|
||||
log.error("Could not load system tray image", e);
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void addStage(Stage stage) {
|
||||
EventQueue.invokeLater(() -> {
|
||||
MenuItem miStage = new MenuItem(stage.getTitle());
|
||||
miStage.setFont(Font.decode(null).deriveFont(Font.BOLD));
|
||||
miStage.addActionListener(e -> Platform.runLater(() -> {
|
||||
stage.show();
|
||||
EventQueue.invokeLater(() -> {
|
||||
popupMenu.remove(miStage);
|
||||
|
||||
if(popupMenu.getItemCount() == 1) {
|
||||
Platform.setImplicitExit(true);
|
||||
SwingUtilities.invokeLater(() -> tray.remove(trayIcon));
|
||||
}
|
||||
});
|
||||
}));
|
||||
//Make sure it's always at the top
|
||||
this.popupMenu.insert(miStage,popupMenu.getItemCount() - 1);
|
||||
|
||||
if(!isShowing()) {
|
||||
// Keeps the JVM running even if there are no
|
||||
// visible JavaFX Stages, otherwise JVM would
|
||||
// exit and we lose the TrayIcon
|
||||
Platform.setImplicitExit(false);
|
||||
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
tray.add(this.trayIcon);
|
||||
} catch(AWTException e) {
|
||||
log.error("Unable to add system tray icon", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean isShowing() {
|
||||
return Arrays.stream(tray.getTrayIcons()).collect(Collectors.toList()).contains(trayIcon);
|
||||
}
|
||||
|
||||
public static boolean isSupported() {
|
||||
return Desktop.isDesktopSupported() && SystemTray.isSupported();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.io.Hwi;
|
||||
import javafx.geometry.Side;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
@@ -22,7 +27,23 @@ public class UsbStatusButton extends MenuButton {
|
||||
public void setDevices(List<Device> devices) {
|
||||
for(Device device : devices) {
|
||||
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
|
||||
if(device.getNeedsPinSent()) {
|
||||
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.KEEPKEY)) {
|
||||
deviceItem = new Menu(device.getModel().toDisplayString());
|
||||
MenuItem toggleItem = new MenuItem("Toggle Passphrase " + (device.isNeedsPassphraseSent() ? "Off" : "On"));
|
||||
toggleItem.setOnAction(event -> {
|
||||
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
|
||||
togglePassphraseService.setOnSucceeded(event1 -> {
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
});
|
||||
togglePassphraseService.setOnFailed(event1 -> {
|
||||
AppServices.showErrorDialog("Error toggling passphrase", event1.getSource().getException().getMessage());
|
||||
});
|
||||
togglePassphraseService.start();
|
||||
});
|
||||
((Menu)deviceItem).getItems().add(toggleItem);
|
||||
}
|
||||
|
||||
if(device.isNeedsPinSent()) {
|
||||
deviceItem.setGraphic(getLockIcon());
|
||||
} else {
|
||||
deviceItem.setGraphic(getLockOpenIcon());
|
||||
|
||||
@@ -72,7 +72,7 @@ public class UtxosChart extends BarChart<String, Number> {
|
||||
|
||||
private String getCategoryName(Entry entry) {
|
||||
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
|
||||
return entry.getLabel().length() > 15 ? entry.getLabel().substring(0, 15) + "..." : entry.getLabel();
|
||||
return entry.getLabel().length() > 15 ? entry.getLabel().substring(0, 15) + "..." : entry.getLabel() + "\n" + ((UtxoEntry)entry).getDescription();
|
||||
}
|
||||
|
||||
return ((UtxoEntry)entry).getDescription();
|
||||
|
||||
@@ -31,6 +31,7 @@ public class WalletBirthDateDialog extends Dialog<Date> {
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(420);
|
||||
dialogPane.setPrefHeight(200);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HISTORY);
|
||||
wallet.setFontSize(50);
|
||||
|
||||
@@ -41,9 +41,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
|
||||
List<WalletExport> exporters;
|
||||
if(wallet.getPolicyType() == PolicyType.SINGLE) {
|
||||
exporters = List.of(new Electrum(), new SpecterDesktop());
|
||||
exporters = List.of(new Electrum(), new SpecterDesktop(), new Sparrow());
|
||||
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
|
||||
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig());
|
||||
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
|
||||
}
|
||||
@@ -59,6 +59,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(480);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,24 @@ import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.UsbDeviceEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletImportEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WalletImportDialog extends Dialog<Wallet> {
|
||||
private Wallet wallet;
|
||||
private final Accordion importAccordion;
|
||||
private final Button scanButton;
|
||||
|
||||
public WalletImportDialog() {
|
||||
EventManager.get().register(this);
|
||||
@@ -38,7 +46,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
Accordion importAccordion = new Accordion();
|
||||
importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
@@ -53,9 +61,19 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
scrollPane.setContent(importAccordion);
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan for Connected Devices", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().addAll(scanButtonType, cancelButtonType);
|
||||
|
||||
scanButton = (Button) dialogPane.lookupButton(scanButtonType);
|
||||
scanButton.setGraphic(new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB));
|
||||
scanButton.addEventFilter(ActionEvent.ACTION, event -> {
|
||||
scan();
|
||||
event.consume();
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(500);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
}
|
||||
@@ -65,4 +83,26 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
wallet = event.getWallet();
|
||||
setResult(wallet);
|
||||
}
|
||||
|
||||
private void scan() {
|
||||
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
|
||||
enumerateService.setOnSucceeded(workerStateEvent -> {
|
||||
scanButton.setGraphic(new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB));
|
||||
scanButton.setTooltip(null);
|
||||
List<Device> devices = enumerateService.getValue();
|
||||
importAccordion.getPanes().removeIf(titledPane -> titledPane instanceof DevicePane);
|
||||
for(Device device : devices) {
|
||||
DevicePane devicePane = new DevicePane(new Wallet(), device);
|
||||
importAccordion.getPanes().add(0, devicePane);
|
||||
}
|
||||
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
||||
glyph.getStyleClass().add("failure");
|
||||
scanButton.setGraphic(glyph);
|
||||
scanButton.setTooltip(new Tooltip(workerStateEvent.getSource().getException().getMessage()));
|
||||
});
|
||||
enumerateService.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(460);
|
||||
dialogPane.setPrefHeight(requestBirthDate ? 250 : 200);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
|
||||
wallet.setFontSize(50);
|
||||
|
||||
@@ -22,17 +22,20 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
private final CustomPasswordField passwordConfirm;
|
||||
private final CheckBox backupExisting;
|
||||
private final CheckBox changePassword;
|
||||
|
||||
public WalletPasswordDialog(PasswordRequirement requirement) {
|
||||
this(null, requirement);
|
||||
}
|
||||
private final CheckBox deleteBackups;
|
||||
private boolean addingPassword;
|
||||
|
||||
public WalletPasswordDialog(String walletName, PasswordRequirement requirement) {
|
||||
this(walletName, requirement, false);
|
||||
}
|
||||
|
||||
public WalletPasswordDialog(String walletName, PasswordRequirement requirement, boolean suggestChangePassword) {
|
||||
this.requirement = requirement;
|
||||
this.password = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
this.passwordConfirm = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
this.backupExisting = new CheckBox("Backup existing wallet first");
|
||||
this.changePassword = new CheckBox("Change password");
|
||||
this.deleteBackups = new CheckBox("Delete any backups");
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
setTitle("Wallet Password" + (walletName != null ? " - " + walletName : ""));
|
||||
@@ -42,6 +45,7 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(380);
|
||||
dialogPane.setPrefHeight(260);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
|
||||
lock.setFontSize(50);
|
||||
@@ -52,13 +56,21 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
content.getChildren().add(password);
|
||||
content.getChildren().add(passwordConfirm);
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_SET) {
|
||||
content.getChildren().add(backupExisting);
|
||||
backupExisting.setSelected(true);
|
||||
}
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_SET) {
|
||||
content.getChildren().add(changePassword);
|
||||
changePassword.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
backupExisting.setVisible(!newValue);
|
||||
});
|
||||
changePassword.setSelected(suggestChangePassword);
|
||||
}
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_SET) {
|
||||
backupExisting.managedProperty().bind(backupExisting.visibleProperty());
|
||||
deleteBackups.managedProperty().bind(deleteBackups.visibleProperty());
|
||||
deleteBackups.visibleProperty().bind(backupExisting.visibleProperty().not());
|
||||
content.getChildren().addAll(backupExisting, deleteBackups);
|
||||
backupExisting.setSelected(true);
|
||||
deleteBackups.setSelected(true);
|
||||
}
|
||||
|
||||
dialogPane.setContent(content);
|
||||
@@ -87,10 +99,14 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
okButton.setText("No Password");
|
||||
passwordConfirm.setVisible(false);
|
||||
passwordConfirm.setManaged(false);
|
||||
backupExisting.setVisible(true);
|
||||
addingPassword = false;
|
||||
} else {
|
||||
okButton.setText("Set Password");
|
||||
passwordConfirm.setVisible(true);
|
||||
passwordConfirm.setManaged(true);
|
||||
backupExisting.setVisible(false);
|
||||
addingPassword = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -103,19 +119,23 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
}
|
||||
|
||||
public boolean isBackupExisting() {
|
||||
return backupExisting.isSelected();
|
||||
return !(addingPassword || isChangePassword()) && backupExisting.isSelected();
|
||||
}
|
||||
|
||||
public boolean isChangePassword() {
|
||||
return changePassword.isSelected();
|
||||
}
|
||||
|
||||
public boolean isDeleteBackups() {
|
||||
return (addingPassword || isChangePassword()) && deleteBackups.isSelected();
|
||||
}
|
||||
|
||||
public enum PasswordRequirement {
|
||||
LOAD("Please enter the wallet password:", "Unlock"),
|
||||
UPDATE_NEW("Add a password to the wallet?\nLeave empty for none:", "No Password"),
|
||||
UPDATE_EMPTY("This wallet has no password.\nAdd a password to the wallet?\nLeave empty for none:", "No Password"),
|
||||
UPDATE_NEW("Add a password to the wallet?\nLeave empty for no password:", "No Password"),
|
||||
UPDATE_EMPTY("This wallet has no password.\nAdd a password to the wallet?\nLeave empty for no password:", "No Password"),
|
||||
UPDATE_SET("Please re-enter the wallet password:", "Verify Password"),
|
||||
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for none:", "No Password");
|
||||
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for no password:", "No Password");
|
||||
|
||||
private final String description;
|
||||
private final String okButtonText;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.github.sarxos.webcam.*;
|
||||
import com.github.sarxos.webcam.ds.buildin.natives.Device;
|
||||
import com.github.sarxos.webcam.ds.buildin.natives.DeviceList;
|
||||
import com.github.sarxos.webcam.ds.buildin.natives.OpenIMAJGrabber;
|
||||
import org.bridj.Pointer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.color.ColorSpace;
|
||||
import java.awt.image.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Hashtable;
|
||||
import java.util.Iterator;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class WebcamScanDevice implements WebcamDevice, WebcamDevice.BufferAccess, Runnable, WebcamDevice.FPSSource {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WebcamScanDevice.class);
|
||||
private static final int DEVICE_BUFFER_SIZE = 5;
|
||||
private static final Dimension[] DIMENSIONS;
|
||||
private static final int[] BAND_OFFSETS;
|
||||
private static final int[] BITS;
|
||||
private static final int[] OFFSET;
|
||||
private static final int DATA_TYPE = 0;
|
||||
private static final ColorSpace COLOR_SPACE;
|
||||
public static final int SCAN_LOOP_WAIT_MILLIS = 100;
|
||||
private int timeout = 5000;
|
||||
private OpenIMAJGrabber grabber = null;
|
||||
private Device device = null;
|
||||
private Dimension size = null;
|
||||
private ComponentSampleModel smodel = null;
|
||||
private ColorModel cmodel = null;
|
||||
private boolean failOnSizeMismatch = false;
|
||||
private final AtomicBoolean disposed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean open = new AtomicBoolean(false);
|
||||
private final AtomicBoolean fresh = new AtomicBoolean(false);
|
||||
private Thread refresher = null;
|
||||
private String name = null;
|
||||
private String id = null;
|
||||
private String fullname = null;
|
||||
private long t1 = -1L;
|
||||
private long t2 = -1L;
|
||||
private volatile double fps = 0.0D;
|
||||
|
||||
protected WebcamScanDevice(Device device) {
|
||||
this.device = device;
|
||||
this.name = device.getNameStr();
|
||||
this.id = device.getIdentifierStr();
|
||||
this.fullname = String.format("%s %s", this.name, this.id);
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return this.fullname;
|
||||
}
|
||||
|
||||
public String getDeviceName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public Device getDeviceRef() {
|
||||
return this.device;
|
||||
}
|
||||
|
||||
public Dimension[] getResolutions() {
|
||||
return DIMENSIONS;
|
||||
}
|
||||
|
||||
public Dimension getResolution() {
|
||||
if (this.size == null) {
|
||||
this.size = this.getResolutions()[0];
|
||||
}
|
||||
|
||||
return this.size;
|
||||
}
|
||||
|
||||
public void setResolution(Dimension size) {
|
||||
if (size == null) {
|
||||
throw new IllegalArgumentException("Size cannot be null");
|
||||
} else if (this.open.get()) {
|
||||
throw new IllegalStateException("Cannot change resolution when webcam is open, please close it first");
|
||||
} else {
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
public ByteBuffer getImageBytes() {
|
||||
if (this.disposed.get()) {
|
||||
LOG.debug("Webcam is disposed, image will be null");
|
||||
return null;
|
||||
} else if (!this.open.get()) {
|
||||
LOG.debug("Webcam is closed, image will be null");
|
||||
return null;
|
||||
} else {
|
||||
if (this.fresh.compareAndSet(false, true)) {
|
||||
this.updateFrameBuffer();
|
||||
}
|
||||
|
||||
LOG.trace("Webcam grabber get image pointer");
|
||||
Pointer<Byte> image = this.grabber.getImage();
|
||||
this.fresh.set(false);
|
||||
if (image == null) {
|
||||
LOG.warn("Null array pointer found instead of image");
|
||||
return null;
|
||||
} else {
|
||||
int length = this.size.width * this.size.height * 3;
|
||||
LOG.trace("Webcam device get buffer, read {} bytes", length);
|
||||
return image.getByteBuffer((long)length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void getImageBytes(ByteBuffer target) {
|
||||
if (this.disposed.get()) {
|
||||
LOG.debug("Webcam is disposed, image will be null");
|
||||
} else if (!this.open.get()) {
|
||||
LOG.debug("Webcam is closed, image will be null");
|
||||
} else {
|
||||
int minSize = this.size.width * this.size.height * 3;
|
||||
int curSize = target.remaining();
|
||||
if (minSize > curSize) {
|
||||
throw new IllegalArgumentException(String.format("Not enough remaining space in target buffer (%d necessary vs %d remaining)", minSize, curSize));
|
||||
} else {
|
||||
if (this.fresh.compareAndSet(false, true)) {
|
||||
this.updateFrameBuffer();
|
||||
}
|
||||
|
||||
LOG.trace("Webcam grabber get image pointer");
|
||||
Pointer<Byte> image = this.grabber.getImage();
|
||||
this.fresh.set(false);
|
||||
if (image == null) {
|
||||
LOG.warn("Null array pointer found instead of image");
|
||||
} else {
|
||||
LOG.trace("Webcam device read buffer {} bytes", minSize);
|
||||
image = image.validBytes((long)minSize);
|
||||
image.getBytes(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BufferedImage getImage() {
|
||||
ByteBuffer buffer = this.getImageBytes();
|
||||
if (buffer == null) {
|
||||
LOG.error("Images bytes buffer is null!");
|
||||
return null;
|
||||
} else {
|
||||
byte[] bytes = new byte[this.size.width * this.size.height * 3];
|
||||
byte[][] data = new byte[][]{bytes};
|
||||
buffer.get(bytes);
|
||||
DataBufferByte dbuf = new DataBufferByte(data, bytes.length, OFFSET);
|
||||
WritableRaster raster = Raster.createWritableRaster(this.smodel, dbuf, (Point)null);
|
||||
BufferedImage bi = new BufferedImage(this.cmodel, raster, false, (Hashtable)null);
|
||||
bi.flush();
|
||||
return bi;
|
||||
}
|
||||
}
|
||||
|
||||
public void open() {
|
||||
if (!this.disposed.get()) {
|
||||
LOG.debug("Opening webcam device {}", this.getName());
|
||||
if (this.size == null) {
|
||||
this.size = this.getResolutions()[0];
|
||||
}
|
||||
|
||||
if (this.size == null) {
|
||||
throw new RuntimeException("The resolution size cannot be null");
|
||||
} else {
|
||||
LOG.debug("Webcam device {} starting session, size {}", this.device.getIdentifierStr(), this.size);
|
||||
this.grabber = new OpenIMAJGrabber();
|
||||
DeviceList list = (DeviceList)this.grabber.getVideoDevices().get();
|
||||
Iterator var2 = list.asArrayList().iterator();
|
||||
|
||||
while(var2.hasNext()) {
|
||||
Device d = (Device)var2.next();
|
||||
d.getNameStr();
|
||||
d.getIdentifierStr();
|
||||
}
|
||||
|
||||
boolean started = this.grabber.startSession(this.size.width, this.size.height, 50, Pointer.pointerTo(this.device));
|
||||
if (!started) {
|
||||
throw new WebcamException("Cannot start native grabber!");
|
||||
} else {
|
||||
this.grabber.setTimeout(this.timeout);
|
||||
LOG.debug("Webcam device session started");
|
||||
Dimension size2 = new Dimension(this.grabber.getWidth(), this.grabber.getHeight());
|
||||
int w1 = this.size.width;
|
||||
int w2 = size2.width;
|
||||
int h1 = this.size.height;
|
||||
int h2 = size2.height;
|
||||
if (w1 != w2 || h1 != h2) {
|
||||
if (this.failOnSizeMismatch) {
|
||||
throw new WebcamException(String.format("Different size obtained vs requested - [%dx%d] vs [%dx%d]", w1, h1, w2, h2));
|
||||
}
|
||||
|
||||
Object[] args = new Object[]{w1, h1, w2, h2, w2, h2};
|
||||
LOG.warn("Different size obtained vs requested - [{}x{}] vs [{}x{}]. Setting correct one. New size is [{}x{}]", args);
|
||||
this.size = new Dimension(w2, h2);
|
||||
}
|
||||
|
||||
this.smodel = new ComponentSampleModel(0, this.size.width, this.size.height, 3, this.size.width * 3, BAND_OFFSETS);
|
||||
this.cmodel = new ComponentColorModel(COLOR_SPACE, BITS, false, false, 1, 0);
|
||||
LOG.debug("Clear memory buffer");
|
||||
this.clearMemoryBuffer();
|
||||
LOG.debug("Webcam device {} is now open", this);
|
||||
this.open.set(true);
|
||||
this.refresher = this.startFramesRefresher();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void clearMemoryBuffer() {
|
||||
for(int i = 0; i < 5; ++i) {
|
||||
this.grabber.nextFrame();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private Thread startFramesRefresher() {
|
||||
Thread refresher = new Thread(this, String.format("frames-refresher-[%s]", this.id));
|
||||
refresher.setUncaughtExceptionHandler(WebcamExceptionHandler.getInstance());
|
||||
refresher.setDaemon(true);
|
||||
refresher.start();
|
||||
return refresher;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (this.open.compareAndSet(true, false)) {
|
||||
LOG.debug("Closing webcam device");
|
||||
this.grabber.stopSession();
|
||||
}
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
if (this.disposed.compareAndSet(false, true)) {
|
||||
LOG.debug("Disposing webcam device {}", this.getName());
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
public void setFailOnSizeMismatch(boolean fail) {
|
||||
this.failOnSizeMismatch = fail;
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return this.open.get();
|
||||
}
|
||||
|
||||
public int getTimeout() {
|
||||
return this.timeout;
|
||||
}
|
||||
|
||||
public void setTimeout(int timeout) {
|
||||
if (this.isOpen()) {
|
||||
throw new WebcamException("Timeout must be set before webcam is open");
|
||||
} else {
|
||||
this.timeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFrameBuffer() {
|
||||
LOG.trace("Next frame");
|
||||
if (this.t1 == -1L || this.t2 == -1L) {
|
||||
this.t1 = System.currentTimeMillis();
|
||||
this.t2 = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
int result = (new WebcamScanDevice.NextFrameTask(this)).nextFrame();
|
||||
this.t1 = this.t2;
|
||||
this.t2 = System.currentTimeMillis();
|
||||
this.fps = (4.0D * this.fps + (double)(1000L / (this.t2 - this.t1 + 1L))) / 5.0D;
|
||||
if (result == -1) {
|
||||
LOG.error("Timeout when requesting image!");
|
||||
} else if (result < -1) {
|
||||
LOG.error("Error requesting new frame!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void run() {
|
||||
do {
|
||||
try {
|
||||
Thread.sleep(SCAN_LOOP_WAIT_MILLIS);
|
||||
} catch(InterruptedException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
if (Thread.interrupted()) {
|
||||
LOG.debug("Refresher has been interrupted");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.open.get()) {
|
||||
LOG.debug("Cancelling refresher");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFrameBuffer();
|
||||
} while(this.open.get());
|
||||
|
||||
}
|
||||
|
||||
public double getFPS() {
|
||||
return this.fps;
|
||||
}
|
||||
|
||||
static {
|
||||
DIMENSIONS = new Dimension[]{WebcamResolution.QQVGA.getSize(), WebcamResolution.QVGA.getSize(), WebcamResolution.VGA.getSize()};
|
||||
BAND_OFFSETS = new int[]{0, 1, 2};
|
||||
BITS = new int[]{8, 8, 8};
|
||||
OFFSET = new int[]{0};
|
||||
COLOR_SPACE = ColorSpace.getInstance(1000);
|
||||
}
|
||||
|
||||
private class NextFrameTask extends WebcamTask {
|
||||
private final AtomicInteger result = new AtomicInteger(0);
|
||||
|
||||
public NextFrameTask(WebcamDevice device) {
|
||||
super(device);
|
||||
}
|
||||
|
||||
public int nextFrame() {
|
||||
try {
|
||||
this.process();
|
||||
} catch (InterruptedException var2) {
|
||||
WebcamScanDevice.LOG.debug("Image buffer request interrupted", var2);
|
||||
}
|
||||
|
||||
return this.result.get();
|
||||
}
|
||||
|
||||
protected void handle() {
|
||||
WebcamScanDevice device = (WebcamScanDevice)this.getDevice();
|
||||
if (device.isOpen()) {
|
||||
try {
|
||||
Thread.sleep(SCAN_LOOP_WAIT_MILLIS);
|
||||
} catch(InterruptedException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
this.result.set(WebcamScanDevice.this.grabber.nextFrame());
|
||||
WebcamScanDevice.this.fresh.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.github.sarxos.webcam.WebcamDevice;
|
||||
import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDevice;
|
||||
import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDriver;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class WebcamScanDriver extends WebcamDefaultDriver {
|
||||
@Override
|
||||
public List<WebcamDevice> getDevices() {
|
||||
List<WebcamDevice> devices = super.getDevices();
|
||||
List<WebcamDevice> scanDevices = new ArrayList<>();
|
||||
for(WebcamDevice device : devices) {
|
||||
WebcamDefaultDevice defaultDevice = (WebcamDefaultDevice)device;
|
||||
scanDevices.add(new WebcamScanDevice(defaultDevice.getDeviceRef()));
|
||||
}
|
||||
|
||||
return scanDevices;
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,46 @@ package com.sparrowwallet.sparrow.control;
|
||||
import com.github.sarxos.webcam.Webcam;
|
||||
import com.github.sarxos.webcam.WebcamListener;
|
||||
import com.github.sarxos.webcam.WebcamResolution;
|
||||
import com.github.sarxos.webcam.WebcamUpdater;
|
||||
import com.google.zxing.*;
|
||||
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.scene.image.Image;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class WebcamService extends Service<Image> {
|
||||
public class WebcamService extends ScheduledService<Image> {
|
||||
private WebcamResolution resolution;
|
||||
private final WebcamListener listener;
|
||||
private final WebcamUpdater.DelayCalculator delayCalculator;
|
||||
private final BooleanProperty opening = new SimpleBooleanProperty(false);
|
||||
|
||||
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
public WebcamService(WebcamResolution resolution, WebcamListener listener) {
|
||||
private static final int QR_SAMPLE_PERIOD_MILLIS = 400;
|
||||
|
||||
private Webcam cam;
|
||||
private long lastQrSampleTime;
|
||||
|
||||
static {
|
||||
Webcam.setDriver(new WebcamScanDriver());
|
||||
}
|
||||
|
||||
public WebcamService(WebcamResolution resolution, WebcamListener listener, WebcamUpdater.DelayCalculator delayCalculator) {
|
||||
this.resolution = resolution;
|
||||
this.listener = listener;
|
||||
this.delayCalculator = delayCalculator;
|
||||
this.lastQrSampleTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,31 +50,57 @@ public class WebcamService extends Service<Image> {
|
||||
return new Task<Image>() {
|
||||
@Override
|
||||
protected Image call() throws Exception {
|
||||
Webcam cam = Webcam.getWebcams(1, TimeUnit.MINUTES).get(0);
|
||||
try {
|
||||
cam.setCustomViewSizes(resolution.getSize());
|
||||
cam.setViewSize(resolution.getSize());
|
||||
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
|
||||
cam.addWebcamListener(listener);
|
||||
if(cam == null) {
|
||||
List<Webcam> webcams = Webcam.getWebcams(1, TimeUnit.MINUTES);
|
||||
if(webcams.isEmpty()) {
|
||||
throw new UnsupportedOperationException("No camera available.");
|
||||
}
|
||||
|
||||
cam = webcams.get(0);
|
||||
cam.setCustomViewSizes(resolution.getSize());
|
||||
cam.setViewSize(resolution.getSize());
|
||||
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
|
||||
cam.addWebcamListener(listener);
|
||||
}
|
||||
|
||||
opening.set(true);
|
||||
cam.open(true, delayCalculator);
|
||||
opening.set(false);
|
||||
}
|
||||
|
||||
cam.open();
|
||||
while(!isCancelled()) {
|
||||
if(cam.isImageNew()) {
|
||||
BufferedImage bimg = cam.getImage();
|
||||
updateValue(SwingFXUtils.toFXImage(bimg, null));
|
||||
readQR(bimg);
|
||||
}
|
||||
BufferedImage bimg = cam.getImage();
|
||||
Image image = SwingFXUtils.toFXImage(bimg, null);
|
||||
updateValue(image);
|
||||
|
||||
if(System.currentTimeMillis() > (lastQrSampleTime + QR_SAMPLE_PERIOD_MILLIS)) {
|
||||
readQR(bimg);
|
||||
lastQrSampleTime = System.currentTimeMillis();
|
||||
}
|
||||
cam.close();
|
||||
return getValue();
|
||||
|
||||
return image;
|
||||
} finally {
|
||||
cam.close();
|
||||
opening.set(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset() {
|
||||
cam = null;
|
||||
super.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean cancel() {
|
||||
if(cam != null && !cam.close()) {
|
||||
cam.close();
|
||||
}
|
||||
|
||||
return super.cancel();
|
||||
}
|
||||
|
||||
private void readQR(BufferedImage bufferedImage) {
|
||||
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||
@@ -89,4 +132,12 @@ public class WebcamService extends Service<Image> {
|
||||
public void setResolution(WebcamResolution resolution) {
|
||||
this.resolution = resolution;
|
||||
}
|
||||
|
||||
public boolean isOpening() {
|
||||
return opening.get();
|
||||
}
|
||||
|
||||
public BooleanProperty openingProperty() {
|
||||
return opening;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.slf4j.Logger;
|
||||
@@ -14,7 +17,9 @@ public class WebcamView {
|
||||
private final WebcamService service;
|
||||
private final Region view;
|
||||
|
||||
private final Label statusPlaceholder ;
|
||||
private final Label statusPlaceholder;
|
||||
|
||||
private final ObjectProperty<Image> imageProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
public WebcamView(WebcamService service) {
|
||||
this.service = service ;
|
||||
@@ -23,22 +28,32 @@ public class WebcamView {
|
||||
// make the cam behave like a mirror:
|
||||
imageView.setScaleX(-1);
|
||||
|
||||
service.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue != null) {
|
||||
imageProperty.set(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
this.statusPlaceholder = new Label();
|
||||
this.view = new Region() {
|
||||
{
|
||||
service.stateProperty().addListener((obs, oldState, newState) -> {
|
||||
switch (newState) {
|
||||
case READY:
|
||||
statusPlaceholder.setText("Initializing");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
if(imageProperty.get() == null) {
|
||||
statusPlaceholder.setText("Initializing");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
}
|
||||
break ;
|
||||
case SCHEDULED:
|
||||
statusPlaceholder.setText("Waiting");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
if(imageProperty.get() == null) {
|
||||
statusPlaceholder.setText("Waiting");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
}
|
||||
break ;
|
||||
case RUNNING:
|
||||
imageView.imageProperty().unbind();
|
||||
imageView.imageProperty().bind(service.valueProperty());
|
||||
imageView.imageProperty().bind(imageProperty);
|
||||
getChildren().setAll(imageView);
|
||||
break ;
|
||||
case CANCELLED:
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.MnemonicException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreXprvImport;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class XprvKeystoreImportPane extends TitledDescriptionPane {
|
||||
protected final Wallet wallet;
|
||||
protected final KeystoreXprvImport importer;
|
||||
|
||||
private Button enterXprvButton;
|
||||
private SplitMenuButton importButton;
|
||||
|
||||
private ExtendedKey xprv;
|
||||
|
||||
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer) {
|
||||
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
|
||||
createImportButton();
|
||||
buttonBox.getChildren().add(importButton);
|
||||
}
|
||||
|
||||
public XprvKeystoreImportPane(Keystore keystore) {
|
||||
super("Master Private Key", "BIP32 key", "", "image/" + WalletModel.SEED.getType() + ".png");
|
||||
this.wallet = null;
|
||||
this.importer = null;
|
||||
|
||||
try {
|
||||
this.xprv = keystore.getExtendedMasterPrivateKey();
|
||||
} catch(MnemonicException e) {
|
||||
//can't happen
|
||||
}
|
||||
|
||||
showHideLink.setVisible(false);
|
||||
buttonBox.getChildren().clear();
|
||||
setContent(getXprvEntry(true));
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
enterXprvButton = new Button("Enter Private Key");
|
||||
enterXprvButton.managedProperty().bind(enterXprvButton.visibleProperty());
|
||||
enterXprvButton.setOnAction(event -> {
|
||||
enterXprvButton.setDisable(true);
|
||||
enterXprv();
|
||||
});
|
||||
|
||||
return enterXprvButton;
|
||||
}
|
||||
|
||||
private void createImportButton() {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.getStyleClass().add("default-button");
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation());
|
||||
});
|
||||
String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
}
|
||||
|
||||
importButton.managedProperty().bind(importButton.visibleProperty());
|
||||
importButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void enterXprv() {
|
||||
setDescription("Enter master private key");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getXprvEntry(false));
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
private void importKeystore(List<ChildNumber> derivation) {
|
||||
importButton.setDisable(true);
|
||||
try {
|
||||
Keystore keystore = importer.getKeystore(derivation, xprv);
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
} catch (ImportException e) {
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
importButton.setDisable(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Node getXprvEntry(boolean displayOnly) {
|
||||
TextArea xprvField = new TextArea();
|
||||
xprvField.setPrefRowCount(2);
|
||||
xprvField.setWrapText(true);
|
||||
xprvField.getStyleClass().add("fixed-width");
|
||||
xprvField.setPromptText(ExtendedKey.Header.fromScriptType(ScriptType.P2PKH, true).getName() + (wallet != null ? "/" + ExtendedKey.Header.fromScriptType(wallet.getScriptType(), true).getName() : "") + "...");
|
||||
HBox.setHgrow(xprvField, Priority.ALWAYS);
|
||||
|
||||
if(xprv != null) {
|
||||
xprvField.setText(xprv.toString());
|
||||
}
|
||||
if(displayOnly) {
|
||||
xprvField.setEditable(false);
|
||||
}
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(xprvField, Validator.combine(
|
||||
Validator.createEmptyValidator("xprv is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid private key", !ExtendedKey.isValid(newValue) || ExtendedKey.fromDescriptor(newValue).getKey().isPubKeyOnly())
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
|
||||
Button importXprvButton = new Button("Import");
|
||||
importXprvButton.setMinWidth(80);
|
||||
importXprvButton.setDisable(true);
|
||||
importXprvButton.setOnAction(event -> {
|
||||
enterXprvButton.setVisible(false);
|
||||
importButton.setVisible(true);
|
||||
setDescription("Ready to import");
|
||||
xprv = ExtendedKey.fromDescriptor(xprvField.getText());
|
||||
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
|
||||
});
|
||||
|
||||
xprvField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
importXprvButton.setDisable(newValue.isEmpty() || !ExtendedKey.isValid(newValue) || ExtendedKey.fromDescriptor(newValue).getKey().isPubKeyOnly());
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(xprvField);
|
||||
if(!displayOnly) {
|
||||
contentBox.getChildren().add(importXprvButton);
|
||||
}
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(100);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getDerivationEntry(List<ChildNumber> derivation) {
|
||||
TextField derivationField = new TextField();
|
||||
derivationField.setPromptText("Derivation path");
|
||||
derivationField.setText(KeyDerivation.writePath(derivation));
|
||||
HBox.setHgrow(derivationField, Priority.ALWAYS);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(derivationField, Validator.combine(
|
||||
Validator.createEmptyValidator("Derivation is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
|
||||
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
|
||||
importDerivationButton.setDisable(true);
|
||||
importDerivationButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
|
||||
importKeystore(importDerivation);
|
||||
});
|
||||
|
||||
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
|
||||
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(derivationField);
|
||||
contentBox.getChildren().add(importDerivationButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(60);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class BlockTransactionFetchedEvent extends PagedEvent {
|
||||
public class BlockTransactionFetchedEvent extends TransactionReferencesFinishedEvent {
|
||||
private final Sha256Hash txId;
|
||||
private final BlockTransaction blockTransaction;
|
||||
private final Map<Sha256Hash, BlockTransaction> inputTransactions;
|
||||
|
||||
public BlockTransactionFetchedEvent(Sha256Hash txId, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions) {
|
||||
this(txId, blockTransaction, inputTransactions, 0, blockTransaction.getTransaction().getInputs().size());
|
||||
}
|
||||
|
||||
public BlockTransactionFetchedEvent(Sha256Hash txId, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions, int pageStart, int pageEnd) {
|
||||
super(pageStart, pageEnd);
|
||||
this.txId = txId;
|
||||
this.blockTransaction = blockTransaction;
|
||||
public BlockTransactionFetchedEvent(Transaction transaction, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions, int pageStart, int pageEnd) {
|
||||
super(transaction, blockTransaction, pageStart, pageEnd);
|
||||
this.txId = transaction.getTxId();
|
||||
this.inputTransactions = inputTransactions;
|
||||
}
|
||||
|
||||
@@ -25,10 +20,6 @@ public class BlockTransactionFetchedEvent extends PagedEvent {
|
||||
return txId;
|
||||
}
|
||||
|
||||
public BlockTransaction getBlockTransaction() {
|
||||
return blockTransaction;
|
||||
}
|
||||
|
||||
public Map<Sha256Hash, BlockTransaction> getInputTransactions() {
|
||||
return inputTransactions;
|
||||
}
|
||||
|
||||
+5
-4
@@ -1,17 +1,18 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class BlockTransactionOutputsFetchedEvent extends PagedEvent {
|
||||
public class BlockTransactionOutputsFetchedEvent extends TransactionReferencesEvent {
|
||||
private final Sha256Hash txId;
|
||||
private final List<BlockTransaction> outputTransactions;
|
||||
|
||||
public BlockTransactionOutputsFetchedEvent(Sha256Hash txId, List<BlockTransaction> outputTransactions, int pageStart, int pageEnd) {
|
||||
super(pageStart, pageEnd);
|
||||
this.txId = txId;
|
||||
public BlockTransactionOutputsFetchedEvent(Transaction transaction, List<BlockTransaction> outputTransactions, int pageStart, int pageEnd) {
|
||||
super(transaction, pageStart, pageEnd);
|
||||
this.txId = transaction.getTxId();
|
||||
this.outputTransactions = outputTransactions;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.sparrow.net.TlsServerException;
|
||||
|
||||
public class ConnectionFailedEvent {
|
||||
private final Throwable exception;
|
||||
|
||||
@@ -10,4 +12,26 @@ public class ConnectionFailedEvent {
|
||||
public Throwable getException() {
|
||||
return exception;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
if(exception instanceof TlsServerException) {
|
||||
return exception.getMessage();
|
||||
}
|
||||
|
||||
Throwable cause = (exception.getCause() != null ? exception.getCause() : exception);
|
||||
cause = (cause.getCause() != null ? cause.getCause() : cause);
|
||||
String message = splitCamelCase(cause.getClass().getSimpleName().replace("Exception", "Error"));
|
||||
return message + " (" + cause.getMessage() + ")";
|
||||
}
|
||||
|
||||
static String splitCamelCase(String s) {
|
||||
return s.replaceAll(
|
||||
String.format("%s|%s|%s",
|
||||
"(?<=[A-Z])(?=[A-Z][a-z])",
|
||||
"(?<=[^A-Z])(?=[A-Z])",
|
||||
"(?<=[A-Za-z])(?=[^A-Za-z])"
|
||||
),
|
||||
" "
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
|
||||
|
||||
public class FeeRatesSelectionChangedEvent {
|
||||
private final Wallet wallet;
|
||||
private final FeeRatesSelection feeRatesSelection;
|
||||
|
||||
public FeeRatesSelectionChangedEvent(FeeRatesSelection feeRatesSelection) {
|
||||
public FeeRatesSelectionChangedEvent(Wallet wallet, FeeRatesSelection feeRatesSelection) {
|
||||
this.wallet = wallet;
|
||||
this.feeRatesSelection = feeRatesSelection;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public FeeRatesSelection getFeeRateSelection() {
|
||||
return feeRatesSelection;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
/**
|
||||
* Empty class to indicate the includeMempoolOutputs config setting has changed
|
||||
*/
|
||||
public class IncludeMempoolOutputsChangedEvent {
|
||||
|
||||
}
|
||||
@@ -11,18 +11,18 @@ public class SpendUtxoEvent {
|
||||
private final List<BlockTransactionHashIndex> utxos;
|
||||
private final List<Payment> payments;
|
||||
private final Long fee;
|
||||
private final boolean includeMempoolInputs;
|
||||
private final boolean includeSpentMempoolOutputs;
|
||||
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
|
||||
this(wallet, utxos, null, null, false);
|
||||
}
|
||||
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeMempoolInputs) {
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeSpentMempoolOutputs) {
|
||||
this.wallet = wallet;
|
||||
this.utxos = utxos;
|
||||
this.payments = payments;
|
||||
this.fee = fee;
|
||||
this.includeMempoolInputs = includeMempoolInputs;
|
||||
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
@@ -41,7 +41,7 @@ public class SpendUtxoEvent {
|
||||
return fee;
|
||||
}
|
||||
|
||||
public boolean isIncludeMempoolInputs() {
|
||||
return includeMempoolInputs;
|
||||
public boolean isIncludeSpentMempoolOutputs() {
|
||||
return includeSpentMempoolOutputs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.sparrow.TabData;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tab;
|
||||
|
||||
public class TabEvent {
|
||||
@@ -15,7 +16,7 @@ public class TabEvent {
|
||||
}
|
||||
|
||||
public String getTabName() {
|
||||
return tab.getText();
|
||||
return ((Label)tab.getGraphic()).getText();
|
||||
}
|
||||
|
||||
public TabData getTabData() {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class TorExternalStatusEvent extends TorStatusEvent {
|
||||
public TorExternalStatusEvent() {
|
||||
super("Tor is already running, using external instance...");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.sparrow.net.TorServerAlreadyBoundException;
|
||||
|
||||
public class TorFailedStatusEvent extends TorStatusEvent {
|
||||
private final Throwable exception;
|
||||
|
||||
public TorFailedStatusEvent(Throwable exception) {
|
||||
super("Tor failed to start: " + (exception.getCause() != null ?
|
||||
(exception.getCause().getMessage().contains("Failed to bind") ? exception.getCause().getMessage() + " Is a Tor proxy already running?" : exception.getCause().getMessage() ) :
|
||||
exception.getMessage()));
|
||||
super("Tor failed to start: " + (exception instanceof TorServerAlreadyBoundException ? exception.getCause().getMessage() + " Is a Tor proxy already running?" :
|
||||
(exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage())));
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
|
||||
public class TransactionReferencesEvent extends PagedEvent {
|
||||
private final Transaction transaction;
|
||||
|
||||
public TransactionReferencesEvent(Transaction transaction) {
|
||||
this(transaction, 0, 0);
|
||||
}
|
||||
|
||||
public TransactionReferencesEvent(Transaction transaction, int pageStart, int pageEnd) {
|
||||
super(pageStart, pageEnd);
|
||||
this.transaction = transaction;
|
||||
}
|
||||
|
||||
public Transaction getTransaction() {
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
|
||||
public class TransactionReferencesFailedEvent extends TransactionReferencesEvent {
|
||||
private final Throwable exception;
|
||||
|
||||
public TransactionReferencesFailedEvent(Transaction transaction, Throwable exception) {
|
||||
super(transaction);
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
public TransactionReferencesFailedEvent(Transaction transaction, Throwable exception, int pageStart, int pageEnd) {
|
||||
super(transaction, pageStart, pageEnd);
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
public Throwable getException() {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
|
||||
public class TransactionReferencesFinishedEvent extends TransactionReferencesEvent {
|
||||
private final BlockTransaction blockTransaction;
|
||||
|
||||
public TransactionReferencesFinishedEvent(Transaction transaction, BlockTransaction blockTransaction) {
|
||||
super(transaction);
|
||||
this.blockTransaction = blockTransaction;
|
||||
}
|
||||
|
||||
public TransactionReferencesFinishedEvent(Transaction transaction, BlockTransaction blockTransaction, int pageStart, int pageEnd) {
|
||||
super(transaction, pageStart, pageEnd);
|
||||
this.blockTransaction = blockTransaction;
|
||||
}
|
||||
|
||||
public BlockTransaction getBlockTransaction() {
|
||||
return blockTransaction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
|
||||
public class TransactionReferencesStartedEvent extends TransactionReferencesEvent {
|
||||
public TransactionReferencesStartedEvent(Transaction transaction) {
|
||||
super(transaction);
|
||||
}
|
||||
|
||||
public TransactionReferencesStartedEvent(Transaction transaction, int pageStart, int pageEnd) {
|
||||
super(transaction, pageStart, pageEnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* This event is posted when a wallet's addresses are changed (keystores, policy, script type).
|
||||
* This event marks a fundamental change that is used to update application level UI, clear node entry caches and similar. It should only be subscribed to by application-level classes.
|
||||
* Note that WalletForm does not listen to this event to save the wallet, since the wallet is foreground saved directly in SettingsController before this event is posted.
|
||||
* This is because any failure in saving the wallet must be immediately reported to the user.
|
||||
* Note that all wallet detail controllers that share a WalletForm, and that class posts WalletNodesChangedEvent once it has cleared it's entry caches.
|
||||
*/
|
||||
public class WalletAddressesChangedEvent extends WalletSettingsChangedEvent {
|
||||
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
|
||||
super(wallet, pastWallet, walletFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
public class WalletHistoryFailedEvent extends WalletHistoryStatusEvent {
|
||||
private final Throwable exception;
|
||||
|
||||
public WalletHistoryFailedEvent(Wallet wallet, Throwable exception) {
|
||||
super(wallet, exception.getCause() == null ? exception.getMessage() : exception.getCause().getMessage());
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
public Throwable getException() {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
public class WalletHistoryFinishedEvent extends WalletHistoryStatusEvent {
|
||||
public WalletHistoryFinishedEvent(Wallet wallet) {
|
||||
super(wallet, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
|
||||
public class WalletHistoryStartedEvent extends WalletHistoryStatusEvent {
|
||||
private final WalletNode walletNode;
|
||||
|
||||
public WalletHistoryStartedEvent(Wallet wallet, WalletNode walletNode) {
|
||||
super(wallet, true);
|
||||
this.walletNode = walletNode;
|
||||
}
|
||||
|
||||
public WalletNode getWalletNode() {
|
||||
return walletNode;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public class WalletHistoryStatusEvent {
|
||||
|
||||
public WalletHistoryStatusEvent(Wallet wallet, String errorMessage) {
|
||||
this.wallet = wallet;
|
||||
this.loading = true;
|
||||
this.loading = false;
|
||||
this.statusMessage = null;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* This event is posted when a wallet's settings are changed (keystores, policy, script type).
|
||||
* This event marks a fundamental change that is used to update application level UI, clear node entry caches and similar. It should only be subscribed to by application-level classes.
|
||||
* Note that WalletForm does not listen to this event to save the wallet, since the wallet is foreground saved directly in SettingsController before this event is posted.
|
||||
* This is because any failure in saving the wallet must be immediately reported to the user.
|
||||
* Note that all wallet detail controllers that share a WalletForm, and that class posts WalletNodesChangedEvent once it has cleared it's entry caches.
|
||||
* This event is posted when a wallet's settings are changed in a way that does not update the wallet addresses or history.
|
||||
* For example, changing the keystore source or label will trigger this event, which updates the wallet but avoids a full wallet refresh.
|
||||
* It is impossible for the validity of a wallet to change on this event - listen for WalletAddressesChangedEvent for this
|
||||
*/
|
||||
public class WalletSettingsChangedEvent extends WalletChangedEvent {
|
||||
private final Wallet pastWallet;
|
||||
|
||||
@@ -40,6 +40,7 @@ public class FontAwesome5 extends GlyphFont {
|
||||
LOCK_OPEN('\uf3c1'),
|
||||
PEN_FANCY('\uf5ac'),
|
||||
PLUS('\uf067'),
|
||||
PLUS_CIRCLE('\uf055'),
|
||||
QRCODE('\uf029'),
|
||||
QUESTION_CIRCLE('\uf059'),
|
||||
RANDOM('\uf074'),
|
||||
@@ -47,12 +48,14 @@ public class FontAwesome5 extends GlyphFont {
|
||||
SATELLITE_DISH('\uf7c0'),
|
||||
SD_CARD('\uf7c2'),
|
||||
SEARCH('\uf002'),
|
||||
SIGN_OUT_ALT('\uf2f5'),
|
||||
SQUARE('\uf0c8'),
|
||||
TIMES_CIRCLE('\uf057'),
|
||||
TOGGLE_OFF('\uf204'),
|
||||
TOGGLE_ON('\uf205'),
|
||||
TOOLS('\uf7d9'),
|
||||
UNDO('\uf0e2'),
|
||||
USER_FRIENDS('\uf500'),
|
||||
WALLET('\uf555');
|
||||
|
||||
private final char ch;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class Bip32 implements KeystoreXprvImport {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Master Private Key (BIP32)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.SEED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import an extended master private key (BIP 32 xprv)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(List<ChildNumber> derivation, ExtendedKey xprv) throws ImportException {
|
||||
try {
|
||||
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(xprv.getKey().getPrivKeyBytes(), xprv.getKey().getChainCode());
|
||||
return Keystore.fromMasterPrivateExtendedKey(masterPrivateExtendedKey, derivation);
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -14,6 +16,8 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
||||
private static final Logger log = LoggerFactory.getLogger(CoboVaultSinglesig.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Cobo Vault";
|
||||
@@ -53,7 +57,8 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
||||
|
||||
return keystore;
|
||||
} catch (Exception e) {
|
||||
throw new ImportException(e);
|
||||
log.error("Error getting Cobo Vault keystore", e);
|
||||
throw new ImportException("Error getting Cobo Vault keystore", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -19,6 +21,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class ColdcardMultisig implements WalletImport, KeystoreFileImport, WalletExport {
|
||||
private static final Logger log = LoggerFactory.getLogger(ColdcardMultisig.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Coldcard Multisig";
|
||||
@@ -81,7 +85,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExportFileExtension() {
|
||||
public String getExportFileExtension(Wallet wallet) {
|
||||
return "txt";
|
||||
}
|
||||
|
||||
@@ -146,7 +150,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
|
||||
return wallet;
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
log.error("Error importing Coldcard multisig wallet", e);
|
||||
throw new ImportException("Error importing Coldcard multisig wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +204,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
writer.flush();
|
||||
writer.close();
|
||||
} catch(Exception e) {
|
||||
throw new ExportException(e);
|
||||
log.error("Error exporting Coldcard multisig wallet", e);
|
||||
throw new ExportException("Error exporting Coldcard multisig wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,4 +233,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
public boolean isWalletExportScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -18,6 +20,8 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
|
||||
private static final Logger log = LoggerFactory.getLogger(ColdcardSinglesig.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Coldcard";
|
||||
@@ -82,7 +86,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new ImportException(e);
|
||||
log.error("Error getting Coldcard keystore", e);
|
||||
throw new ImportException("Error getting Coldcard keystore", e);
|
||||
}
|
||||
|
||||
throw new ImportException("Correct derivation not found for script type: " + scriptType);
|
||||
|
||||
@@ -11,8 +11,11 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Arrays;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class Config {
|
||||
private static final Logger log = LoggerFactory.getLogger(Config.class);
|
||||
@@ -25,9 +28,10 @@ public class Config {
|
||||
private FeeRatesSelection feeRatesSelection;
|
||||
private Currency fiatCurrency;
|
||||
private ExchangeSource exchangeSource;
|
||||
private boolean loadRecentWallets = true;
|
||||
private boolean validateDerivationPaths = true;
|
||||
private boolean groupByAddress = true;
|
||||
private boolean includeMempoolChange = true;
|
||||
private boolean includeMempoolOutputs = true;
|
||||
private boolean notifyNewTransactions = true;
|
||||
private boolean checkNewVersions = true;
|
||||
private Theme theme;
|
||||
@@ -37,7 +41,7 @@ public class Config {
|
||||
private List<File> recentWalletFiles;
|
||||
private Integer keyDerivationPeriod;
|
||||
private File hwi;
|
||||
private boolean hdCapture;
|
||||
private Boolean hdCapture;
|
||||
private ServerType serverType;
|
||||
private String publicElectrumServer;
|
||||
private String coreServer;
|
||||
@@ -80,7 +84,7 @@ public class Config {
|
||||
if(config != null) {
|
||||
return config;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
} catch(Exception e) {
|
||||
log.error("Error opening " + configFile.getAbsolutePath(), e);
|
||||
//Ignore and assume no config
|
||||
}
|
||||
@@ -155,6 +159,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isLoadRecentWallets() {
|
||||
return loadRecentWallets;
|
||||
}
|
||||
|
||||
public void setLoadRecentWallets(boolean loadRecentWallets) {
|
||||
this.loadRecentWallets = loadRecentWallets;
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isValidateDerivationPaths() {
|
||||
return validateDerivationPaths;
|
||||
}
|
||||
@@ -173,12 +186,12 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isIncludeMempoolChange() {
|
||||
return includeMempoolChange;
|
||||
public boolean isIncludeMempoolOutputs() {
|
||||
return includeMempoolOutputs;
|
||||
}
|
||||
|
||||
public void setIncludeMempoolChange(boolean includeMempoolChange) {
|
||||
this.includeMempoolChange = includeMempoolChange;
|
||||
public void setIncludeMempoolOutputs(boolean includeMempoolOutputs) {
|
||||
this.includeMempoolOutputs = includeMempoolOutputs;
|
||||
flush();
|
||||
}
|
||||
|
||||
@@ -263,11 +276,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isHdCapture() {
|
||||
public Boolean getHdCapture() {
|
||||
return hdCapture;
|
||||
}
|
||||
|
||||
public void setHdCapture(boolean hdCapture) {
|
||||
public Boolean isHdCapture() {
|
||||
return hdCapture != null && hdCapture;
|
||||
}
|
||||
|
||||
public void setHdCapture(Boolean hdCapture) {
|
||||
this.hdCapture = hdCapture;
|
||||
flush();
|
||||
}
|
||||
@@ -289,8 +306,16 @@ public class Config {
|
||||
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : (getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? getPublicElectrumServer() : getElectrumServer());
|
||||
}
|
||||
|
||||
public boolean requiresInternalTor() {
|
||||
if(isUseProxy()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requiresTor();
|
||||
}
|
||||
|
||||
public boolean requiresTor() {
|
||||
if(isUseProxy() || !hasServerAddress()) {
|
||||
if(!hasServerAddress()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -311,6 +336,11 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public void changePublicServer() {
|
||||
List<String> otherServers = Arrays.stream(PublicElectrumServer.values()).map(PublicElectrumServer::getUrl).filter(url -> !url.equals(getPublicElectrumServer())).collect(Collectors.toList());
|
||||
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
|
||||
}
|
||||
|
||||
public String getCoreServer() {
|
||||
return coreServer;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ public class Device {
|
||||
return needsPinSent;
|
||||
}
|
||||
|
||||
public Boolean isNeedsPinSent() {
|
||||
return needsPinSent != null && needsPinSent;
|
||||
}
|
||||
|
||||
public void setNeedsPinSent(Boolean needsPinSent) {
|
||||
this.needsPinSent = needsPinSent;
|
||||
}
|
||||
@@ -49,6 +53,10 @@ public class Device {
|
||||
return needsPassphraseSent;
|
||||
}
|
||||
|
||||
public Boolean isNeedsPassphraseSent() {
|
||||
return needsPassphraseSent != null && needsPassphraseSent;
|
||||
}
|
||||
|
||||
public void setNeedsPassphraseSent(Boolean needsPassphraseSent) {
|
||||
this.needsPassphraseSent = needsPassphraseSent;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.crypto.*;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
@@ -22,6 +22,8 @@ import java.util.*;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
public class Electrum implements KeystoreFileImport, WalletImport, WalletExport {
|
||||
private static final Logger log = LoggerFactory.getLogger(Electrum.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Electrum";
|
||||
@@ -229,6 +231,22 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(BlockTransaction blkTx : ew.transactions.values()) {
|
||||
if(blkTx.getLabel() == null) {
|
||||
Transaction tx = blkTx.getTransaction();
|
||||
for(TransactionOutput txOutput : tx.getOutputs()) {
|
||||
try {
|
||||
Address[] addresses = txOutput.getScript().getToAddresses();
|
||||
if(Arrays.asList(addresses).contains(address)) {
|
||||
blkTx.setLabel(ew.labels.get(key));
|
||||
}
|
||||
} catch(NonStandardScriptException ex) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(Exception ex) {
|
||||
//not an address
|
||||
}
|
||||
@@ -246,7 +264,8 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
|
||||
return wallet;
|
||||
} catch (Exception e) {
|
||||
throw new ImportException(e);
|
||||
log.error("Error importing Electrum Wallet", e);
|
||||
throw new ImportException("Error importing Electrum Wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +292,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExportFileExtension() {
|
||||
public String getExportFileExtension(Wallet wallet) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
@@ -309,11 +328,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
ek.xpub = keystore.getExtendedPublicKey().toString(xpubHeader);
|
||||
ek.xprv = keystore.getExtendedPrivateKey().toString(xprvHeader);
|
||||
ek.pw_hash_version = 1;
|
||||
if(keystore.getSeed().getType() == DeterministicSeed.Type.ELECTRUM) {
|
||||
if(keystore.getSeed() == null || keystore.getSeed().getType() == DeterministicSeed.Type.BIP39) {
|
||||
ew.seed_type = "bip39";
|
||||
} else if(keystore.getSeed().getType() == DeterministicSeed.Type.ELECTRUM) {
|
||||
ek.seed = keystore.getSeed().getMnemonicString().asString();
|
||||
ek.passphrase = keystore.getSeed().getPassphrase() == null ? null : keystore.getSeed().getPassphrase().asString();
|
||||
} else if(keystore.getSeed().getType() == DeterministicSeed.Type.BIP39) {
|
||||
ew.seed_type = "bip39";
|
||||
}
|
||||
ew.use_encryption = false;
|
||||
} else if(keystore.getSource() == KeystoreSource.SW_WATCH) {
|
||||
@@ -350,7 +369,8 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
outputStream.flush();
|
||||
outputStream.close();
|
||||
} catch (Exception e) {
|
||||
throw new ExportException(e);
|
||||
log.error("Error exporting Electrum Wallet", e);
|
||||
throw new ExportException("Error exporting Electrum Wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +399,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
return "Export this wallet as an Electrum wallet file.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static class ElectrumJsonWallet {
|
||||
public Map<String, ElectrumKeystore> keystores = new LinkedHashMap<>();
|
||||
public String wallet_type;
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.google.common.io.ByteStreams;
|
||||
import com.google.common.io.CharStreams;
|
||||
import com.google.gson.*;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
@@ -29,7 +30,7 @@ import java.util.zip.ZipInputStream;
|
||||
|
||||
public class Hwi {
|
||||
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
|
||||
private static final String TEMP_FILE_PREFIX = "hwi-1.2.1-";
|
||||
private static final String VERSION_PREFIX = "hwi-2.0.1";
|
||||
|
||||
private static boolean isPromptActive = false;
|
||||
|
||||
@@ -42,11 +43,18 @@ public class Hwi {
|
||||
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), Command.ENUMERATE.toString());
|
||||
}
|
||||
|
||||
isPromptActive = true;
|
||||
String output = execute(command);
|
||||
Device[] devices = getGson().fromJson(output, Device[].class);
|
||||
if(devices == null) {
|
||||
throw new ImportException("Error scanning, check devices are ready");
|
||||
}
|
||||
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
|
||||
} catch(IOException e) {
|
||||
log.error("Error executing " + VERSION_PREFIX, e);
|
||||
throw new ImportException(e);
|
||||
} finally {
|
||||
isPromptActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +78,16 @@ public class Hwi {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean togglePassphrase(Device device) throws ImportException {
|
||||
try {
|
||||
String output = execute(getDeviceCommand(device, Command.TOGGLE_PASSPHRASE));
|
||||
isPromptActive = false;
|
||||
return wasSuccessful(output);
|
||||
} catch(IOException e) {
|
||||
throw new ImportException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
|
||||
try {
|
||||
String output;
|
||||
@@ -84,32 +102,32 @@ public class Hwi {
|
||||
if(result.get("xpub") != null) {
|
||||
return result.get("xpub").getAsString();
|
||||
} else {
|
||||
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
|
||||
JsonElement error = result.get("error");
|
||||
if(error != null) {
|
||||
throw new ImportException(error.getAsString());
|
||||
} else {
|
||||
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
|
||||
}
|
||||
}
|
||||
} catch(IOException e) {
|
||||
throw new ImportException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String displayAddress(Device device, String passphrase, ScriptType scriptType, String derivationPath) throws DisplayAddressException {
|
||||
public String displayAddress(Device device, String passphrase, ScriptType scriptType, OutputDescriptor outputDescriptor) throws DisplayAddressException {
|
||||
try {
|
||||
if(!List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH).contains(scriptType)) {
|
||||
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only single sig types supported");
|
||||
if(!Arrays.asList(ScriptType.SINGLE_HASH_TYPES).contains(scriptType)) {
|
||||
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only single hash types supported");
|
||||
}
|
||||
|
||||
String type = null;
|
||||
if(scriptType == ScriptType.P2SH_P2WPKH) {
|
||||
type = "--sh_wpkh";
|
||||
} else if(scriptType == ScriptType.P2WPKH) {
|
||||
type = "--wpkh";
|
||||
}
|
||||
String descriptor = outputDescriptor.toString();
|
||||
|
||||
isPromptActive = false;
|
||||
isPromptActive = true;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--desc", descriptor));
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
|
||||
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--desc", descriptor));
|
||||
}
|
||||
|
||||
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
|
||||
@@ -125,12 +143,14 @@ public class Hwi {
|
||||
}
|
||||
} catch(IOException e) {
|
||||
throw new DisplayAddressException(e);
|
||||
} finally {
|
||||
isPromptActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
public String signMessage(Device device, String passphrase, String message, String derivationPath) throws SignMessageException {
|
||||
try {
|
||||
isPromptActive = false;
|
||||
isPromptActive = true;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_MESSAGE, message, derivationPath));
|
||||
@@ -151,6 +171,8 @@ public class Hwi {
|
||||
}
|
||||
} catch(IOException e) {
|
||||
throw new SignMessageException(e);
|
||||
} finally {
|
||||
isPromptActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +187,6 @@ public class Hwi {
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.SIGN_TX, psbtBase64));
|
||||
}
|
||||
isPromptActive = false;
|
||||
|
||||
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
|
||||
if(result.get("psbt") != null) {
|
||||
@@ -187,6 +208,8 @@ public class Hwi {
|
||||
throw new SignTransactionException("Could not sign PSBT", e);
|
||||
} catch(PSBTParseException e) {
|
||||
throw new SignTransactionException("Could not parsed signed PSBT", e);
|
||||
} finally {
|
||||
isPromptActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +222,9 @@ public class Hwi {
|
||||
private synchronized File getHwiExecutable(Command command) {
|
||||
File hwiExecutable = Config.get().getHwi();
|
||||
if(hwiExecutable != null && hwiExecutable.exists()) {
|
||||
if(command.isTestFirst() && (!hwiExecutable.getAbsolutePath().contains(TEMP_FILE_PREFIX) || !testHwi(hwiExecutable))) {
|
||||
String tmpDir = System.getProperty("java.io.tmpdir");
|
||||
String hwiPath = hwiExecutable.getAbsolutePath();
|
||||
if(command.isTestFirst() && hwiPath.startsWith(tmpDir) && (!hwiPath.contains(VERSION_PREFIX) || !testHwi(hwiExecutable))) {
|
||||
if(Platform.getCurrent() == Platform.OSX) {
|
||||
deleteDirectory(hwiExecutable.getParentFile());
|
||||
} else {
|
||||
@@ -218,8 +243,8 @@ public class Hwi {
|
||||
//The check will still happen on first invocation, but will not thereafter
|
||||
//See https://github.com/bitcoin-core/HWI/issues/327 for details
|
||||
if(platform == Platform.OSX) {
|
||||
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/hwi-1.2.1-mac-amd64-signed.zip");
|
||||
Path tempHwiDirPath = Files.createTempDirectory(TEMP_FILE_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
|
||||
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + VERSION_PREFIX + "-mac-amd64-signed.zip");
|
||||
Path tempHwiDirPath = Files.createTempDirectory(VERSION_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
|
||||
File tempHwiDir = tempHwiDirPath.toFile();
|
||||
//tempHwiDir.deleteOnExit();
|
||||
log.debug("Using temp HWI path: " + tempHwiDir.getAbsolutePath());
|
||||
@@ -227,16 +252,20 @@ public class Hwi {
|
||||
File tempExec = null;
|
||||
ZipInputStream zis = new ZipInputStream(inputStream);
|
||||
ZipEntry zipEntry = zis.getNextEntry();
|
||||
while (zipEntry != null) {
|
||||
File newFile = newFile(tempHwiDir, zipEntry, ownerExecutableWritable);
|
||||
//newFile.deleteOnExit();
|
||||
FileOutputStream fos = new FileOutputStream(newFile);
|
||||
ByteStreams.copy(zis, new FileOutputStream(newFile));
|
||||
fos.flush();
|
||||
fos.close();
|
||||
while(zipEntry != null) {
|
||||
if(zipEntry.isDirectory()) {
|
||||
newDirectory(tempHwiDir, zipEntry, ownerExecutableWritable);
|
||||
} else {
|
||||
File newFile = newFile(tempHwiDir, zipEntry, ownerExecutableWritable);
|
||||
//newFile.deleteOnExit();
|
||||
FileOutputStream fos = new FileOutputStream(newFile);
|
||||
ByteStreams.copy(zis, new FileOutputStream(newFile));
|
||||
fos.flush();
|
||||
fos.close();
|
||||
|
||||
if (zipEntry.getName().equals("hwi")) {
|
||||
tempExec = newFile;
|
||||
if(zipEntry.getName().equals("hwi")) {
|
||||
tempExec = newFile;
|
||||
}
|
||||
}
|
||||
|
||||
zipEntry = zis.getNextEntry();
|
||||
@@ -250,10 +279,10 @@ public class Hwi {
|
||||
Path tempExecPath;
|
||||
if(platform == Platform.WINDOWS) {
|
||||
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
|
||||
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null);
|
||||
tempExecPath = Files.createTempFile(VERSION_PREFIX, null);
|
||||
} else {
|
||||
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
|
||||
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
|
||||
tempExecPath = Files.createTempFile(VERSION_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
|
||||
}
|
||||
|
||||
File tempExec = tempExecPath.toFile();
|
||||
@@ -299,6 +328,20 @@ public class Hwi {
|
||||
return directoryToBeDeleted.delete();
|
||||
}
|
||||
|
||||
public static File newDirectory(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
|
||||
String destDirPath = destinationDir.getCanonicalPath();
|
||||
|
||||
Path path = Path.of(destDirPath, zipEntry.getName());
|
||||
File destDir = Files.createDirectory(path, PosixFilePermissions.asFileAttribute(setFilePermissions)).toFile();
|
||||
|
||||
String destSubDirPath = destDir.getCanonicalPath();
|
||||
if(!destSubDirPath.startsWith(destDirPath + File.separator)) {
|
||||
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
|
||||
}
|
||||
|
||||
return destDir;
|
||||
}
|
||||
|
||||
public static File newFile(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
|
||||
String destDirPath = destinationDir.getCanonicalPath();
|
||||
|
||||
@@ -306,7 +349,7 @@ public class Hwi {
|
||||
File destFile = Files.createFile(path, PosixFilePermissions.asFileAttribute(setFilePermissions)).toFile();
|
||||
|
||||
String destFilePath = destFile.getCanonicalPath();
|
||||
if (!destFilePath.startsWith(destDirPath + File.separator)) {
|
||||
if(!destFilePath.startsWith(destDirPath + File.separator)) {
|
||||
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
|
||||
}
|
||||
|
||||
@@ -324,30 +367,41 @@ public class Hwi {
|
||||
|
||||
private List<String> getDeviceCommand(Device device, Command command) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--testnet");
|
||||
}
|
||||
addChainType(elements);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private List<String> getDeviceCommand(Device device, Command command, String... commandData) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--testnet");
|
||||
}
|
||||
addChainType(elements);
|
||||
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
|
||||
return elements;
|
||||
}
|
||||
|
||||
private List<String> getDeviceCommand(Device device, String passphrase, Command command, String... commandData) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), "--password", passphrase, command.toString()));
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--testnet");
|
||||
}
|
||||
addChainType(elements);
|
||||
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
|
||||
return elements;
|
||||
}
|
||||
|
||||
private void addChainType(List<String> elements) {
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--chain");
|
||||
elements.add(elements.size() - 1, getChainName(Network.get()));
|
||||
}
|
||||
}
|
||||
|
||||
private String getChainName(Network network) {
|
||||
if(network == Network.MAINNET) {
|
||||
return "main";
|
||||
} else if(network == Network.TESTNET) {
|
||||
return "test";
|
||||
}
|
||||
|
||||
return network.toString();
|
||||
}
|
||||
|
||||
public static class EnumerateService extends Service<List<Device>> {
|
||||
private final String passphrase;
|
||||
|
||||
@@ -426,17 +480,35 @@ public class Hwi {
|
||||
}
|
||||
}
|
||||
|
||||
public static class TogglePassphraseService extends Service<Boolean> {
|
||||
private final Device device;
|
||||
|
||||
public TogglePassphraseService(Device device) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() throws ImportException {
|
||||
Hwi hwi = new Hwi();
|
||||
return hwi.togglePassphrase(device);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class DisplayAddressService extends Service<String> {
|
||||
private final Device device;
|
||||
private final String passphrase;
|
||||
private final ScriptType scriptType;
|
||||
private final String derivationPath;
|
||||
private final OutputDescriptor outputDescriptor;
|
||||
|
||||
public DisplayAddressService(Device device, String passphrase, ScriptType scriptType, String derivationPath) {
|
||||
public DisplayAddressService(Device device, String passphrase, ScriptType scriptType, OutputDescriptor outputDescriptor) {
|
||||
this.device = device;
|
||||
this.passphrase = passphrase;
|
||||
this.scriptType = scriptType;
|
||||
this.derivationPath = derivationPath;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -444,7 +516,7 @@ public class Hwi {
|
||||
return new Task<>() {
|
||||
protected String call() throws DisplayAddressException {
|
||||
Hwi hwi = new Hwi();
|
||||
return hwi.displayAddress(device, passphrase, scriptType, derivationPath);
|
||||
return hwi.displayAddress(device, passphrase, scriptType, outputDescriptor);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -553,6 +625,7 @@ public class Hwi {
|
||||
ENUMERATE("enumerate", true),
|
||||
PROMPT_PIN("promptpin", true),
|
||||
SEND_PIN("sendpin", false),
|
||||
TOGGLE_PASSPHRASE("togglepassphrase", true),
|
||||
DISPLAY_ADDRESS("displayaddress", true),
|
||||
SIGN_MESSAGE("signmessage", true),
|
||||
GET_XPUB("getxpub", true),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface KeystoreXprvImport extends KeystoreImport {
|
||||
Keystore getKeystore(List<ChildNumber> derivation, ExtendedKey xprv) throws ImportException;
|
||||
}
|
||||
@@ -28,11 +28,21 @@ public class PassportMultisig extends ColdcardMultisig {
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import file or QR created from Settings > Pair Software Wallet > Sparrow > Multisig > microSD/QR on your Passport.";
|
||||
return "Import file or QR created from New Account > Sparrow > Multisig > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isKeystoreImportScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "As part of the New Account > Sparrow > Multisig flow, Passport will ask you to scan or import the multisig configuration from Sparrow.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletExportScannable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class PassportSinglesig extends ColdcardSinglesig {
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import file or QR created from Settings > Pair Software Wallet > Sparrow > Single Sig > microSD/QR on your Passport.";
|
||||
return "Import file or QR created from New Account > Sparrow > Standard > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class Sparrow implements WalletExport {
|
||||
private static final Logger log = LoggerFactory.getLogger(Sparrow.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Sparrow";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.SPARROW;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
|
||||
try {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
Files.copy(storage.getWalletFile().toPath(), outputStream);
|
||||
outputStream.flush();
|
||||
outputStream.close();
|
||||
} catch(Exception e) {
|
||||
log.error("Error exporting Sparrow wallet file", e);
|
||||
throw new ExportException("Error exporting Sparrow wallet file", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Exports a copy of your Sparrow wallet file, which can be loaded in another Sparrow instance running on any supported platform.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExportFileExtension(Wallet wallet) {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
if(storage != null && (storage.getEncryptionPubKey() == null || Storage.NO_PASSWORD_KEY.equals(storage.getEncryptionPubKey()))) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletExportScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -15,6 +17,8 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class SpecterDIY implements KeystoreFileImport {
|
||||
private static final Logger log = LoggerFactory.getLogger(SpecterDIY.class);
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
try {
|
||||
@@ -34,7 +38,8 @@ public class SpecterDIY implements KeystoreFileImport {
|
||||
|
||||
return keystore;
|
||||
} catch(IOException e) {
|
||||
throw new ImportException(e);
|
||||
log.error("Error getting Specter DIY keystore", e);
|
||||
throw new ImportException("Error getting Specter DIY keystore", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -15,6 +17,8 @@ import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class SpecterDesktop implements WalletImport, WalletExport {
|
||||
private static final Logger log = LoggerFactory.getLogger(SpecterDesktop.class);
|
||||
|
||||
@Override
|
||||
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
|
||||
try {
|
||||
@@ -29,7 +33,8 @@ public class SpecterDesktop implements WalletImport, WalletExport {
|
||||
outputStream.flush();
|
||||
outputStream.close();
|
||||
} catch(Exception e) {
|
||||
throw new ExportException(e);
|
||||
log.error("Error exporting Specter Desktop wallet", e);
|
||||
throw new ExportException("Error exporting Specter Desktop wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +44,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExportFileExtension() {
|
||||
public String getExportFileExtension(Wallet wallet) {
|
||||
return "json";
|
||||
}
|
||||
|
||||
@@ -68,7 +73,8 @@ public class SpecterDesktop implements WalletImport, WalletExport {
|
||||
return wallet;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
log.error("Error importing Specter Desktop wallet", e);
|
||||
throw new ImportException("Error importing Specter Desktop wallet", e);
|
||||
}
|
||||
|
||||
throw new ImportException("File was not a valid Specter Desktop wallet");
|
||||
@@ -99,6 +105,11 @@ public class SpecterDesktop implements WalletImport, WalletExport {
|
||||
return WalletModel.SPECTER_DESKTOP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class SpecterWallet {
|
||||
public String label;
|
||||
public Integer blockheight;
|
||||
|
||||
@@ -10,37 +10,46 @@ import com.sparrowwallet.drongo.crypto.*;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.MnemonicException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.MainApp;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import org.controlsfx.tools.Platform;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.*;
|
||||
|
||||
import static com.sparrowwallet.drongo.crypto.Argon2KeyDeriver.SPRW1_PARAMETERS;
|
||||
|
||||
public class Storage {
|
||||
private static final Logger log = LoggerFactory.getLogger(Storage.class);
|
||||
public static final ECKey NO_PASSWORD_KEY = ECKey.fromPublicOnly(ECKey.fromPrivate(Utils.hexToBytes("885e5a09708a167ea356a252387aa7c4893d138d632e296df8fbf5c12798bd28")));
|
||||
|
||||
private static final DateFormat BACKUP_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss");
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile(".+-([0-9]{14}?).*");
|
||||
|
||||
public static final String SPARROW_DIR = ".sparrow";
|
||||
public static final String WINDOWS_SPARROW_DIR = "Sparrow";
|
||||
public static final String WALLETS_DIR = "wallets";
|
||||
public static final String WALLETS_BACKUP_DIR = "backup";
|
||||
public static final String CERTS_DIR = "certs";
|
||||
public static final String HEADER_MAGIC_1 = "SPRW1";
|
||||
private static final int BINARY_HEADER_LENGTH = 28;
|
||||
public static final String TEMP_BACKUP_EXTENSION = "tmp";
|
||||
|
||||
private File walletFile;
|
||||
private final Gson gson;
|
||||
@@ -81,17 +90,49 @@ public class Storage {
|
||||
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
|
||||
}
|
||||
|
||||
public Wallet loadWallet() throws IOException {
|
||||
Reader reader = new FileReader(walletFile);
|
||||
public WalletBackupAndKey loadWallet() throws IOException {
|
||||
Wallet wallet = loadWallet(walletFile);
|
||||
|
||||
Wallet backupWallet = null;
|
||||
File[] backups = getBackups("json." + TEMP_BACKUP_EXTENSION);
|
||||
if(backups.length > 0) {
|
||||
try {
|
||||
backupWallet = loadWallet(backups[0]);
|
||||
} catch(Exception e) {
|
||||
log.error("Error loading backup wallet " + TEMP_BACKUP_EXTENSION, e);
|
||||
}
|
||||
}
|
||||
|
||||
encryptionPubKey = NO_PASSWORD_KEY;
|
||||
return new WalletBackupAndKey(wallet, backupWallet, null);
|
||||
}
|
||||
|
||||
public Wallet loadWallet(File jsonFile) throws IOException {
|
||||
Reader reader = new FileReader(jsonFile);
|
||||
Wallet wallet = gson.fromJson(reader, Wallet.class);
|
||||
reader.close();
|
||||
|
||||
encryptionPubKey = NO_PASSWORD_KEY;
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public WalletAndKey loadWallet(CharSequence password) throws IOException, StorageException {
|
||||
InputStream fileStream = new FileInputStream(walletFile);
|
||||
public WalletBackupAndKey loadWallet(CharSequence password) throws IOException, StorageException {
|
||||
WalletAndKey walletAndKey = loadWallet(walletFile, password);
|
||||
|
||||
WalletAndKey backupAndKey = new WalletAndKey(null, null);
|
||||
File[] backups = getBackups(TEMP_BACKUP_EXTENSION, "json." + TEMP_BACKUP_EXTENSION);
|
||||
if(backups.length > 0) {
|
||||
try {
|
||||
backupAndKey = loadWallet(backups[0], password);
|
||||
} catch(Exception e) {
|
||||
log.error("Error loading backup wallet " + TEMP_BACKUP_EXTENSION, e);
|
||||
}
|
||||
}
|
||||
|
||||
return new WalletBackupAndKey(walletAndKey.wallet, backupAndKey.wallet, walletAndKey.key);
|
||||
}
|
||||
|
||||
public WalletAndKey loadWallet(File encryptedFile, CharSequence password) throws IOException, StorageException {
|
||||
InputStream fileStream = new FileInputStream(encryptedFile);
|
||||
ECKey encryptionKey = getEncryptionKey(password, fileStream);
|
||||
|
||||
InputStream inputStream = new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic()));
|
||||
@@ -168,6 +209,18 @@ public class Storage {
|
||||
}
|
||||
|
||||
public void backupWallet() throws IOException {
|
||||
backupWallet(null);
|
||||
}
|
||||
|
||||
public void backupTempWallet() {
|
||||
try {
|
||||
backupWallet(TEMP_BACKUP_EXTENSION);
|
||||
} catch(IOException e) {
|
||||
log.error("Error creating ." + TEMP_BACKUP_EXTENSION + " backup wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void backupWallet(String extension) throws IOException {
|
||||
File backupDir = getWalletsBackupDir();
|
||||
|
||||
Date backupDate = new Date();
|
||||
@@ -180,10 +233,52 @@ public class Storage {
|
||||
backupName += dateSuffix;
|
||||
}
|
||||
|
||||
if(extension != null) {
|
||||
backupName += "." + extension;
|
||||
}
|
||||
|
||||
File backupFile = new File(backupDir, backupName);
|
||||
Files.copy(walletFile, backupFile);
|
||||
}
|
||||
|
||||
public void deleteBackups() {
|
||||
deleteBackups(null);
|
||||
}
|
||||
|
||||
public void deleteBackups(String extension) {
|
||||
File[] backups = getBackups(extension);
|
||||
for(File backup : backups) {
|
||||
backup.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private File[] getBackups(String extension) {
|
||||
return getBackups(extension, null);
|
||||
}
|
||||
|
||||
private File[] getBackups(String extension, String notExtension) {
|
||||
File backupDir = getWalletsBackupDir();
|
||||
File[] backups = backupDir.listFiles((dir, name) -> {
|
||||
return name.startsWith(Files.getNameWithoutExtension(walletFile.getName()) + "-") &&
|
||||
getBackupDate(name) != null &&
|
||||
(extension == null || name.endsWith("." + extension)) &&
|
||||
(notExtension == null || !name.endsWith("." + notExtension));
|
||||
});
|
||||
|
||||
Arrays.sort(backups, Comparator.comparing(o -> getBackupDate(((File)o).getName())).reversed());
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
private String getBackupDate(String backupFileName) {
|
||||
Matcher matcher = DATE_PATTERN.matcher(backupFileName);
|
||||
if(matcher.matches()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ECKey getEncryptionPubKey() {
|
||||
return encryptionPubKey;
|
||||
}
|
||||
@@ -238,6 +333,8 @@ public class Storage {
|
||||
}
|
||||
|
||||
keyDeriver = new Argon2KeyDeriver(salt);
|
||||
} else if(inputStream != null) {
|
||||
inputStream.skip(BINARY_HEADER_LENGTH);
|
||||
}
|
||||
|
||||
return keyDeriver;
|
||||
@@ -290,6 +387,37 @@ public class Storage {
|
||||
return walletsDir;
|
||||
}
|
||||
|
||||
public static File getCertificateFile(String host) {
|
||||
File certsDir = getCertsDir();
|
||||
File[] certs = certsDir.listFiles((dir, name) -> name.equals(host));
|
||||
if(certs.length > 0) {
|
||||
return certs[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void saveCertificate(String host, Certificate cert) {
|
||||
try(FileWriter writer = new FileWriter(new File(getCertsDir(), host))) {
|
||||
writer.write("-----BEGIN CERTIFICATE-----\n");
|
||||
writer.write(Base64.getEncoder().encodeToString(cert.getEncoded()).replaceAll("(.{64})", "$1\n"));
|
||||
writer.write("\n-----END CERTIFICATE-----\n");
|
||||
} catch(CertificateEncodingException e) {
|
||||
log.error("Error encoding PEM certificate", e);
|
||||
} catch(IOException e) {
|
||||
log.error("Error writing PEM certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
static File getCertsDir() {
|
||||
File certsDir = new File(getSparrowDir(), CERTS_DIR);
|
||||
if(!certsDir.exists()) {
|
||||
certsDir.mkdirs();
|
||||
}
|
||||
|
||||
return certsDir;
|
||||
}
|
||||
|
||||
static File getSparrowDir() {
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
return new File(getSparrowHome(), Network.get().getName());
|
||||
@@ -399,7 +527,7 @@ public class Storage {
|
||||
@Override
|
||||
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore);
|
||||
if(keystore.hasSeed()) {
|
||||
if(keystore.hasPrivateKey()) {
|
||||
jsonObject.remove("extendedPublicKey");
|
||||
jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint");
|
||||
}
|
||||
@@ -465,7 +593,16 @@ public class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoadWalletService extends Service<WalletAndKey> {
|
||||
public static class WalletBackupAndKey extends WalletAndKey {
|
||||
public final Wallet backupWallet;
|
||||
|
||||
public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, Key key) {
|
||||
super(wallet, key);
|
||||
this.backupWallet = backupWallet;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoadWalletService extends Service<WalletBackupAndKey> {
|
||||
private final Storage storage;
|
||||
private final SecureString password;
|
||||
|
||||
@@ -475,12 +612,12 @@ public class Storage {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<WalletAndKey> createTask() {
|
||||
protected Task<WalletBackupAndKey> createTask() {
|
||||
return new Task<>() {
|
||||
protected WalletAndKey call() throws IOException, StorageException, MnemonicException {
|
||||
WalletAndKey walletAndKey = storage.loadWallet(password);
|
||||
protected WalletBackupAndKey call() throws IOException, StorageException {
|
||||
WalletBackupAndKey walletBackupAndKey = storage.loadWallet(password);
|
||||
password.clear();
|
||||
return walletAndKey;
|
||||
return walletBackupAndKey;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.io.OutputStream;
|
||||
public interface WalletExport extends Export {
|
||||
void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException;
|
||||
String getWalletExportDescription();
|
||||
String getExportFileExtension();
|
||||
String getExportFileExtension(Wallet wallet);
|
||||
boolean isWalletExportScannable();
|
||||
boolean walletExportRequiresDecryption();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(watchOnlyButtonType, cancelButtonType);
|
||||
dialogPane.setPrefWidth(650);
|
||||
dialogPane.setPrefHeight(620);
|
||||
dialogPane.setPrefHeight(690);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? getWatchOnlyKeystore() : null);
|
||||
} catch(IOException e) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.keystoreimport;
|
||||
import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
|
||||
import com.sparrowwallet.sparrow.control.MnemonicKeystoreImportPane;
|
||||
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
|
||||
import com.sparrowwallet.sparrow.control.XprvKeystoreImportPane;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Accordion;
|
||||
@@ -14,7 +15,7 @@ public class SwController extends KeystoreImportDetailController {
|
||||
private Accordion importAccordion;
|
||||
|
||||
public void initializeView() {
|
||||
List<KeystoreImport> importers = List.of(new Bip39(), new Electrum());
|
||||
List<KeystoreImport> importers = List.of(new Bip39(), new Electrum(), new Bip32());
|
||||
|
||||
for(KeystoreImport importer : importers) {
|
||||
TitledDescriptionPane importPane = null;
|
||||
@@ -23,6 +24,8 @@ public class SwController extends KeystoreImportDetailController {
|
||||
importPane = new FileKeystoreImportPane(getMasterController().getWallet(), (KeystoreFileImport)importer);
|
||||
} else if(importer instanceof KeystoreMnemonicImport) {
|
||||
importPane = new MnemonicKeystoreImportPane(getMasterController().getWallet(), (KeystoreMnemonicImport)importer);
|
||||
} else if(importer instanceof KeystoreXprvImport) {
|
||||
importPane = new XprvKeystoreImportPane(getMasterController().getWallet(), (KeystoreXprvImport)importer);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Could not create ImportPane for importer of type " + importer.getClass());
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public class Bwt {
|
||||
private void start(Collection<Wallet> wallets, CallbackNotifier callback) {
|
||||
List<Wallet> validWallets = wallets.stream().filter(Wallet::isValid).collect(Collectors.toList());
|
||||
|
||||
List<String> outputDescriptors = new ArrayList<>();
|
||||
Set<String> outputDescriptors = new LinkedHashSet<>();
|
||||
for(Wallet wallet : validWallets) {
|
||||
OutputDescriptor receiveOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE);
|
||||
outputDescriptors.add(receiveOutputDescriptor.toString(false, false));
|
||||
@@ -89,7 +89,7 @@ public class Bwt {
|
||||
* @param gapLimit desired gap limit beyond last used address
|
||||
* @param callback object receiving notifications
|
||||
*/
|
||||
private void start(List<String> outputDescriptors, Integer rescanSince, Boolean forceRescan, Integer gapLimit, CallbackNotifier callback) {
|
||||
private void start(Collection<String> outputDescriptors, Integer rescanSince, Boolean forceRescan, Integer gapLimit, CallbackNotifier callback) {
|
||||
BwtConfig bwtConfig = new BwtConfig();
|
||||
bwtConfig.network = Network.get() == Network.MAINNET ? "bitcoin" : Network.get().getName();
|
||||
|
||||
@@ -188,7 +188,7 @@ public class Bwt {
|
||||
public Boolean createWalletIfMissing;
|
||||
|
||||
@SerializedName("descriptors")
|
||||
public List<String> descriptors;
|
||||
public Collection<String> descriptors;
|
||||
|
||||
@SerializedName("xpubs")
|
||||
public String xpubs;
|
||||
|
||||
@@ -29,6 +29,8 @@ import java.io.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ElectrumServer {
|
||||
@@ -42,10 +44,16 @@ public class ElectrumServer {
|
||||
|
||||
private static final Map<String, Set<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
private static String previousServerAddress;
|
||||
|
||||
private static Map<String, String> retrievedScriptHashes = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
|
||||
|
||||
private static String bwtElectrumServer;
|
||||
|
||||
private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed:[^\"]*)\".*");
|
||||
|
||||
private static synchronized Transport getTransport() throws ServerException {
|
||||
if(transport == null) {
|
||||
try {
|
||||
@@ -80,6 +88,12 @@ public class ElectrumServer {
|
||||
throw new ServerConfigException("Electrum server URL must start with " + Protocol.TCP.toUrlString() + " or " + Protocol.SSL.toUrlString());
|
||||
}
|
||||
|
||||
//If changing server, don't rely on previous transaction history
|
||||
if(!electrumServer.equals(previousServerAddress)) {
|
||||
retrievedScriptHashes.clear();
|
||||
}
|
||||
previousServerAddress = electrumServer;
|
||||
|
||||
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
|
||||
|
||||
if(Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) {
|
||||
@@ -146,6 +160,11 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearRetrievedScriptHashes(Wallet wallet) {
|
||||
wallet.getNode(KeyPurpose.RECEIVE).getChildren().stream().map(node -> getScriptHash(wallet, node)).forEach(scriptHash -> retrievedScriptHashes.remove(scriptHash));
|
||||
wallet.getNode(KeyPurpose.CHANGE).getChildren().stream().map(node -> getScriptHash(wallet, node)).forEach(scriptHash -> retrievedScriptHashes.remove(scriptHash));
|
||||
}
|
||||
|
||||
public Map<WalletNode, Set<BlockTransactionHash>> getHistory(Wallet wallet) throws ServerException {
|
||||
Map<WalletNode, Set<BlockTransactionHash>> receiveTransactionMap = new TreeMap<>();
|
||||
getHistory(wallet, KeyPurpose.RECEIVE, receiveTransactionMap);
|
||||
@@ -173,6 +192,15 @@ public class ElectrumServer {
|
||||
Set<BlockTransactionHash> newReferences = nodeTransactionMap.values().stream().flatMap(Collection::stream).filter(ref -> !wallet.getTransactions().containsKey(ref.getHash())).collect(Collectors.toSet());
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
|
||||
//Subscribe and retrieve transaction history from child nodes if necessary to maintain gap limit
|
||||
Set<KeyPurpose> keyPurposes = nodes.stream().map(WalletNode::getKeyPurpose).collect(Collectors.toUnmodifiableSet());
|
||||
for(KeyPurpose keyPurpose : keyPurposes) {
|
||||
WalletNode purposeNode = wallet.getNode(keyPurpose);
|
||||
getHistoryToGapLimit(wallet, nodeTransactionMap, purposeNode);
|
||||
}
|
||||
|
||||
log.debug("Fetched nodes history for: " + nodeTransactionMap.keySet());
|
||||
|
||||
if(!newReferences.isEmpty()) {
|
||||
//Look for additional nodes to fetch history for by considering the inputs and outputs of new transactions found
|
||||
log.debug(wallet.getName() + " found new transactions: " + newReferences);
|
||||
@@ -212,13 +240,22 @@ public class ElectrumServer {
|
||||
|
||||
public void getHistory(Wallet wallet, KeyPurpose keyPurpose, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
|
||||
WalletNode purposeNode = wallet.getNode(keyPurpose);
|
||||
//Subscribe to all existing address WalletNodes and add them to nodeTransactionMap as keys to empty sets if they have history
|
||||
//Subscribe to all existing address WalletNodes and add them to nodeTransactionMap as keys to empty sets if they have history that needs to be fetched
|
||||
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
|
||||
//All WalletNode keys in nodeTransactionMap need to have their history fetched (nodes without history will not be keys in the map yet)
|
||||
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, 0);
|
||||
//Fetch all referenced transaction to wallet transactions map. We do this now even though it is done again later to get it done before too many script hashes are subscribed
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
//Increase child nodes if necessary to maintain gap limit, and ensure they are subscribed and history is fetched
|
||||
getHistoryToGapLimit(wallet, nodeTransactionMap, purposeNode);
|
||||
|
||||
log.debug("Fetched history for: " + nodeTransactionMap.keySet());
|
||||
|
||||
//Set the remaining WalletNode keys in nodeTransactionMap to empty sets to indicate no history (if no script hash history has already been retrieved in a previous call)
|
||||
purposeNode.getChildren().stream().filter(node -> !nodeTransactionMap.containsKey(node) && retrievedScriptHashes.get(getScriptHash(wallet, node)) == null).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
|
||||
}
|
||||
|
||||
private void getHistoryToGapLimit(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, WalletNode purposeNode) throws ServerException {
|
||||
//Because node children are added sequentially in WalletNode.fillToIndex, we can simply look at the number of children to determine the highest filled index
|
||||
int historySize = purposeNode.getChildren().size();
|
||||
//The gap limit size takes the highest used index in the retrieved history and adds the gap limit (plus one to be comparable to the number of children since index is zero based)
|
||||
@@ -231,9 +268,6 @@ public class ElectrumServer {
|
||||
historySize = purposeNode.getChildren().size();
|
||||
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
|
||||
}
|
||||
|
||||
//Set the remaining WalletNode keys in nodeTransactionMap to empty sets to indicate no history
|
||||
purposeNode.getChildren().stream().filter(node -> !nodeTransactionMap.containsKey(node)).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
|
||||
}
|
||||
|
||||
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
|
||||
@@ -315,9 +349,12 @@ public class ElectrumServer {
|
||||
|
||||
if(node != null && node.getIndex() >= startIndex) {
|
||||
String scriptHash = getScriptHash(wallet, node);
|
||||
if(getSubscribedScriptHashStatus(scriptHash) != null) {
|
||||
//Already subscribed, but still need to fetch history from a used node
|
||||
nodeTransactionMap.put(node, new TreeSet<>());
|
||||
String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
|
||||
if(subscribedStatus != null) {
|
||||
//Already subscribed, but still need to fetch history from a used node if not previously fetched
|
||||
if(!subscribedStatus.equals(retrievedScriptHashes.get(scriptHash))) {
|
||||
nodeTransactionMap.put(node, new TreeSet<>());
|
||||
}
|
||||
} else if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
|
||||
//Unique script hash we are not yet subscribed to
|
||||
pathScriptHashes.put(node.getDerivationPath(), scriptHash);
|
||||
@@ -325,6 +362,8 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Subscribe to: " + pathScriptHashes.keySet());
|
||||
|
||||
if(pathScriptHashes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -339,8 +378,8 @@ public class ElectrumServer {
|
||||
WalletNode node = optionalNode.get();
|
||||
String scriptHash = getScriptHash(wallet, node);
|
||||
|
||||
//Check if there is history for this script hash
|
||||
if(status != null) {
|
||||
//Check if there is history for this script hash, and if the history has changed since last fetched
|
||||
if(status != null && !status.equals(retrievedScriptHashes.get(scriptHash))) {
|
||||
//Set the value for this node to be an empty set to mark it as requiring a get_history RPC call for this wallet
|
||||
nodeTransactionMap.put(node, new TreeSet<>());
|
||||
}
|
||||
@@ -824,11 +863,16 @@ public class ElectrumServer {
|
||||
bwtStartCondition.await();
|
||||
|
||||
if(!bwt.isReady()) {
|
||||
if(bwtStartException != null && bwtStartException.getMessage().contains("Wallet file not specified")) {
|
||||
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
|
||||
} else {
|
||||
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
|
||||
if(bwtStartException != null) {
|
||||
Matcher walletLoadingMatcher = RPC_WALLET_LOADING_PATTERN.matcher(bwtStartException.getMessage());
|
||||
if(bwtStartException.getMessage().contains("Wallet file not specified")) {
|
||||
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
|
||||
} else if(walletLoadingMatcher.matches() && walletLoadingMatcher.group(1) != null) {
|
||||
throw new ServerException(walletLoadingMatcher.group(1));
|
||||
}
|
||||
}
|
||||
|
||||
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
|
||||
}
|
||||
} catch(InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
@@ -944,7 +988,7 @@ public class ElectrumServer {
|
||||
});
|
||||
disconnectionService.start();
|
||||
} else if(subscribe) {
|
||||
EventManager.get().post(new DisconnectionEvent());
|
||||
Platform.runLater(() -> EventManager.get().post(new DisconnectionEvent()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,11 +1067,22 @@ public class ElectrumServer {
|
||||
protected Boolean call() throws ServerException {
|
||||
walletSynchronizeLocks.putIfAbsent(wallet, new Object());
|
||||
synchronized(walletSynchronizeLocks.get(wallet)) {
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
|
||||
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
|
||||
return true;
|
||||
if(isConnected()) {
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
|
||||
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
|
||||
|
||||
//Add all of the script hashes we have now fetched the history for so we don't need to fetch again until the script hash status changes
|
||||
for(WalletNode node : nodeTransactionMap.keySet()) {
|
||||
String scriptHash = getScriptHash(wallet, node);
|
||||
retrievedScriptHashes.put(scriptHash, getSubscribedScriptHashStatus(scriptHash));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,7 +56,11 @@ public enum ExchangeSource {
|
||||
Gson gson = new Gson();
|
||||
return gson.fromJson(reader, CoinbaseRates.class);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving currency rates", e);
|
||||
if(log.isDebugEnabled()) {
|
||||
log.warn("Error retrieving currency rates", e);
|
||||
} else {
|
||||
log.warn("Error retrieving currency rates (" + e.getMessage() + ")");
|
||||
}
|
||||
return new CoinbaseRates();
|
||||
}
|
||||
}
|
||||
@@ -86,8 +90,12 @@ public enum ExchangeSource {
|
||||
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
|
||||
Gson gson = new Gson();
|
||||
return gson.fromJson(reader, CoinGeckoRates.class);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving currency rates", e);
|
||||
} catch(Exception e) {
|
||||
if(log.isDebugEnabled()) {
|
||||
log.warn("Error retrieving currency rates", e);
|
||||
} else {
|
||||
log.warn("Error retrieving currency rates (" + e.getMessage() + ")");
|
||||
}
|
||||
return new CoinGeckoRates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ public enum FeeRatesSource {
|
||||
};
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FeeRatesSource.class);
|
||||
public static final int BLOCKS_IN_HALF_HOUR = 3;
|
||||
public static final int BLOCKS_IN_HOUR = 6;
|
||||
public static final int BLOCKS_IN_TWO_HOURS = 12;
|
||||
|
||||
private final String name;
|
||||
|
||||
@@ -60,19 +63,35 @@ public enum FeeRatesSource {
|
||||
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
|
||||
Gson gson = new Gson();
|
||||
ThreeTierRates threeTierRates = gson.fromJson(reader, ThreeTierRates.class);
|
||||
Double lastRate = null;
|
||||
for(Integer blockTarget : defaultblockTargetFeeRates.keySet()) {
|
||||
if(blockTarget < 3) {
|
||||
if(blockTarget < BLOCKS_IN_HALF_HOUR) {
|
||||
blockTargetFeeRates.put(blockTarget, threeTierRates.fastestFee);
|
||||
} else if(blockTarget < 6) {
|
||||
} else if(blockTarget < BLOCKS_IN_HOUR) {
|
||||
blockTargetFeeRates.put(blockTarget, threeTierRates.halfHourFee);
|
||||
} else if(blockTarget <= 10 || defaultblockTargetFeeRates.get(blockTarget) > threeTierRates.hourFee) {
|
||||
} else if(blockTarget < BLOCKS_IN_TWO_HOURS || defaultblockTargetFeeRates.get(blockTarget) > threeTierRates.hourFee) {
|
||||
blockTargetFeeRates.put(blockTarget, threeTierRates.hourFee);
|
||||
} else if(threeTierRates.minimumFee != null && defaultblockTargetFeeRates.get(blockTarget) < threeTierRates.minimumFee) {
|
||||
blockTargetFeeRates.put(blockTarget, threeTierRates.minimumFee + (threeTierRates.hourFee > threeTierRates.minimumFee ? threeTierRates.hourFee * 0.2 : 0.0));
|
||||
} else {
|
||||
blockTargetFeeRates.put(blockTarget, defaultblockTargetFeeRates.get(blockTarget));
|
||||
}
|
||||
|
||||
if(lastRate != null) {
|
||||
blockTargetFeeRates.put(blockTarget, Math.min(lastRate, blockTargetFeeRates.get(blockTarget)));
|
||||
}
|
||||
lastRate = blockTargetFeeRates.get(blockTarget);
|
||||
}
|
||||
|
||||
if(threeTierRates.minimumFee != null) {
|
||||
blockTargetFeeRates.put(Integer.MAX_VALUE, threeTierRates.minimumFee);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Error retrieving recommended fee rates from " + url, e);
|
||||
if(log.isDebugEnabled()) {
|
||||
log.warn("Error retrieving recommended fee rates from " + url, e);
|
||||
} else {
|
||||
log.warn("Error retrieving recommended fee rates from " + url + " (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
return blockTargetFeeRates;
|
||||
@@ -98,5 +117,6 @@ public enum FeeRatesSource {
|
||||
Double fastestFee;
|
||||
Double halfHourFee;
|
||||
Double hourFee;
|
||||
Double minimumFee;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public enum Protocol {
|
||||
},
|
||||
SSL {
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
|
||||
public Transport getTransport(HostAndPort server) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
if(isOnionAddress(server)) {
|
||||
return new TorTcpOverTlsTransport(server);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public enum Protocol {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws NoSuchAlgorithmException, KeyManagementException {
|
||||
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
return new ProxyTcpOverTlsTransport(server, proxy);
|
||||
}
|
||||
|
||||
@@ -68,27 +68,27 @@ public enum Protocol {
|
||||
},
|
||||
HTTP {
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
|
||||
public Transport getTransport(HostAndPort server) {
|
||||
throw new UnsupportedOperationException("No transport supported for HTTP");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
public Transport getTransport(HostAndPort server, File serverCert) {
|
||||
throw new UnsupportedOperationException("No transport supported for HTTP");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
public Transport getTransport(HostAndPort server, HostAndPort proxy) {
|
||||
throw new UnsupportedOperationException("No transport supported for HTTP");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) {
|
||||
throw new UnsupportedOperationException("No transport supported for HTTP");
|
||||
}
|
||||
};
|
||||
|
||||
public abstract Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException;
|
||||
public abstract Transport getTransport(HostAndPort server) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
|
||||
|
||||
public abstract Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
public class ProxyServerException extends ServerException {
|
||||
public ProxyServerException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
|
||||
|
||||
private final HostAndPort proxy;
|
||||
|
||||
public ProxyTcpOverTlsTransport(HostAndPort server, HostAndPort proxy) throws KeyManagementException, NoSuchAlgorithmException {
|
||||
public ProxyTcpOverTlsTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
super(server);
|
||||
this.proxy = proxy;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
|
||||
Socket underlying = new Socket(new Proxy(Proxy.Type.SOCKS, proxyAddr));
|
||||
underlying.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(DEFAULT_PORT)));
|
||||
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(underlying, proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT), true);
|
||||
sslSocket.startHandshake();
|
||||
startHandshake(sslSocket);
|
||||
|
||||
return sslSocket;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
private static final Logger log = LoggerFactory.getLogger(SimpleElectrumServerRpc.class);
|
||||
private static final int MAX_TARGET_BLOCKS = 25;
|
||||
private static final int MAX_RETRIES = 10;
|
||||
private static final int MAX_RETRIES = 5;
|
||||
private static final int RETRY_DELAY = 1;
|
||||
|
||||
private final AtomicLong idCounter = new AtomicLong();
|
||||
@@ -145,6 +145,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
String blockHeader = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("blockchain.block.header").id(idCounter.incrementAndGet()).params(blockHeight).execute());
|
||||
result.put(blockHeight, blockHeader);
|
||||
} catch(ServerException e) {
|
||||
//If there is an error with the server connection, don't keep trying - this may take too long given many blocks
|
||||
throw new ElectrumServerRpcException("Failed to retrieve block header for block height: " + blockHeight, e);
|
||||
} catch(JsonRpcException e) {
|
||||
log.warn("Failed to retrieve block header for block height: " + blockHeight + " (" + e.getErrorMessage() + ")");
|
||||
} catch(Exception e) {
|
||||
|
||||
@@ -32,10 +32,10 @@ public class SubscriptionService {
|
||||
|
||||
Set<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
|
||||
if(existingStatuses == null) {
|
||||
log.warn("Received script hash status update for unsubscribed script hash: " + scriptHash);
|
||||
log.debug("Received script hash status update for unsubscribed script hash: " + scriptHash);
|
||||
ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status);
|
||||
} else if(existingStatuses.contains(status)) {
|
||||
log.warn("Received script hash status update, but status has not changed");
|
||||
log.debug("Received script hash status update, but status has not changed");
|
||||
return;
|
||||
} else {
|
||||
String oldStatus = Iterables.getLast(existingStatuses);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.net.ssl.*;
|
||||
import java.io.File;
|
||||
@@ -8,35 +11,23 @@ import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
import java.security.*;
|
||||
import java.security.cert.*;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
public class TcpOverTlsTransport extends TcpTransport {
|
||||
private static final Logger log = LoggerFactory.getLogger(TcpOverTlsTransport.class);
|
||||
|
||||
public static final int DEFAULT_PORT = 50002;
|
||||
|
||||
protected final SSLSocketFactory sslSocketFactory;
|
||||
|
||||
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException {
|
||||
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException, CertificateException, KeyStoreException, IOException {
|
||||
super(server);
|
||||
|
||||
TrustManager[] trustAllCerts = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||
}
|
||||
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) {
|
||||
}
|
||||
}
|
||||
};
|
||||
TrustManager[] trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()));
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustAllCerts, new SecureRandom());
|
||||
sslContext.init(null, trustManagers, new SecureRandom());
|
||||
|
||||
this.sslSocketFactory = sslContext.getSocketFactory();
|
||||
}
|
||||
@@ -44,25 +35,77 @@ public class TcpOverTlsTransport extends TcpTransport {
|
||||
public TcpOverTlsTransport(HostAndPort server, File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
super(server);
|
||||
|
||||
Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
keyStore.load(null, null);
|
||||
keyStore.setCertificateEntry("electrumx", certificate);
|
||||
|
||||
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trustManagerFactory.init(keyStore);
|
||||
TrustManager[] trustManagers = getTrustManagers(crtFile);
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
|
||||
sslContext.init(null, trustManagers, null);
|
||||
|
||||
sslSocketFactory = sslContext.getSocketFactory();
|
||||
}
|
||||
|
||||
private TrustManager[] getTrustManagers(File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
|
||||
if(crtFile == null) {
|
||||
return new TrustManager[] {
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
||||
}
|
||||
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
||||
if(certs.length == 0) {
|
||||
throw new CertificateException("No server certificate provided");
|
||||
}
|
||||
|
||||
certs[0].checkValidity();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
|
||||
if(certificate instanceof X509Certificate) {
|
||||
try {
|
||||
X509Certificate x509Certificate = (X509Certificate)certificate;
|
||||
x509Certificate.checkValidity();
|
||||
} catch(CertificateException e) {
|
||||
crtFile.delete();
|
||||
return getTrustManagers(null);
|
||||
}
|
||||
}
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
keyStore.load(null, null);
|
||||
keyStore.setCertificateEntry("electrum-server", certificate);
|
||||
|
||||
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trustManagerFactory.init(keyStore);
|
||||
|
||||
return trustManagerFactory.getTrustManagers();
|
||||
}
|
||||
|
||||
protected Socket createSocket() throws IOException {
|
||||
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(server.getHost(), server.getPortOrDefault(DEFAULT_PORT));
|
||||
sslSocket.startHandshake();
|
||||
|
||||
startHandshake(sslSocket);
|
||||
return sslSocket;
|
||||
}
|
||||
|
||||
protected void startHandshake(SSLSocket sslSocket) throws IOException {
|
||||
sslSocket.addHandshakeCompletedListener(event -> {
|
||||
if(Storage.getCertificateFile(server.getHost()) == null) {
|
||||
try {
|
||||
Certificate[] certs = event.getPeerCertificates();
|
||||
if(certs.length > 0) {
|
||||
Storage.saveCertificate(server.getHost(), certs[0]);
|
||||
}
|
||||
} catch(SSLPeerUnverifiedException e) {
|
||||
log.warn("Attempting to retrieve certificate for unverified peer", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sslSocket.startHandshake();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.net.SocketFactory;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.*;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -23,6 +25,7 @@ public class TcpTransport implements Transport, Closeable {
|
||||
|
||||
public static final int DEFAULT_PORT = 50001;
|
||||
private static final int[] READ_TIMEOUT_SECS = {3, 8, 16, 34};
|
||||
public static final int SOCKET_READ_TIMEOUT_MILLIS = 5000;
|
||||
|
||||
protected final HostAndPort server;
|
||||
protected final SocketFactory socketFactory;
|
||||
@@ -106,8 +109,9 @@ public class TcpTransport implements Transport, Closeable {
|
||||
try {
|
||||
readingCondition.await();
|
||||
} catch(InterruptedException e) {
|
||||
//Restore interrupt status and continue
|
||||
//Restore interrupt status and break
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +183,7 @@ public class TcpTransport implements Transport, Closeable {
|
||||
}
|
||||
|
||||
protected String readInputStream(BufferedReader in) throws IOException {
|
||||
String response = in.readLine();
|
||||
String response = readLine(in);
|
||||
|
||||
if(response == null) {
|
||||
throw new IOException("Could not connect to server at " + Config.get().getServerAddress());
|
||||
@@ -188,11 +192,30 @@ public class TcpTransport implements Transport, Closeable {
|
||||
return response;
|
||||
}
|
||||
|
||||
private String readLine(BufferedReader in) throws IOException {
|
||||
while(!socket.isClosed()) {
|
||||
try {
|
||||
return in.readLine();
|
||||
} catch(SocketTimeoutException e) {
|
||||
//ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void connect() throws ServerException {
|
||||
try {
|
||||
socket = createSocket();
|
||||
socket.setSoTimeout(SOCKET_READ_TIMEOUT_MILLIS);
|
||||
running = true;
|
||||
} catch(SSLHandshakeException e) {
|
||||
throw new TlsServerException(server, e);
|
||||
} catch(IOException e) {
|
||||
if(e.getStackTrace().length > 0 && e.getStackTrace()[0].getClassName().contains("SocksSocketImpl")) {
|
||||
throw new ProxyServerException(e);
|
||||
}
|
||||
|
||||
throw new ServerException(e);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +231,6 @@ public class TcpTransport implements Transport, Closeable {
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if(socket != null) {
|
||||
running = false;
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
|
||||
import java.io.File;
|
||||
import java.security.cert.CertificateExpiredException;
|
||||
import java.security.cert.CertificateNotYetValidException;
|
||||
|
||||
public class TlsServerException extends ServerException {
|
||||
private final HostAndPort server;
|
||||
|
||||
public TlsServerException(HostAndPort server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public TlsServerException(HostAndPort server, String message) {
|
||||
super(message);
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public TlsServerException(HostAndPort server, Throwable cause) {
|
||||
this(server, getMessage(cause, server), cause);
|
||||
}
|
||||
|
||||
public TlsServerException(HostAndPort server, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public HostAndPort getServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
private static String getMessage(Throwable cause, HostAndPort server) {
|
||||
if(cause != null) {
|
||||
if(cause.getMessage().contains("PKIX path building failed")) {
|
||||
File configCrtFile = Config.get().getElectrumServerCert();
|
||||
File savedCrtFile = Storage.getCertificateFile(server.getHost());
|
||||
if(configCrtFile != null) {
|
||||
return "Provided server certificate from " + server.getHost() + " did not match configured certificate at " + configCrtFile.getAbsolutePath();
|
||||
} else if(savedCrtFile != null) {
|
||||
return "Provided server certificate from " + server.getHost() + " did not match previously saved certificate at " + savedCrtFile.getAbsolutePath();
|
||||
}
|
||||
|
||||
return "Provided server certificate from " + server.getHost() + " was invalid: " + (cause.getCause() != null ? cause.getCause().getMessage() : cause.getMessage());
|
||||
} else if(cause.getCause() instanceof CertificateNotYetValidException) {
|
||||
return cause.getMessage().replace("NotBefore:", "Certificate only valid from");
|
||||
} else if(cause.getCause() instanceof CertificateExpiredException) {
|
||||
return cause.getMessage().replace("NotAfter:", "Certificate expired at");
|
||||
}
|
||||
|
||||
return cause.getMessage();
|
||||
}
|
||||
|
||||
return "SSL Handshake Error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
public class TorServerAlreadyBoundException extends TorServerException {
|
||||
public TorServerAlreadyBoundException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public TorServerAlreadyBoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
public class TorServerException extends ServerException {
|
||||
public TorServerException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public TorServerException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class TorService extends ScheduledService<NativeTor> {
|
||||
@Override
|
||||
protected Task<NativeTor> createTask() {
|
||||
return new Task<>() {
|
||||
protected NativeTor call() throws IOException {
|
||||
protected NativeTor call() throws IOException, TorServerException {
|
||||
if(Tor.getDefault() == null) {
|
||||
Path path = Files.createTempDirectory(TOR_DIR_PREFIX);
|
||||
File torInstallDir = path.toFile();
|
||||
@@ -45,11 +45,15 @@ public class TorService extends ScheduledService<NativeTor> {
|
||||
|
||||
return new NativeTor(torInstallDir, Collections.emptyList(), override);
|
||||
} catch(TorCtlException e) {
|
||||
log.error("Failed to start Tor", e);
|
||||
if(e.getCause() instanceof TorControlError) {
|
||||
throw new IOException("Failed to start Tor", e.getCause());
|
||||
if(e.getCause().getMessage().contains("Failed to bind")) {
|
||||
throw new TorServerAlreadyBoundException("Tor server already bound", e.getCause());
|
||||
}
|
||||
log.error("Failed to start Tor", e);
|
||||
throw new TorServerException("Failed to start Tor", e.getCause());
|
||||
} else {
|
||||
throw new IOException("Failed to start Tor", e);
|
||||
log.error("Failed to start Tor", e);
|
||||
throw new TorServerException("Failed to start Tor", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import java.security.cert.CertificateException;
|
||||
public class TorTcpOverTlsTransport extends TcpOverTlsTransport {
|
||||
private static final Logger log = LoggerFactory.getLogger(TorTcpOverTlsTransport.class);
|
||||
|
||||
public TorTcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException {
|
||||
public TorTcpOverTlsTransport(HostAndPort server) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
super(server);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class TorTcpOverTlsTransport extends TcpOverTlsTransport {
|
||||
}
|
||||
|
||||
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socket, server.getHost(), server.getPortOrDefault(DEFAULT_PORT), true);
|
||||
sslSocket.startHandshake();
|
||||
startHandshake(sslSocket);
|
||||
|
||||
return sslSocket;
|
||||
}
|
||||
|
||||
+14
-4
@@ -33,6 +33,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
@FXML
|
||||
private ComboBox<ExchangeSource> exchangeSource;
|
||||
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch loadRecentWallets;
|
||||
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch validateDerivationPaths;
|
||||
|
||||
@@ -40,7 +43,7 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
private UnlabeledToggleSwitch groupByAddress;
|
||||
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch includeMempoolChange;
|
||||
private UnlabeledToggleSwitch includeMempoolOutputs;
|
||||
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch notifyNewTransactions;
|
||||
@@ -97,6 +100,12 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
|
||||
updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem());
|
||||
|
||||
loadRecentWallets.setSelected(config.isLoadRecentWallets());
|
||||
loadRecentWallets.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setLoadRecentWallets(newValue);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
});
|
||||
|
||||
validateDerivationPaths.setSelected(config.isValidateDerivationPaths());
|
||||
validateDerivationPaths.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setValidateDerivationPaths(newValue);
|
||||
@@ -104,12 +113,13 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
});
|
||||
|
||||
groupByAddress.setSelected(config.isGroupByAddress());
|
||||
includeMempoolChange.setSelected(config.isIncludeMempoolChange());
|
||||
includeMempoolOutputs.setSelected(config.isIncludeMempoolOutputs());
|
||||
groupByAddress.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setGroupByAddress(newValue);
|
||||
});
|
||||
includeMempoolChange.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setIncludeMempoolChange(newValue);
|
||||
includeMempoolOutputs.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setIncludeMempoolOutputs(newValue);
|
||||
EventManager.get().post(new IncludeMempoolOutputsChangedEvent());
|
||||
});
|
||||
|
||||
notifyNewTransactions.setSelected(config.isNotifyNewTransactions());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user