Compare commits

...
118 Commits
Author SHA1 Message Date
Craig Raw 5c3a00b71b v1.4.3 2021-07-15 12:02:45 +02:00
Craig Raw ced4d4d337 add caravan import/export, minor ui fixes 2021-07-15 11:09:39 +02:00
Craig Raw 4a3ad9f4ff restrict shown sighash to available values 2021-07-14 15:13:38 +02:00
Craig Raw f5a72105ac minor fixes after refactoring 2021-07-12 08:56:37 +02:00
Craig Raw 0502eec0cd dont show message sign context menu item when message signing cant be performed 2021-07-09 13:35:45 +02:00
Craig Raw 8e6933b5ca increase animation rate and fragment length for UR QRs 2021-07-09 08:23:40 +02:00
Craig Raw 1fd1dec6cf rename tx segwit version field to segwit flag 2021-07-08 11:46:21 +02:00
Craig Raw 422713ff53 fix camera selection issues on linux 2021-07-05 12:58:26 +02:00
Craig Raw ada45ee75b allow selection of webcam from QR scan dialog 2021-07-05 12:33:31 +02:00
Craig Raw 2f153686dd avoid hang on closing webcam due to rescans for new camera devices 2021-07-04 19:18:02 +02:00
Craig Raw f691f1691e add missing requires directive for bwt lib 2021-07-02 13:41:22 +02:00
Craig Raw 1f9e37b40c enable max button for selected utxos without address and label filled 2021-07-02 12:05:38 +02:00
Craig Raw a1d2de1859 use all addressable script types (not just single hash types) 2021-07-02 10:44:24 +02:00
Craig Raw 143472bdfc fix save of address labels on a new wallet 2021-07-01 09:41:56 +02:00
Craig Raw b9e64d42ff accept pasting a whitespace delimited sequence of words into
a textfield of mnemonic word entry
2021-06-30 11:55:58 +02:00
Craig Raw 9a09bb8cda always delete hwi dir on osx before copying 2021-06-30 11:51:31 +02:00
Craig Raw 4b028af123 handle multiple selection when freezing and unfreezing utxos 2021-06-30 09:18:14 +02:00
Craig Raw 8033e5fd88 improve amount error labels adding dust threshold label for too low amounts 2021-06-29 12:48:02 +02:00
Craig Raw badf8c8f2f optimize and increase sampling rate of qr reading 2021-06-29 10:53:22 +02:00
Craig Raw b6a353815c switch mempool size chart to kvB when max Y value is less than 1 MvB 2021-06-28 14:14:22 +02:00
Craig Raw ea2f858dc9 close message sign dialog on platform button and escape key 2021-06-28 13:32:24 +02:00
Craig Raw 324540009a fix detection of input type on finalizing psbt wallet 2021-06-28 13:08:44 +02:00
Craig Raw 1c1f90344f update github build to java 16 2021-06-25 15:18:51 +02:00
Craig Raw 094dd45547 upgrade to gradle 7.1, java 16, javafx 16 2021-06-25 14:56:22 +02:00
Craig Raw 6d434722cc fix prev commit 2021-06-24 12:41:32 +02:00
Craig Raw c8a4ed0c3d add documentation link to bitcoin core error message 2021-06-24 10:07:54 +02:00
Craig Raw c8d997fbf0 v1.4.2 2021-06-23 12:51:38 +02:00
Craig Raw 911ed3a718 improve background text 2021-06-23 12:30:58 +02:00
Craig Raw dbfed31432 add format toggle to message signing dialog (electrum or trezor) 2021-06-22 16:00:15 +02:00
Craig Raw 6f3d4e224e add context menu item to copy transaction hex 2021-06-22 11:47:56 +02:00
Craig Raw 4d6609990c fix error messages on subclassed importers/exporters 2021-06-22 10:13:37 +02:00
Craig Raw 5482196cc7 dark theme improvements 2021-06-22 08:36:16 +02:00
Craig Raw 09f6c9ef81 ensure cleanup of migrated wallets when importing 2021-06-21 11:50:58 +02:00
Craig Raw 9b8f97c041 fix import of sparrow wallet with seed, alphabetically sort import and export choices 2021-06-21 11:25:47 +02:00
Craig Raw c68c713a4b add orig files for keystone and seedsigner 2021-06-18 13:31:08 +02:00
Craig Raw 02e144f802 set keystore label, model and source type when importing a wallet from specter desktop 2021-06-18 11:21:53 +02:00
Craig Raw a9ab4d6c78 Handle importing a wallet from a crypto-account QR using the File > Import Wallet dialog 2021-06-17 14:34:11 +02:00
Craig Raw 5df4e5761c add seedsigner keystore import 2021-06-16 14:46:55 +02:00
Craig Raw 6d8b8579ba followup 2021-06-16 09:37:35 +02:00
Craig Raw eaa5190502 various db persistence improvements 2021-06-16 09:35:26 +02:00
Craig Raw 445db6a4d6 reduce file reads on db files to avoid locking exception on windows 2021-06-15 17:58:25 +02:00
Craig Raw 7f178b5f67 jpms related changes for v1.4.2-beta 2021-06-15 16:50:05 +02:00
Craig Raw 1208baf00e use mempool.space onion address for fee rates if tor proxy enabled 2021-06-15 09:31:12 +02:00
Craig Raw 655a473cd5 update send selected button when freezing or unfreezing utxos 2021-06-14 16:58:05 +02:00
Craig Raw e6c536930b highlight default button and allow actioning from keyboard when only one usb device is listed 2021-06-14 16:55:01 +02:00
Craig Raw f1510de360 update encrypted seeds and private keys when wallet password changes 2021-06-14 14:54:40 +02:00
Craig Raw cfac2768ae use varbinary rather than fixed binary column types where length is variable 2021-06-14 11:52:12 +02:00
Craig Raw ab41f2e80e upgrade to h2 HEAD 2021-06-11 15:45:16 +02:00
Craig Raw 9ebabecfbe use json persistence for sparrow wallet export, add sparrow wallet import to wallets dir 2021-06-11 11:29:54 +02:00
Craig Raw 8914acff68 minor followup 2021-06-10 17:02:32 +02:00
Craig Raw 4a0ecba716 add keystone hww import and export 2021-06-10 16:37:41 +02:00
Craig Raw e99b1d4171 upgrade to hwi 2.0.2 and relocate hwi to sparrow home folder on osx to avoid partial installation deletions in tmpdir 2021-06-10 14:06:14 +02:00
Craig Raw a59d5d3086 introduce database persistence with automatic migration of existing wallets 2021-06-10 12:08:35 +02:00
Craig Raw 600a77da3a allow psbts without utxo data to be loaded if utxos are provided in an existing psbt 2021-05-28 11:09:23 +02:00
craigrawandGitHub bc83f6fa22 Merge pull request #134 from haakonn/mnemonic-entry
Make mnemonic entry more efficient
2021-05-27 16:18:39 +02:00
Haakon Nilsen 4cbde7e7aa When entering mnemonic words, don't close dropdown when a prefix is encountered, and move focus to the next field upon completion 2021-05-27 10:56:47 +02:00
craigrawandGitHub 3ae63408e6 Merge pull request #133 from haakonn/save-tx-menuitems
Fixes around transaction saving menu items
2021-05-27 09:58:11 +02:00
Haakon Nilsen e740c6d162 Disable transaction saving menu items after a transaction tab is closed and no other tabs are open 2021-05-27 09:15:36 +02:00
Haakon Nilsen f7f5852476 Disable the "Save transaction" menu item when starting app without any open tabs 2021-05-26 18:57:07 +02:00
Haakon Nilsen 447e2ab264 Disable the keyboard shortcut for saving PSBT binary when its parent menu is disabled 2021-05-26 18:54:20 +02:00
craigrawandGitHub 8a77f22158 Merge pull request #132 from haakonn/close-on-escape
Close "About" and "Introduction" when Escape key is pressed
2021-05-26 08:39:16 +02:00
Haakon Nilsen c096327be4 Close "About" and "Introduction" when Escape key is pressed 2021-05-25 22:13:51 +02:00
Craig Raw 911153e1aa fix receive to address context menu actions 2021-05-25 08:21:17 +02:00
Craig Raw a60eadf8fc add export to specter diy 2021-05-24 14:20:08 +02:00
Craig Raw 9ebbf2557f support electrs batching and improve batching read timeout handling 2021-05-24 13:39:06 +02:00
Craig Raw 42b279d22a add specter desktop reimport usb wallets warning 2021-05-24 12:29:57 +02:00
Craig Raw 1a452db4cf add tooltip to send utxos button with directions on how to select multiple utxos 2021-05-24 12:27:30 +02:00
Craig Raw c1cf5be616 add mempool.bisq.services as a broadcaster, broadcast tx twice if possible on mainnet, handle different network broadcaster network capabilities 2021-05-21 11:48:13 +02:00
Craig Raw dd146210ba set send amount to total utxo value when sending selected utxos before an address or label is added 2021-05-21 09:53:00 +02:00
Craig Raw 94088f795c handle invalid values from mempool.get_fee_histogram 2021-05-21 08:44:55 +02:00
Craig Raw c5b09189df explicitly place decorationpane in the scene graph to avoid app resizing issues 2021-05-20 17:27:33 +02:00
Craig Raw 197c44bb07 update null and empty labels, clear script hash cache on increasing gap limit 2021-05-20 14:49:16 +02:00
Craig Raw c202a941b9 add mempool.emzy.de as another broadcaster 2021-05-20 14:48:01 +02:00
Craig Raw 383594b03c v1.4.1 2021-05-20 12:32:15 +02:00
Craig Raw 42bfe572ef calculate and cache script hashes statuses on wallet load to avoid unnecessary initial history fetching 2021-05-20 09:52:02 +02:00
Craig Raw 1677c47500 request payjoin transactions (over tor if available) in background thread 2021-05-19 12:24:46 +02:00
Craig Raw 53a447c72d fix cpu usage on spreadsheetview 2021-05-19 09:51:55 +02:00
Craig Raw f23a891ece add send to many dialog 2021-05-19 08:41:15 +02:00
Craig Raw 0e42c657b3 broadcast transactions over tor to a broadcasting service where tor proxy available 2021-05-18 09:37:59 +02:00
Craig Raw b17c15f702 request new change address in the transaction diagram 2021-05-17 13:21:56 +02:00
Craig Raw c9cdf6e77d handle partial rbf, fix cursor reset on payment label 2021-05-17 11:58:12 +02:00
Craig Raw 4f6ead842d fix txtree selection bug when preselecting a txi/txo on opening the editor for the first time 2021-05-17 10:53:26 +02:00
Craig Raw adcddfa84d various minor utxo screen improvements, including hiding the utxo chart 2021-05-17 10:09:19 +02:00
Craig Raw d67c5c5218 update related address, tx, txi and txo labels when null, set tx, txi, txo labels to address label on receive, add paste label to context menu 2021-05-14 18:03:46 +02:00
Craig Raw fb72010bdf refactor storage to handle different persistence strategies 2021-05-14 12:55:12 +02:00
Craig Raw 8bc8bdb2f2 dont backup wallets outside of wallets folder 2021-05-12 14:52:19 +02:00
Craig Raw 8f4cf9f2a0 add unique4j classes to avoid module dependency issue 2021-05-12 14:28:56 +02:00
Craig Raw ed323a8388 ensure aopp window is always on top 2021-05-12 09:19:49 +02:00
Craig Raw d559b437b3 bring window to front one external file load 2021-05-12 09:02:39 +02:00
Craig Raw 6155306acc always try obtain application lock on startup 2021-05-12 08:46:24 +02:00
Craig Raw 6f35d86890 bring opened file window to foreground 2021-05-11 12:58:11 +02:00
Craig Raw cbf32a36c6 follow up #2 2021-05-11 12:40:20 +02:00
Craig Raw 9789e0f198 follow up 2021-05-11 12:33:33 +02:00
Craig Raw 574209c837 ensure file and uri opening on original app instance 2021-05-11 12:31:36 +02:00
Craig Raw 74c83fc5e1 fade animation on server toggle while connecting 2021-05-11 08:52:45 +02:00
Craig Raw a4ddc11bab Merge branch 'master' of github.com:sparrowwallet/sparrow 2021-05-10 14:34:57 +02:00
Craig Raw 3bf480234f fix aopp alert dialog on windows 2021-05-10 14:25:16 +02:00
Craig Raw 29811ca5de handle uri and psbt opening on linux 2021-05-10 14:22:24 +02:00
Craig Raw b7522420cf cleanup windows install 2021-05-10 13:41:46 +02:00
Craig Raw f7d1e22e4e use custom wix installation file 2021-05-10 13:39:40 +02:00
Craig Raw e0c38031b1 add aopp registry keys on windows install 2021-05-10 12:17:52 +02:00
Craig Raw e046512e86 open files or uris from commandline arguments 2021-05-10 11:44:06 +02:00
Craig Raw 7cba0de268 check if opening uris is supported 2021-05-10 08:42:50 +02:00
Craig Raw 425e476f20 add aopp proof of address ownership support 2021-05-07 16:24:28 +02:00
Craig Raw e5dd33d5a1 register and add bitcoin: uri handler, register aopp: handler 2021-05-06 14:21:56 +02:00
Craig Raw 07012615ff 2nd fix to alert height 2021-05-05 14:59:53 +02:00
Craig Raw 23f3ae51db hide alert on clicking link 2021-05-05 14:43:33 +02:00
Craig Raw 10514b8039 shorten mitm message to avoid alert height ellipsis 2021-05-05 14:35:43 +02:00
Craig Raw 19637fd706 handle missing webcam dependency on windows showing error alert with link to resolve 2021-05-05 14:31:28 +02:00
Craig Raw dd56e2b42e handle windows with owner only file and directory creation 2021-05-05 12:05:48 +02:00
Craig Raw e0b6530275 only save tmp backup wallet if connected 2021-05-05 11:34:45 +02:00
Craig Raw a0d3f3b745 ensure config, wallet dirs and wallets are owner read/write only 2021-05-05 11:25:25 +02:00
Craig Raw 438c13fe2d add wallet transactions loading log viewer, and improve loading messages 2021-05-04 15:17:57 +02:00
Craig Raw c067300ee8 fix empty addresses table bug after requesting many new addresses 2021-05-04 10:42:18 +02:00
Craig Raw 020ca2f312 always use 8 fraction digits when displaying btc amounts, use a fixed width font for numbers on osx 2021-05-04 10:23:35 +02:00
Craig Raw 5ede1dd97d ensure wallet export file streams are always closed 2021-04-30 14:03:58 +02:00
Craig Raw 45f6ae214a add icons to wallet context menu items where they appear as action buttons 2021-04-30 13:19:53 +02:00
Craig Raw 45a9093055 ensure import file stream is closed 2021-04-29 20:58:04 +02:00
185 changed files with 8179 additions and 1271 deletions
+4 -9
View File
@@ -12,16 +12,11 @@ jobs:
- uses: actions/checkout@v2
with:
submodules: true
- name: Set up JDK 15.0.2
if: ${{ runner.os == 'Linux' }}
uses: joschi/setup-jdk@v2
- name: Set up JDK 16.0.1
uses: actions/setup-java@v2
with:
java-version: 15
- name: Set up JDK 14.0.2
if: ${{ runner.os == 'Windows' }}
uses: actions/setup-java@v1
with:
java-version: 14.0.2
distribution: 'adopt'
java-version: '16.0.1'
- name: Show Build Versions
run: ./gradlew -v
- name: Cache Gradle packages
+241 -21
View File
@@ -1,11 +1,11 @@
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.0.9'
id 'org.kordamp.gradle.jdeps' version '0.9.0'
id 'org.beryx.jlink' version '2.22.0'
id 'extra-java-module-info'
id 'com.dua3.javafxgradle7plugin' version '0.0.9'
id 'org.beryx.jlink' version '2.24.0'
}
def sparrowVersion = '1.4.0'
def sparrowVersion = '1.4.3'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -29,7 +29,7 @@ tasks.withType(AbstractArchiveTask) {
}
javafx {
version = "15"
version = "16"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -44,23 +44,39 @@ dependencies {
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:1.4.201-SNAPSHOT')
implementation('com.zaxxer:HikariCP:4.0.3')
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0')
implementation('org.flywaydb:flyway-core:7.10.5-SNAPSHOT')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0')
implementation('com.github.arteam:simple-json-rpc-client:1.0')
implementation('com.google.zxing:javase:3.4.0') {
exclude group: 'com.beust', module: 'jcommander'
}
implementation('com.beust:jcommander:1.81')
implementation('com.github.arteam:simple-json-rpc-core:1.0')
implementation('com.github.arteam:simple-json-rpc-client:1.0') {
exclude group: 'com.github.arteam', module: 'simple-json-rpc-core'
}
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.0')
implementation('com.sparrowwallet:hummingbird:1.6.1')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
exclude group: 'com.google.android.tools', module: 'dx'
}
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8")
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.0.2' ) {
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics'
exclude group: 'org.openjfx', module: 'javafx-controls'
@@ -69,7 +85,7 @@ dependencies {
exclude group: 'org.openjfx', module: 'javafx-web'
exclude group: 'org.openjfx', module: 'javafx-media'
}
implementation('dev.bwt:bwt-jni:0.1.7')
implementation('dev.bwt:bwt-jni:0.1.8')
implementation('net.sourceforge.javacsv:javacsv:2.0')
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
@@ -77,7 +93,10 @@ dependencies {
testImplementation('junit:junit:4.12')
}
mainClassName = 'com.sparrowwallet.sparrow/com.sparrowwallet.sparrow.MainApp'
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.MainApp'
}
compileJava {
options.with {
@@ -105,6 +124,7 @@ run {
"--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls",
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
@@ -127,9 +147,11 @@ jlink {
requires 'java.xml'
requires 'java.logging'
requires 'javafx.base'
requires 'com.fasterxml.jackson.databind'
requires 'jdk.crypto.cryptoki'
requires 'java.management'
requires 'io.leangen.geantyref'
uses 'org.flywaydb.core.extensibility.FlywayExtension'
uses 'org.flywaydb.core.internal.database.DatabaseType'
}
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png']
@@ -143,15 +165,19 @@ jlink {
"--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls",
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.tk=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.merged.module",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.menu=com.sparrowwallet.merged.module",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop"]
"--add-reads=com.sparrowwallet.merged.module=java.desktop",
"--add-reads=com.sparrowwallet.merged.module=java.sql",
"--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=logback.classic"]
if(os.macOsX) {
jvmArgs += "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"
@@ -164,9 +190,9 @@ jlink {
appVersion = "${sparrowVersion}"
skipInstaller = os.macOsX
imageOptions = []
installerOptions = ['--file-associations', 'src/main/deploy/associations.properties', '--license-file', 'LICENSE']
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/aopp.properties', '--license-file', 'LICENSE']
if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut']
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
installerType = "exe"
}
@@ -203,3 +229,197 @@ task packageTarDistribution(type: Tar) {
include "Sparrow/**"
}
}
extraJavaModuleInfo {
module('tornadofx-controls-1.0.4.jar', 'tornadofx.controls', '1.0.4') {
exports('tornadofx.control')
requires('javafx.controls')
}
module('simple-json-rpc-core-1.0.jar', 'simple.json.rpc.core', '1.0') {
exports('com.github.arteam.simplejsonrpc.core.annotation')
exports('com.github.arteam.simplejsonrpc.core.domain')
requires('com.fasterxml.jackson.core')
requires('com.fasterxml.jackson.annotation')
requires('com.fasterxml.jackson.databind')
requires('org.jetbrains.annotations')
}
module('simple-json-rpc-client-1.0.jar', 'simple.json.rpc.client', '1.0') {
exports('com.github.arteam.simplejsonrpc.client')
exports('com.github.arteam.simplejsonrpc.client.builder')
exports('com.github.arteam.simplejsonrpc.client.exception')
requires('com.fasterxml.jackson.databind')
requires('simple.json.rpc.core')
}
module('simple-json-rpc-server-1.0.jar', 'simple.json.rpc.server', '1.0') {
exports('com.github.arteam.simplejsonrpc.server')
requires('simple.json.rpc.core')
requires('com.google.common')
requires('org.slf4j')
requires('com.fasterxml.jackson.databind')
}
module('bridj-0.7-20140918-3.jar', 'com.nativelibs4java.bridj', '0.7-20140918-3') {
exports('org.bridj')
exports('org.bridj.cpp')
requires('java.logging')
}
module('webcam-capture-0.3.13-SNAPSHOT.jar', 'com.github.sarxos.webcam.capture', '0.3.13-SNAPSHOT') {
exports('com.github.sarxos.webcam')
exports('com.github.sarxos.webcam.ds.buildin')
exports('com.github.sarxos.webcam.ds.buildin.natives')
requires('java.desktop')
requires('com.nativelibs4java.bridj')
requires('org.slf4j')
}
module('centerdevice-nsmenufx-2.1.7.jar', 'centerdevice.nsmenufx', '2.1.7') {
exports('de.codecentric.centerdevice')
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
}
module('javacsv-2.0.jar', 'net.sourceforge.javacsv', '2.0') {
exports('com.csvreader')
}
module('jul-to-slf4j-1.7.30.jar', 'org.slf4j.jul.to.slf4j', '1.7.30') {
exports('org.slf4j.bridge')
requires('java.logging')
requires('org.slf4j')
}
module('jeromq-0.5.0.jar', 'jeromq', '0.5.0') {
exports('org.zeromq')
}
module('json-simple-1.1.1.jar', 'json.simple', '1.1.1') {
exports('org.json.simple')
}
module('logback-classic-1.2.3.jar', 'logback.classic', '1.2.3') {
exports('ch.qos.logback.classic')
requires('org.slf4j')
requires('logback.core')
requires('java.xml')
requires('java.logging')
}
module('kotlin-logging-1.5.4.jar', 'io.github.microutils.kotlin.logging', '1.5.4') {
exports('mu')
requires('kotlin.stdlib')
requires('org.slf4j')
}
module('failureaccess-1.0.1.jar', 'failureaccess', '1.0.1') {
exports('com.google.common.util.concurrent.internal')
}
module('listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar', 'com.google.guava.listenablefuture', '9999.0-empty-to-avoid-conflict-with-guava')
module('guava-28.2-jre.jar', 'com.google.common', '28.2-jre') {
exports('com.google.common.eventbus')
exports('com.google.common.net')
exports('com.google.common.base')
exports('com.google.common.collect')
exports('com.google.common.io')
requires('failureaccess')
requires('java.logging')
}
module('jsr305-3.0.2.jar', 'com.google.code.findbugs.jsr305', '3.0.2')
module('j2objc-annotations-1.3.jar', 'com.google.j2objc.j2objc.annotations', '1.3')
module('jdbi3-core-3.20.0.jar', 'org.jdbi.v3.core', '3.20.0') {
exports('org.jdbi.v3.core')
exports('org.jdbi.v3.core.mapper')
exports('org.jdbi.v3.core.statement')
exports('org.jdbi.v3.core.result')
exports('org.jdbi.v3.core.h2')
exports('org.jdbi.v3.core.spi')
requires('io.leangen.geantyref')
requires('java.sql')
requires('org.slf4j')
}
module('geantyref-1.3.11.jar', 'io.leangen.geantyref', '1.3.11') {
exports('io.leangen.geantyref')
}
module('richtextfx-0.10.4.jar', 'org.fxmisc.richtext', '0.10.4') {
exports('org.fxmisc.richtext')
exports('org.fxmisc.richtext.event')
exports('org.fxmisc.richtext.model')
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
requires('org.fxmisc.flowless')
requires('org.reactfx.reactfx')
requires('org.fxmisc.undo.undofx')
requires('org.fxmisc.wellbehaved')
}
module('undofx-2.1.0.jar', 'org.fxmisc.undo.undofx', '2.1.0') {
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
requires('org.reactfx.reactfx')
}
module('flowless-0.6.1.jar', 'org.fxmisc.flowless', '0.6.1') {
exports('org.fxmisc.flowless')
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
requires('org.reactfx.reactfx')
}
module('reactfx-2.0-M5.jar', 'org.reactfx.reactfx', '2.0-M5') {
exports('org.reactfx')
exports('org.reactfx.value')
exports('org.reactfx.collection')
exports('org.reactfx.util')
requires('javafx.base')
requires('javafx.graphics')
requires('javafx.controls')
}
module('wellbehavedfx-0.3.3.jar', 'org.fxmisc.wellbehaved', '0.3.3') {
requires('javafx.base')
requires('javafx.graphics')
}
module('jai-imageio-core-1.4.0.jar', 'com.github.jai.imageio.jai.imageio.core', '1.4.0')
module('kotlin-stdlib-jdk8-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk8', '1.5.20')
module('kotlin-stdlib-jdk7-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk7', '1.5.20')
module('kotlin-stdlib-1.5.20.jar', 'kotlin.stdlib', '1.5.20') {
exports('kotlin')
}
module('hummingbird-1.6.1.jar', 'com.sparrowwallet.hummingbird', '1.6.1') {
exports('com.sparrowwallet.hummingbird')
exports('com.sparrowwallet.hummingbird.registry')
requires('co.nstant.in.cbor')
}
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module("netlayer-jpms-${osName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
exports('org.berndpruenster.netlayer.tor')
requires('com.github.ravn.jsocks')
requires('com.github.JesusMcCloud.jtorctl')
requires('kotlin.stdlib')
requires('commons.compress')
requires('org.tukaani.xz')
requires('java.management')
requires('io.github.microutils.kotlin.logging')
}
module('jtorctl-1.5.jar', 'com.github.JesusMcCloud.jtorctl', '1.5') {
exports('net.freehaven.tor.control')
}
module('commons-compress-1.18.jar', 'commons.compress', '1.18') {
exports('org.apache.commons.compress')
requires('org.tukaani.xz')
}
module('xz-1.6.jar', 'org.tukaani.xz', '1.6') {
exports('org.tukaani.xz')
}
module('jsocks-1.0.jar', 'com.github.ravn.jsocks', '1.0') {
exports('com.runjva.sourceforge.jsocks.protocol')
requires('org.slf4j')
}
module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0')
module('logback-core-1.2.3.jar', 'logback.core', '1.2.3') {
requires('java.xml')
}
module('kotlin-stdlib-common-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.common', '1.5.20') {
exports('kotlin.jvm')
exports('kotlin.collections')
}
module('jcommander-1.81.jar', 'com.beust.jcommander', '1.81') {
exports('com.beust.jcommander')
}
module('junit-4.12.jar', 'junit', '4.12') {
exports('org.junit')
}
module('hamcrest-core-1.3.jar', 'org.hamcrest.core', '1.3')
}
+21
View File
@@ -0,0 +1,21 @@
plugins {
id 'java-gradle-plugin' // so we can assign and ID to our plugin
}
dependencies {
implementation 'org.ow2.asm:asm:8.0.1'
}
repositories {
mavenCentral()
}
gradlePlugin {
plugins {
// here we register our plugin with an ID
register("extra-java-module-info") {
id = "extra-java-module-info"
implementationClass = "org.gradle.sample.transform.javamodules.ExtraModuleInfoPlugin"
}
}
}
@@ -0,0 +1,54 @@
package org.gradle.sample.transform.javamodules;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.attributes.Attribute;
import org.gradle.api.plugins.JavaPlugin;
/**
* Entry point of our plugin that should be applied in the root project.
*/
public class ExtraModuleInfoPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
// register the plugin extension as 'extraJavaModuleInfo {}' configuration block
ExtraModuleInfoPluginExtension extension = project.getObjects().newInstance(ExtraModuleInfoPluginExtension.class);
project.getExtensions().add(ExtraModuleInfoPluginExtension.class, "extraJavaModuleInfo", extension);
// setup the transform for all projects in the build
project.getPlugins().withType(JavaPlugin.class).configureEach(javaPlugin -> configureTransform(project, extension));
}
private void configureTransform(Project project, ExtraModuleInfoPluginExtension extension) {
Attribute<String> artifactType = Attribute.of("artifactType", String.class);
Attribute<Boolean> javaModule = Attribute.of("javaModule", Boolean.class);
// compile and runtime classpath express that they only accept modules by requesting the javaModule=true attribute
project.getConfigurations().matching(this::isResolvingJavaPluginConfiguration).all(
c -> c.getAttributes().attribute(javaModule, true));
// all Jars have a javaModule=false attribute by default; the transform also recognizes modules and returns them without modification
project.getDependencies().getArtifactTypes().getByName("jar").getAttributes().attribute(javaModule, false);
// register the transform for Jars and "javaModule=false -> javaModule=true"; the plugin extension object fills the input parameter
project.getDependencies().registerTransform(ExtraModuleInfoTransform.class, t -> {
t.parameters(p -> {
p.setModuleInfo(extension.getModuleInfo());
p.setAutomaticModules(extension.getAutomaticModules());
});
t.getFrom().attribute(artifactType, "jar").attribute(javaModule, false);
t.getTo().attribute(artifactType, "jar").attribute(javaModule, true);
});
}
private boolean isResolvingJavaPluginConfiguration(Configuration configuration) {
if (!configuration.isCanBeResolved()) {
return false;
}
return configuration.getName().endsWith(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME.substring(1))
|| configuration.getName().endsWith(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME.substring(1))
|| configuration.getName().endsWith(JavaPlugin.ANNOTATION_PROCESSOR_CONFIGURATION_NAME.substring(1));
}
}
@@ -0,0 +1,52 @@
package org.gradle.sample.transform.javamodules;
import org.gradle.api.Action;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* A data class to collect all the module information we want to add.
* Here the class is used as extension that can be configured in the build script
* and as input to the ExtraModuleInfoTransform that add the information to Jars.
*/
public class ExtraModuleInfoPluginExtension {
private final Map<String, ModuleInfo> moduleInfo = new HashMap<>();
private final Map<String, String> automaticModules = new HashMap<>();
/**
* Add full module information for a given Jar file.
*/
public void module(String jarName, String moduleName, String moduleVersion) {
module(jarName, moduleName, moduleVersion, null);
}
/**
* Add full module information, including exported packages and dependencies, for a given Jar file.
*/
public void module(String jarName, String moduleName, String moduleVersion, @Nullable Action<? super ModuleInfo> conf) {
ModuleInfo moduleInfo = new ModuleInfo(moduleName, moduleVersion);
if (conf != null) {
conf.execute(moduleInfo);
}
this.moduleInfo.put(jarName, moduleInfo);
}
/**
* Add only an automatic module name to a given jar file.
*/
public void automaticModule(String jarName, String moduleName) {
automaticModules.put(jarName, moduleName);
}
protected Map<String, ModuleInfo> getModuleInfo() {
return moduleInfo;
}
protected Map<String, String> getAutomaticModules() {
return automaticModules;
}
}
@@ -0,0 +1,164 @@
package org.gradle.sample.transform.javamodules;
import org.gradle.api.artifacts.transform.InputArtifact;
import org.gradle.api.artifacts.transform.TransformAction;
import org.gradle.api.artifacts.transform.TransformOutputs;
import org.gradle.api.artifacts.transform.TransformParameters;
import org.gradle.api.file.FileSystemLocation;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.Input;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.ModuleVisitor;
import org.objectweb.asm.Opcodes;
import java.io.*;
import java.util.Collections;
import java.util.Map;
import java.util.jar.*;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
/**
* An artifact transform that applies additional information to Jars without module information.
* The transformation fails the build if a Jar does not contain information and no extra information
* was defined for it. This way we make sure that all Jars are turned into modules.
*/
abstract public class ExtraModuleInfoTransform implements TransformAction<ExtraModuleInfoTransform.Parameter> {
public static class Parameter implements TransformParameters, Serializable {
private Map<String, ModuleInfo> moduleInfo = Collections.emptyMap();
private Map<String, String> automaticModules = Collections.emptyMap();
@Input
public Map<String, ModuleInfo> getModuleInfo() {
return moduleInfo;
}
@Input
public Map<String, String> getAutomaticModules() {
return automaticModules;
}
public void setModuleInfo(Map<String, ModuleInfo> moduleInfo) {
this.moduleInfo = moduleInfo;
}
public void setAutomaticModules(Map<String, String> automaticModules) {
this.automaticModules = automaticModules;
}
}
@InputArtifact
protected abstract Provider<FileSystemLocation> getInputArtifact();
@Override
public void transform(TransformOutputs outputs) {
Map<String, ModuleInfo> moduleInfo = getParameters().moduleInfo;
Map<String, String> automaticModules = getParameters().automaticModules;
File originalJar = getInputArtifact().get().getAsFile();
String originalJarName = originalJar.getName();
if (isModule(originalJar)) {
outputs.file(originalJar);
} else if (moduleInfo.containsKey(originalJarName)) {
addModuleDescriptor(originalJar, getModuleJar(outputs, originalJar), moduleInfo.get(originalJarName));
} else if (isAutoModule(originalJar)) {
outputs.file(originalJar);
} else if (automaticModules.containsKey(originalJarName)) {
addAutomaticModuleName(originalJar, getModuleJar(outputs, originalJar), automaticModules.get(originalJarName));
} else {
throw new RuntimeException("Not a module and no mapping defined: " + originalJarName);
}
}
private boolean isModule(File jar) {
Pattern moduleInfoClassMrjarPath = Pattern.compile("META-INF/versions/\\d+/module-info.class");
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(jar))) {
boolean isMultiReleaseJar = containsMultiReleaseJarEntry(inputStream);
ZipEntry next = inputStream.getNextEntry();
while (next != null) {
if ("module-info.class".equals(next.getName())) {
return true;
}
if (isMultiReleaseJar && moduleInfoClassMrjarPath.matcher(next.getName()).matches()) {
return true;
}
next = inputStream.getNextEntry();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return false;
}
private boolean containsMultiReleaseJarEntry(JarInputStream jarStream) {
Manifest manifest = jarStream.getManifest();
return manifest != null && Boolean.parseBoolean(manifest.getMainAttributes().getValue("Multi-Release"));
}
private boolean isAutoModule(File jar) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(jar))) {
return inputStream.getManifest().getMainAttributes().getValue("Automatic-Module-Name") != null;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private File getModuleJar(TransformOutputs outputs, File originalJar) {
return outputs.file(originalJar.getName().substring(0, originalJar.getName().lastIndexOf('.')) + "-module.jar");
}
private static void addAutomaticModuleName(File originalJar, File moduleJar, String moduleName) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) {
Manifest manifest = inputStream.getManifest();
manifest.getMainAttributes().put(new Attributes.Name("Automatic-Module-Name"), moduleName);
try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), inputStream.getManifest())) {
copyEntries(inputStream, outputStream);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static void addModuleDescriptor(File originalJar, File moduleJar, ModuleInfo moduleInfo) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) {
try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), inputStream.getManifest())) {
copyEntries(inputStream, outputStream);
outputStream.putNextEntry(new JarEntry("module-info.class"));
outputStream.write(addModuleInfo(moduleInfo));
outputStream.closeEntry();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static void copyEntries(JarInputStream inputStream, JarOutputStream outputStream) throws IOException {
JarEntry jarEntry = inputStream.getNextJarEntry();
while (jarEntry != null) {
outputStream.putNextEntry(jarEntry);
outputStream.write(inputStream.readAllBytes());
outputStream.closeEntry();
jarEntry = inputStream.getNextJarEntry();
}
}
private static byte[] addModuleInfo(ModuleInfo moduleInfo) {
ClassWriter classWriter = new ClassWriter(0);
classWriter.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null);
ModuleVisitor moduleVisitor = classWriter.visitModule(moduleInfo.getModuleName(), Opcodes.ACC_OPEN, moduleInfo.getModuleVersion());
for (String packageName : moduleInfo.getExports()) {
moduleVisitor.visitExport(packageName.replace('.', '/'), 0);
}
moduleVisitor.visitRequire("java.base", 0, null);
for (String requireName : moduleInfo.getRequires()) {
moduleVisitor.visitRequire(requireName, 0, null);
}
for (String requireName : moduleInfo.getRequiresTransitive()) {
moduleVisitor.visitRequire(requireName, Opcodes.ACC_TRANSITIVE, null);
}
moduleVisitor.visitEnd();
classWriter.visitEnd();
return classWriter.toByteArray();
}
}
@@ -0,0 +1,53 @@
package org.gradle.sample.transform.javamodules;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* Data class to hold the information that should be added as module-info.class to an existing Jar file.
*/
public class ModuleInfo implements Serializable {
private String moduleName;
private String moduleVersion;
private List<String> exports = new ArrayList<>();
private List<String> requires = new ArrayList<>();
private List<String> requiresTransitive = new ArrayList<>();
ModuleInfo(String moduleName, String moduleVersion) {
this.moduleName = moduleName;
this.moduleVersion = moduleVersion;
}
public void exports(String exports) {
this.exports.add(exports);
}
public void requires(String requires) {
this.requires.add(requires);
}
public void requiresTransitive(String requiresTransitive) {
this.requiresTransitive.add(requiresTransitive);
}
public String getModuleName() {
return moduleName;
}
protected String getModuleVersion() {
return moduleVersion;
}
protected List<String> getExports() {
return exports;
}
protected List<String> getRequires() {
return requires;
}
protected List<String> getRequiresTransitive() {
return requiresTransitive;
}
}
+1 -1
Submodule drongo updated: db9617ee10...db081695e8
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+2
View File
@@ -82,6 +82,7 @@ esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
@@ -129,6 +130,7 @@ fi
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
Vendored
+4 -18
View File
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -54,7 +54,7 @@ goto fail
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
@@ -64,28 +64,14 @@ echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
+2
View File
@@ -0,0 +1,2 @@
mime-type=x-scheme-handler/aopp
description=Verify Address Ownership URI
-3
View File
@@ -1,3 +0,0 @@
extension=psbt
mime-type=application/octet-stream
description=Partially Signed Bitcoin Transaction
+2
View File
@@ -0,0 +1,2 @@
mime-type=x-scheme-handler/bitcoin
description=Bitcoin Scheme URI
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Sparrow
Comment=Sparrow
Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/aopp
+84 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.4.0</string>
<string>1.4.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -35,5 +35,88 @@
<string>true</string>
<key>NSCameraUsageDescription</key>
<string>Sparrow requires access to the camera in order to scan QR codes</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.bitcoin</string>
<key>CFBundleURLSchemes</key>
<array>
<string>bitcoin</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.aopp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>aopp</string>
</array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.bitcoin.psbt</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>psbt</string>
</array>
</dict>
<key>UTTypeDescription</key>
<string>Partially Signed Bitcoin Transaction</string>
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.bitcoin.txn</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>txn</string>
</array>
</dict>
<key>UTTypeDescription</key>
<string>Bitcoin Transaction</string>
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>sparrow.icns</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>psbt</string>
</array>
<key>CFBundleTypeName</key>
<string>Partially Signed Bitcoin Transaction</string>
<key>LSHandlerRank</key>
<string>Default</string>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>sparrow.icns</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>txn</string>
</array>
<key>CFBundleTypeName</key>
<string>Bitcoin Transaction</string>
<key>LSHandlerRank</key>
<string>Default</string>
</dict>
</array>
</dict>
</plist>
+163
View File
@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<?ifdef JpIsSystemWide ?>
<?define JpInstallScope="perMachine"?>
<?else?>
<?define JpInstallScope="perUser"?>
<?endif?>
<?define JpProductLanguage=1033 ?>
<?define JpInstallerVersion=200 ?>
<?define JpCompressedMsi=yes ?>
<?include $(var.JpConfigDir)/overrides.wxi ?>
<Product
Id="$(var.JpProductCode)"
Name="$(var.JpAppName)"
Language="$(var.JpProductLanguage)"
Version="$(var.JpAppVersion)"
Manufacturer="$(var.JpAppVendor)"
UpgradeCode="$(var.JpProductUpgradeCode)">
<Package
Description="$(var.JpAppDescription)"
Manufacturer="$(var.JpAppVendor)"
InstallerVersion="$(var.JpInstallerVersion)"
Compressed="$(var.JpCompressedMsi)"
InstallScope="$(var.JpInstallScope)" Platform="x64"
/>
<Media Id="1" Cabinet="Data.cab" EmbedCab="yes" />
<?ifdef JpAllowDowngrades ?>
<?ifdef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="yes"
Disallow="no"
/>
<?endif?>
<?endif?>
<?ifdef JpAllowDowngrades ?>
<?ifndef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="yes"
Disallow="yes"
DisallowUpgradeErrorMessage="!(loc.DisallowUpgradeErrorMessage)"
/>
<?endif?>
<?endif?>
<?ifndef JpAllowDowngrades ?>
<?ifdef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="no"
Disallow="no"
DowngradeErrorMessage="!(loc.DowngradeErrorMessage)"
/>
<?endif?>
<?endif?>
<?ifndef JpAllowDowngrades ?>
<?ifndef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="no"
Disallow="yes"
DowngradeErrorMessage="!(loc.DowngradeErrorMessage)"
DisallowUpgradeErrorMessage="!(loc.DisallowUpgradeErrorMessage)"
/>
<?endif?>
<?endif?>
<!-- Standard required root -->
<Directory Id="TARGETDIR" Name="SourceDir"/>
<DirectoryRef Id="TARGETDIR">
<Component Id="RegistryEntries" Guid="{206C911C-56EF-44B8-9257-5FD214427965}">
<RegistryKey Root="HKCR" Key="aopp" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Address Ownership Proof Protocol"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR" Key="bitcoin" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Bitcoin Payment URL"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
</Component>
</DirectoryRef>
<Feature Id="DefaultFeature" Title="!(loc.MainFeatureTitle)" Level="1">
<ComponentGroupRef Id="Shortcuts"/>
<ComponentGroupRef Id="Files"/>
<ComponentGroupRef Id="FileAssociations"/>
<ComponentRef Id="RegistryEntries"/>
</Feature>
<?ifdef JpInstallDirChooser ?>
<Binary Id="JpCaDll" SourceFile="wixhelper.dll"/>
<CustomAction Id="JpCheckInstallDir" BinaryKey="JpCaDll" DllEntry="CheckInstallDir" />
<?endif?>
<UI>
<?ifdef JpInstallDirChooser ?>
<Dialog Id="JpInvalidInstallDir" Width="300" Height="85" Title="[ProductName] Setup" NoMinimize="yes">
<Control Id="JpInvalidInstallDirYes" Type="PushButton" X="100" Y="55" Width="50" Height="15" Default="no" Cancel="no" Text="Yes">
<Publish Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
</Control>
<Control Id="JpInvalidInstallDirNo" Type="PushButton" X="150" Y="55" Width="50" Height="15" Default="yes" Cancel="yes" Text="No">
<Publish Event="NewDialog" Value="InstallDirDlg">1</Publish>
</Control>
<Control Id="Text" Type="Text" X="25" Y="15" Width="250" Height="30" TabSkip="no">
<Text>!(loc.message.install.dir.exist)</Text>
</Control>
</Dialog>
<!--
Run WixUI_InstallDir dialog in the default install directory.
-->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR"/>
<UIRef Id="WixUI_InstallDir" />
<Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="JpCheckInstallDir" Order="3">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="JpInvalidInstallDir" Order="5">INSTALLDIR_VALID="0"</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="5">INSTALLDIR_VALID="1"</Publish>
<?ifndef JpLicenseRtf ?>
<!--
No license file provided.
Override the dialog sequence in built-in dialog set "WixUI_InstallDir"
to exclude license dialog.
-->
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">1</Publish>
<?endif?>
<?else?>
<?ifdef JpLicenseRtf ?>
<UIRef Id="WixUI_Minimal" />
<?endif?>
<?endif?>
</UI>
<?ifdef JpLicenseRtf ?>
<WixVariable Id="WixUILicenseRtf" Value="$(var.JpLicenseRtf)"/>
<?endif?>
</Product>
</Wix>
+3
View File
@@ -0,0 +1,3 @@
mime-type=application/psbt
extension=psbt
description=Partially Signed Bitcoin Transaction
+3
View File
@@ -0,0 +1,3 @@
mime-type=application/bitcoin-transaction
extension=txn
description=Bitcoin Transaction
@@ -12,7 +12,7 @@ public class AboutController {
private Label title;
public void initializeView() {
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION);
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION + MainApp.APP_VERSION_SUFFIX);
}
public void setStage(Stage stage) {
@@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow;
import com.google.common.base.Charsets;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteSource;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
@@ -18,6 +15,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.psbt.PSBTSignatureException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
@@ -87,6 +85,9 @@ public class AppController implements Initializable {
@FXML
private Menu savePSBT;
@FXML
private MenuItem savePSBTBinary;
@FXML
private MenuItem exportWallet;
@@ -120,6 +121,12 @@ public class AppController implements Initializable {
@FXML
private CheckMenuItem useHdCameraResolution;
@FXML
private CheckMenuItem showLoadingLog;
@FXML
private CheckMenuItem showUtxosChart;
@FXML
private CheckMenuItem showTxHex;
@@ -129,6 +136,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem refreshWallet;
@FXML
private MenuItem sendToMany;
@FXML
private StackPane rootStack;
@@ -167,11 +177,7 @@ public class AppController implements Initializable {
boolean success = false;
if(db.hasFiles()) {
for(File file : db.getFiles()) {
if(isWalletFile(file)) {
openWalletFile(file, true);
} else {
openTransactionFile(file);
}
openFile(file);
}
success = true;
}
@@ -227,6 +233,8 @@ public class AppController implements Initializable {
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage)tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
}
}
});
@@ -259,9 +267,14 @@ public class AppController implements Initializable {
hideEmptyUsedAddresses.setSelected(Config.get().isHideEmptyUsedAddresses());
useHdCameraResolution.setSelected(Config.get().isHdCapture());
showTxHex.setSelected(Config.get().isShowTransactionHex());
showLoadingLog.setSelected(Config.get().isShowLoadingLog());
showUtxosChart.setSelected(Config.get().isShowUtxosChart());
saveTransaction.setDisable(true);
savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
exportWallet.setDisable(true);
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
sendToMany.disableProperty().bind(exportWallet.disableProperty());
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
@@ -311,7 +324,7 @@ public class AppController implements Initializable {
}
}
public void showDocumentation(ActionEvent event) throws IOException {
public void showDocumentation(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/docs");
}
@@ -324,7 +337,7 @@ public class AppController implements Initializable {
}
}
public void submitBugReport(ActionEvent event) throws IOException {
public void submitBugReport(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
}
@@ -343,7 +356,9 @@ public class AppController implements Initializable {
stage.setTitle("About " + MainApp.APP_NAME);
stage.initStyle(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? StageStyle.UNDECORATED : StageStyle.DECORATED);
stage.setResizable(false);
stage.setScene(new Scene(root));
Scene scene = new Scene(root);
AppServices.onEscapePressed(scene, stage::close);
stage.setScene(scene);
controller.setStage(stage);
controller.initializeView();
setStageIcon(stage);
@@ -432,7 +447,7 @@ public class AppController implements Initializable {
showErrorDialog("Invalid PSBT", e.getMessage());
} catch(TransactionParseException e) {
showErrorDialog("Invalid transaction", e.getMessage());
} catch(ParseException e) {
} catch(Exception e) {
showErrorDialog("Invalid file", e.getMessage());
}
}
@@ -664,6 +679,18 @@ public class AppController implements Initializable {
Config.get().setHdCapture(item.isSelected());
}
public void showLoadingLog(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowLoadingLog(item.isSelected());
EventManager.get().post(new LoadingLogChangedEvent(item.isSelected()));
}
public void showUtxosChart(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowUtxosChart(item.isSelected());
EventManager.get().post(new UtxosChartChangedEvent(item.isSelected()));
}
public void showTxHex(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowTransactionHex(item.isSelected());
@@ -677,9 +704,12 @@ public class AppController implements Initializable {
EventManager.get().post(new BitcoinUnitChangedEvent(unit));
}
private boolean isWalletFile(File file) {
FileType fileType = IOUtils.getFileType(file);
return FileType.JSON.equals(fileType) || FileType.BINARY.equals(fileType);
public void openFile(File file) {
if(isWalletFile(file)) {
openWalletFile(file, true);
} else {
openTransactionFile(file);
}
}
private void setServerToggleTooltip(Integer currentBlockHeight) {
@@ -729,17 +759,11 @@ public class AppController implements Initializable {
public void openWalletFile(File file, boolean forceSameWindow) {
try {
Storage storage = new Storage(file);
FileType fileType = IOUtils.getFileType(file);
if(FileType.JSON.equals(fileType)) {
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, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
} else if(FileType.BINARY.equals(fileType)) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
if(!storage.isEncrypted()) {
WalletBackupAndKey walletBackupAndKey = storage.loadUnencryptedWallet();
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
} else {
WalletPasswordDialog dlg = new WalletPasswordDialog(storage.getWalletName(null), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> optionalPassword = dlg.showAndWait();
if(optionalPassword.isEmpty()) {
return;
@@ -748,20 +772,12 @@ public class AppController implements Initializable {
SecureString password = optionalPassword.get();
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage, password);
loadWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Storage.WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
try {
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 {
walletBackupAndKey.key.clear();
}
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Done"));
WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
});
loadWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Failed"));
Throwable exception = loadWalletService.getException();
if(exception instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
@@ -776,19 +792,42 @@ public class AppController implements Initializable {
password.clear();
}
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.START, "Decrypting wallet..."));
loadWalletService.start();
} else {
throw new IOException("Unsupported file type");
}
} catch(StorageException e) {
showErrorDialog("Error Opening Wallet", e.getMessage());
} catch(Exception e) {
if(!attemptImportWallet(file, null)) {
if(e instanceof IOException && e.getMessage().startsWith("The process cannot access the file because another process has locked")) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", "The wallet file is locked. Is another instance of " + MainApp.APP_NAME + " already running?");
} else if(!attemptImportWallet(file, null)) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage() == null ? "Unsupported file format" : e.getMessage());
}
}
}
private void openWallet(Storage storage, WalletBackupAndKey walletBackupAndKey, AppController appController, boolean forceSameWindow) {
try {
checkWalletNetwork(walletBackupAndKey.getWallet());
restorePublicKeysFromSeed(walletBackupAndKey.getWallet(), walletBackupAndKey.getKey());
if(!walletBackupAndKey.getWallet().isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
AppController walletAppController = appController.addWalletTabOrWindow(storage, walletBackupAndKey.getWallet(), walletBackupAndKey.getBackupWallet(), forceSameWindow);
for(Map.Entry<Storage, WalletBackupAndKey> entry : walletBackupAndKey.getChildWallets().entrySet()) {
openWallet(entry.getKey(), entry.getValue(), walletAppController, true);
}
Platform.runLater(() -> selectTab(walletBackupAndKey.getWallet()));
} catch(Exception e) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage());
} finally {
walletBackupAndKey.clear();
}
}
private void checkWalletNetwork(Wallet wallet) {
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
@@ -857,7 +896,9 @@ public class AppController implements Initializable {
new Electrum(),
new SpecterDesktop(),
new CoboVaultSinglesig(), new CoboVaultMultisig(),
new PassportSinglesig());
new PassportSinglesig(),
new KeystoneSinglesig(), new KeystoneMultisig(),
new CaravanMultisig());
for(WalletImport importer : walletImporters) {
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
@@ -921,17 +962,28 @@ public class AppController implements Initializable {
walletFile.delete();
}
if(wallet.isEncrypted()) {
throw new IllegalArgumentException("Imported wallet must be unencrypted");
}
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
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, null, false);
try {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(wallet, null);
addWalletTabOrWindow(storage, wallet, null, false);
} catch(IOException | StorageException | MnemonicException e) {
log.error("Error saving imported wallet", e);
}
} else {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = null;
@@ -940,7 +992,12 @@ public class AppController implements Initializable {
key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
wallet.encrypt(key);
storage.setEncryptionPubKey(encryptionPubKey);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(wallet, key);
addWalletTabOrWindow(storage, wallet, null, false);
} catch(IOException | StorageException | MnemonicException e) {
log.error("Error saving imported wallet", e);
} finally {
encryptionFullKey.clear();
if(key != null) {
@@ -949,10 +1006,10 @@ public class AppController implements Initializable {
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Failed"));
showErrorDialog("Error encrypting wallet", keyDerivationService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.START, "Encrypting wallet..."));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.START, "Encrypting wallet..."));
keyDerivationService.start();
}
}
@@ -997,6 +1054,28 @@ public class AppController implements Initializable {
messageSignDialog.showAndWait();
}
public void sendToMany(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
Wallet wallet = walletTabData.getWallet();
BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit();
if(bitcoinUnit == BitcoinUnit.AUTO) {
bitcoinUnit = wallet.getAutoUnit();
}
SendToManyDialog sendToManyDialog = new SendToManyDialog(bitcoinUnit);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
optPayments.ifPresent(payments -> {
if(!payments.isEmpty()) {
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet())));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(wallet, payments)));
}
});
}
}
public void minimizeToTray(ActionEvent event) {
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
}
@@ -1011,18 +1090,18 @@ public class AppController implements Initializable {
walletTabData.getStorage().backupTempWallet();
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, walletTabData.getWalletForm().getWalletId()));
}
}
public void addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage);
public AppController addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage.getWalletId(wallet));
if(existingWalletWindow instanceof Stage) {
Stage existingWalletStage = (Stage)existingWalletWindow;
existingWalletStage.toFront();
EventManager.get().post(new ViewWalletEvent(existingWalletWindow, wallet, storage));
return;
return this;
}
if(!forceSameWindow && Config.get().isOpenWalletsInNewWindows() && !getOpenWallets().isEmpty()) {
@@ -1031,17 +1110,16 @@ public class AppController implements Initializable {
stage.toFront();
stage.setX(AppServices.get().getWalletWindowMaxX() + 30);
appController.addWalletTab(storage, wallet, backupWallet);
return appController;
} else {
addWalletTab(storage, wallet, backupWallet);
return this;
}
}
public void addWalletTab(Storage storage, Wallet wallet, Wallet backupWallet) {
try {
String name = storage.getWalletFile().getName();
if(name.endsWith(".json")) {
name = name.substring(0, name.lastIndexOf('.'));
}
String name = storage.getWalletName(wallet);
if(!name.equals(wallet.getName())) {
wallet.setName(name);
}
@@ -1106,7 +1184,8 @@ public class AppController implements Initializable {
private void addTransactionTab(String name, File file, byte[] bytes) throws PSBTParseException, ParseException, TransactionParseException {
if(PSBT.isPSBT(bytes)) {
PSBT psbt = new PSBT(bytes);
//Don't verify signatures here - provided PSBT may omit UTXO data that can be found when combining with an existing PSBT
PSBT psbt = new PSBT(bytes, false);
addTransactionTab(name, file, psbt);
} else if(Transaction.isTransaction(bytes)) {
try {
@@ -1182,6 +1261,16 @@ public class AppController implements Initializable {
}
}
if(psbt != null) {
try {
//Any PSBTs that have reached this point could not be combined with an existing PSBT. Verify signatures before continuing
psbt.verifySignatures();
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
return;
}
}
try {
String tabName = name;
@@ -1277,6 +1366,28 @@ public class AppController implements Initializable {
EventManager.get().post(new ThemeChangedEvent(selectedTheme));
}
private void serverToggleStartAnimation() {
Node thumbArea = serverToggle.lookup(".thumb-area");
if(thumbArea != null) {
FadeTransition fadeTransition = new FadeTransition(Duration.millis(1000), thumbArea);
fadeTransition.setFromValue(1.0);
fadeTransition.setToValue(0.4);
fadeTransition.setAutoReverse(true);
fadeTransition.setCycleCount(Animation.INDEFINITE);
fadeTransition.play();
serverToggle.setUserData(fadeTransition);
}
}
private void serverToggleStopAnimation() {
if(serverToggle.getUserData() != null) {
FadeTransition fadeTransition = (FadeTransition)serverToggle.getUserData();
fadeTransition.stop();
fadeTransition.getNode().setOpacity(1.0);
serverToggle.setUserData(null);
}
}
private void tabLabelStartAnimation(Wallet wallet) {
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == wallet).forEach(this::tabLabelStartAnimation);
}
@@ -1364,6 +1475,8 @@ public class AppController implements Initializable {
saveTransaction.setVisible(false);
}
exportWallet.setDisable(true);
showLoadingLog.setDisable(true);
showUtxosChart.setDisable(true);
showTxHex.setDisable(false);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
@@ -1371,6 +1484,8 @@ public class AppController implements Initializable {
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid());
showLoadingLog.setDisable(false);
showUtxosChart.setDisable(false);
showTxHex.setDisable(true);
}
}
@@ -1396,7 +1511,7 @@ public class AppController implements Initializable {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData) {
WalletTabData walletTabData = (WalletTabData)tabData;
if(walletTabData.getWalletForm().getWalletFile().equals(event.getWalletFile())) {
if(walletTabData.getWalletForm().getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid());
}
}
@@ -1544,12 +1659,14 @@ public class AppController implements Initializable {
if(!statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
statusUpdated(new StatusEvent(event.getStatus(), 120));
}
serverToggleStartAnimation();
}
@Subscribe
public void connectionFailed(ConnectionFailedEvent event) {
String status = CONNECTION_FAILED_PREFIX + event.getMessage();
statusUpdated(new StatusEvent(status));
serverToggleStopAnimation();
}
@Subscribe
@@ -1557,6 +1674,7 @@ public class AppController implements Initializable {
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
statusUpdated(new StatusEvent(status));
setServerToggleTooltip(event.getBlockHeight());
serverToggleStopAnimation();
}
@Subscribe
@@ -1571,6 +1689,7 @@ public class AppController implements Initializable {
for(Wallet wallet : getOpenWallets().keySet()) {
tabLabelStopAnimation(wallet);
}
serverToggleStopAnimation();
}
@Subscribe
@@ -1707,6 +1826,17 @@ public class AppController implements Initializable {
@Subscribe
public void viewWallet(ViewWalletEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
if(event.getStorage().getWalletId(event.getWallet()).equals(walletTabData.getWalletForm().getWalletId())) {
tabs.getSelectionModel().select(tab);
return;
}
}
}
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
@@ -1758,14 +1888,22 @@ public class AppController implements Initializable {
@Subscribe
public void requestWalletOpen(RequestWalletOpenEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
openWallet(true);
if(event.getFile() != null) {
openWalletFile(event.getFile(), true);
} else {
openWallet(true);
}
}
}
@Subscribe
public void requestTransactionOpen(RequestTransactionOpenEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
openTransactionFromFile(null);
if(event.getFile() != null) {
openTransactionFile(event.getFile());
} else {
openTransactionFromFile(null);
}
}
}
@@ -1775,4 +1913,14 @@ public class AppController implements Initializable {
openTransactionFromQR(null);
}
}
}
@Subscribe
public void sendAction(SendActionEvent event) {
selectTab(event.getWallet());
}
@Subscribe
public void recieveAction(ReceiveActionEvent event) {
selectTab(event.getWallet());
}
}
@@ -4,17 +4,16 @@ 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.ScriptType;
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.TextUtils;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
@@ -27,28 +26,35 @@ import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.*;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.HyperlinkLabel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.desktop.OpenFilesHandler;
import java.awt.desktop.OpenURIHandler;
import java.io.File;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class AppServices {
@@ -61,6 +67,7 @@ public class AppServices {
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
private static AppServices INSTANCE;
@@ -94,6 +101,10 @@ public class AppServices {
private static List<Device> devices;
private static final List<File> argFiles = new ArrayList<>();
private static final List<URI> argUris = new ArrayList<>();
private static final Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
private final ChangeListener<Boolean> onlineServicesListener = new ChangeListener<>() {
@@ -113,6 +124,14 @@ public class AppServices {
}
};
private static final OpenURIHandler openURIHandler = event -> {
openURI(event.getURI());
};
private static final OpenFilesHandler openFilesHandler = event -> {
openFiles(event.getFiles(), null);
};
public AppServices(MainApp application) {
this.application = application;
EventManager.get().register(this);
@@ -134,6 +153,8 @@ public class AppServices {
restartServices();
}
}
addURIHandlers();
}
private void restartServices() {
@@ -219,7 +240,7 @@ public class AppServices {
connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert();
if(crtFile != null) {
if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
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.");
@@ -352,18 +373,36 @@ public class AppServices {
return Tor.getDefault() != null;
}
public static boolean isUsingProxy() {
return isTorRunning() || Config.get().isUseProxy();
}
public static Proxy getProxy() {
return getProxy(TOR_DEFAULT_PROXY_CIRCUIT_ID);
}
public static Proxy getProxy(String proxyCircuitId) {
Config config = Config.get();
Proxy proxy = null;
if(config.isUseProxy()) {
HostAndPort proxy = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
HostAndPort proxyHostAndPort = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
} else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
}
return null;
//Setting new proxy authentication credentials will force a new Tor circuit to be created
if(proxy != null) {
Authenticator.setDefault(new Authenticator() {
public PasswordAuthentication getPasswordAuthentication() {
return (new PasswordAuthentication("user", proxyCircuitId.toCharArray()));
}
});
}
return proxy;
}
static void initialize(MainApp application) {
@@ -415,6 +454,14 @@ public class AppServices {
stage.hide();
}
public static void onEscapePressed(Scene scene, Runnable runnable) {
scene.setOnKeyPressed(event -> {
if(event.getCode() == KeyCode.ESCAPE) {
runnable.run();
}
});
}
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
@@ -426,8 +473,8 @@ public class AppServices {
return openWallets;
}
public Window getWindowForWallet(Storage storage) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getStorage().getWalletFile().equals(storage.getWalletFile()))).map(Map.Entry::getKey).findFirst();
public Window getWindowForWallet(String walletId) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWalletForm().getWalletId().equals(walletId))).map(Map.Entry::getKey).findFirst();
return optWindow.orElse(null);
}
@@ -500,6 +547,10 @@ public class AppServices {
ElectrumServer.clearRetrievedScriptHashes(wallet);
}
public static boolean isWalletFile(File file) {
return Storage.isWalletFile(file);
}
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
}
@@ -514,6 +565,29 @@ public class AppServices {
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
if(matcher.find()) {
String link = matcher.group(1);
HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content);
hyperlinkLabel.setMaxWidth(Double.MAX_VALUE);
hyperlinkLabel.setMaxHeight(Double.MAX_VALUE);
hyperlinkLabel.getStyleClass().add("content");
Label label = new Label();
hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50));
hyperlinkLabel.setOnAction(event -> {
alert.close();
get().getApplication().getHostServices().showDocument(link);
});
alert.getDialogPane().setContent(hyperlinkLabel);
}
String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3) {
alert.getDialogPane().setPrefHeight(200 + lines.length * 20);
}
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@@ -545,10 +619,6 @@ public class AppServices {
}
}
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();
@@ -567,6 +637,135 @@ public class AppServices {
}
}
static void parseFileUriArguments(List<String> fileUriArguments) {
for(String fileUri : fileUriArguments) {
try {
File file = new File(fileUri.replace("~", System.getProperty("user.home")));
if(file.exists()) {
argFiles.add(file);
continue;
}
URI uri = new URI(fileUri);
argUris.add(uri);
} catch(URISyntaxException e) {
log.warn("Could not parse " + fileUri + " as a valid file or URI");
} catch(Exception e) {
//ignore
}
}
}
public static void openFileUriArguments(Window window) {
openFiles(argFiles, window);
argFiles.clear();
for(URI argUri : argUris) {
openURI(argUri);
}
argUris.clear();
}
private static void openFiles(List<File> files, Window window) {
final List<File> openFiles = new ArrayList<>(files);
Platform.runLater(() -> {
Window openWindow = window;
if(openWindow == null) {
openWindow = getActiveWindow();
}
if(openWindow instanceof Stage) {
((Stage)openWindow).setAlwaysOnTop(true);
((Stage)openWindow).setAlwaysOnTop(false);
}
for(File file : openFiles) {
if(isWalletFile(file)) {
EventManager.get().post(new RequestWalletOpenEvent(openWindow, file));
} else {
EventManager.get().post(new RequestTransactionOpenEvent(openWindow, file));
}
}
});
}
private static void openURI(URI uri) {
Platform.runLater(() -> {
if("bitcoin".equals(uri.getScheme())) {
openBitcoinUri(uri);
} else if("aopp".equals(uri.getScheme())) {
openAddressOwnershipProof(uri);
}
});
}
public static void addURIHandlers() {
try {
if(Desktop.isDesktopSupported()) {
if(Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_FILE)) {
Desktop.getDesktop().setOpenFileHandler(openFilesHandler);
}
if(Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
Desktop.getDesktop().setOpenURIHandler(openURIHandler);
}
}
} catch(Exception e) {
log.error("Could not add URI handler", e);
}
}
private static void openBitcoinUri(URI uri) {
try {
BitcoinURI bitcoinURI = new BitcoinURI(uri.toString());
Wallet wallet = selectWallet(null, "pay from");
if(wallet != null) {
final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet())));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
}
} catch(Exception e) {
showErrorDialog("Not a valid bitcoin URI", e.getMessage());
}
}
public static void openAddressOwnershipProof(URI uri) {
try {
Aopp aopp = new Aopp(uri);
Wallet wallet = selectWallet(aopp.getScriptType(), "send proof of address");
if(wallet != null) {
EventManager.get().post(new ReceiveActionEvent(wallet));
Platform.runLater(() -> EventManager.get().post(new ReceiveProofEvent(wallet, aopp)));
}
} catch(Exception e) {
showErrorDialog("Not a valid AOPP URI", e.getMessage());
}
}
private static Wallet selectWallet(ScriptType scriptType, String actionDescription) {
Wallet wallet = null;
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> scriptType == null || w.getScriptType() == scriptType).collect(Collectors.toList());
if(wallets.isEmpty()) {
showErrorDialog("No wallet available", "Open a" + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1) {
wallet = wallets.iterator().next();
} else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
walletChoiceDialog.setTitle("Choose Wallet");
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
Image image = new Image("/image/sparrow-small.png");
walletChoiceDialog.getDialogPane().setGraphic(new ImageView(image));
setStageIcon(walletChoiceDialog.getDialogPane().getScene().getWindow());
moveToActiveWindowScreen(walletChoiceDialog);
Optional<Wallet> optWallet = walletChoiceDialog.showAndWait();
if(optWallet.isPresent()) {
wallet = optWallet.get();
}
}
return wallet;
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
}
@@ -637,7 +836,10 @@ public class AppServices {
@Subscribe
public void openWallets(OpenWalletsEvent event) {
if(event.getWalletTabDataList().isEmpty()) {
walletWindows.remove(event.getWindow());
List<WalletTabData> closedTabData = walletWindows.remove(event.getWindow());
if(closedTabData != null && !closedTabData.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedTabData));
}
} else {
walletWindows.put(event.getWindow(), event.getWalletTabDataList());
}
@@ -646,7 +848,7 @@ public class AppServices {
Platform.runLater(() -> {
if(!Window.getWindows().isEmpty()) {
List<File> walletFiles = allWallets.stream().map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
List<File> walletFiles = allWallets.stream().filter(walletTabData -> walletTabData.getWallet().getMasterWallet() == null).map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
Config.get().setRecentWalletFiles(Config.get().isLoadRecentWallets() ? walletFiles : Collections.emptyList());
}
});
@@ -663,10 +865,6 @@ public class AppServices {
deviceEnumerateService = createDeviceEnumerateService();
}
if(deviceEnumerateService.isRunning()) {
deviceEnumerateService.cancel();
}
if(deviceEnumerateService.getState() == Worker.State.CANCELLED) {
deviceEnumerateService.reset();
}
@@ -7,14 +7,14 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.FileType;
import com.sparrowwallet.sparrow.io.IOUtils;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Bwt;
import com.sparrowwallet.sparrow.net.PublicElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.instance.InstanceException;
import com.sparrowwallet.sparrow.instance.InstanceList;
import javafx.application.Application;
import javafx.scene.text.Font;
import javafx.stage.Stage;
@@ -29,13 +29,17 @@ import java.util.*;
import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.4.0";
public static final String APP_VERSION = "1.4.3";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
private Stage mainStage;
private static SparrowInstance sparrowInstance;
@Override
public void init() throws Exception {
Thread.setDefaultUncaughtExceptionHandler((t, e) -> LoggerFactory.getLogger(MainApp.class).error("Exception in thread \"" + t.getName() + "\"", e));
@@ -94,9 +98,8 @@ public class MainApp extends Application {
List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
if(recentWalletFiles != null) {
//Re-sort to preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(file -> FileType.BINARY.equals(IOUtils.getFileType(file))).collect(Collectors.toList());
Collections.reverse(encryptedWalletFiles);
//Preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(Storage::isEncrypted).collect(Collectors.toList());
List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles);
sortedWalletFiles.removeAll(encryptedWalletFiles);
sortedWalletFiles.addAll(encryptedWalletFiles);
@@ -108,6 +111,8 @@ public class MainApp extends Application {
}
}
AppServices.openFileUriArguments(stage);
AppServices.get().start();
}
@@ -115,6 +120,9 @@ public class MainApp extends Application {
public void stop() throws Exception {
AppServices.get().stop();
mainStage.close();
if(sparrowInstance != null) {
sparrowInstance.freeLock();
}
}
public static void main(String[] argv) {
@@ -162,6 +170,19 @@ public class MainApp extends Application {
getLogger().info("Using " + Network.get() + " configuration");
}
List<String> fileUriArguments = jCommander.getUnknownOptions();
try {
sparrowInstance = new SparrowInstance(fileUriArguments);
sparrowInstance.acquireLock(); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired
} catch(InstanceException e) {
getLogger().error("Could not access application lock", e);
}
if(!fileUriArguments.isEmpty()) {
AppServices.parseFileUriArguments(fileUriArguments);
}
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
@@ -170,4 +191,31 @@ public class MainApp extends Application {
private static Logger getLogger() {
return LoggerFactory.getLogger(MainApp.class);
}
private static class SparrowInstance extends InstanceList {
private final List<String> fileUriArguments;
public SparrowInstance(List<String> fileUriArguments) {
super(MainApp.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty());
this.fileUriArguments = fileUriArguments;
}
@Override
protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) {
AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null);
}
}
@Override
protected List<String> sendMessageList() {
return fileUriArguments;
}
@Override
protected void beforeExit() {
getLogger().info("Opening files/URIs in already running instance, exiting...");
}
}
}
@@ -12,6 +12,7 @@ public class WelcomeDialog extends Dialog<Mode> {
public WelcomeDialog() {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader welcomeLoader = new FXMLLoader(AppServices.class.getResource("welcome.fxml"));
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
import javafx.scene.control.ContentDisplay;
@@ -32,7 +33,7 @@ public class AddressCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
Address address = utxoEntry.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), null));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
Tooltip tooltip = new Tooltip();
tooltip.setText(getTooltipText(utxoEntry));
setTooltip(tooltip);
@@ -0,0 +1,37 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import javafx.util.StringConverter;
public class AddressStringConverter extends StringConverter<Address> {
@Override
public Address fromString(String value) {
// If the specified value is null or zero-length, return null
if(value == null) {
return null;
}
value = value.trim();
if (value.length() < 1) {
return null;
}
try {
return Address.fromString(value);
} catch(InvalidAddressException e) {
throw new IllegalArgumentException(e);
}
}
@Override
public String toString(Address value) {
// If the specified value is null, return a zero-length String
if(value == null) {
return "";
}
return value.toString();
}
}
@@ -9,6 +9,7 @@ import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.ListChangeListener;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
@@ -82,6 +83,10 @@ public class AddressTreeTable extends CoinTreeTable {
}
}
});
rootEntry.getChildren().addListener((ListChangeListener<Entry>) c -> {
this.refresh();
});
}
public void updateAll(NodeEntry rootEntry) {
@@ -99,22 +104,21 @@ public class AddressTreeTable extends CoinTreeTable {
}
public void updateHistory(List<WalletNode> updatedNodes) {
//We only ever add or replace child nodes - never remove in order to keep a full sequence
//We only ever add child nodes - never remove in order to keep a full sequence
NodeEntry rootEntry = (NodeEntry)getRoot().getValue();
for(WalletNode updatedNode : updatedNodes) {
NodeEntry nodeEntry = new NodeEntry(rootEntry.getWallet(), updatedNode);
Optional<Entry> optEntry = rootEntry.getChildren().stream().filter(childEntry -> ((NodeEntry)childEntry).getNode().equals(updatedNode)).findFirst();
if(optEntry.isPresent()) {
int index = rootEntry.getChildren().indexOf(optEntry.get());
rootEntry.getChildren().set(index, nodeEntry);
NodeEntry existingEntry = (NodeEntry)optEntry.get();
existingEntry.refreshChildren();
} else {
NodeEntry nodeEntry = new NodeEntry(rootEntry.getWallet(), updatedNode);
rootEntry.getChildren().add(nodeEntry);
}
}
sort();
refresh();
}
public void updateLabel(Entry entry) {
@@ -10,16 +10,24 @@ import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.scene.layout.Region;
import org.controlsfx.tools.Platform;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
class CoinCell extends TreeTableCell<Entry, Number> {
public static final DecimalFormat TABLE_BTC_FORMAT = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final Tooltip tooltip;
public CoinCell() {
super();
tooltip = new Tooltip();
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
getStyleClass().add("number-field");
}
}
@Override
@@ -38,7 +46,8 @@ class CoinCell extends TreeTableCell<Entry, Number> {
BitcoinUnit unit = coinTreeTable.getBitcoinUnit();
String satsValue = String.format(Locale.ENGLISH, "%,d", amount.longValue());
final String btcValue = CoinLabel.getBTCFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
DecimalFormat decimalFormat = (amount.longValue() == 0L ? CoinLabel.getBTCFormat() : TABLE_BTC_FORMAT);
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
@@ -5,8 +5,8 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -92,7 +92,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletFile()));
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
}
});
if(wallet.getBirthDate() == null) {
@@ -34,7 +34,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
if(entry instanceof UtxoEntry) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
} else {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
@@ -25,8 +25,8 @@ public class DeviceAddressDialog extends DeviceDialog<String> {
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, outputDescriptor, device);
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(wallet, outputDescriptor, device, defaultDevice);
}
@Subscribe
@@ -22,6 +22,7 @@ import java.util.Objects;
public abstract class DeviceDialog<R> extends Dialog<R> {
private final List<String> operationFingerprints;
private final Accordion deviceAccordion;
private final Button scanButton;
private final VBox scanBox;
private final Label scanLabel;
@@ -57,18 +58,19 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
Glyph usb = new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
usb.setFontSize(50);
scanLabel = new Label("Connect Hardware Wallet");
Button button = new Button("Scan...");
button.setPrefSize(120, 60);
button.setOnAction(event -> {
scanButton = new Button("Scan...");
scanButton.setPrefSize(120, 60);
scanButton.setOnAction(event -> {
scan();
});
scanBox.getChildren().addAll(usb, scanLabel, button);
scanBox.getChildren().addAll(usb, scanLabel, scanButton);
scanBox.managedProperty().bind(scanBox.visibleProperty());
stackPane.getChildren().addAll(anchorPane, scanBox);
List<Device> devices = AppServices.getDevices();
if(devices == null || devices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
} else {
Platform.runLater(() -> setDevices(devices));
@@ -97,15 +99,21 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
scanButton.setText("Scan...");
List<Device> devices = enumerateService.getValue();
setDevices(devices);
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
});
enumerateService.setOnFailed(workerStateEvent -> {
scanButton.setText("Scan...");
deviceAccordion.getPanes().clear();
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
scanLabel.setText(workerStateEvent.getSource().getException().getMessage());
});
enumerateService.setOnRunning(workerStateEvent -> {
scanButton.setText("Scanning...");
});
enumerateService.start();
}
@@ -122,16 +130,17 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
deviceAccordion.getPanes().clear();
if(dialogDevices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
scanLabel.setText("No matching devices found");
} else {
scanBox.setVisible(false);
for(Device device : dialogDevices) {
DevicePane devicePane = getDevicePane(device);
DevicePane devicePane = getDevicePane(device, dialogDevices.size() == 1);
deviceAccordion.getPanes().add(devicePane);
}
}
}
protected abstract DevicePane getDevicePane(Device device);
protected abstract DevicePane getDevicePane(Device device, boolean defaultDevice);
}
@@ -59,7 +59,9 @@ public class DevicePane extends TitledDescriptionPane {
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
public DevicePane(Wallet wallet, Device device) {
private boolean defaultDevice;
public DevicePane(Wallet wallet, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.IMPORT;
this.wallet = wallet;
@@ -68,6 +70,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -80,7 +83,7 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, importButton);
}
public DevicePane(PSBT psbt, Device device) {
public DevicePane(PSBT psbt, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null;
@@ -89,6 +92,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -101,7 +105,7 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
}
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device) {
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
this.wallet = wallet;
@@ -110,6 +114,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -122,7 +127,7 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
}
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device) {
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
@@ -131,6 +136,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = keyDerivation;
this.message = message;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -145,6 +151,7 @@ public class DevicePane extends TitledDescriptionPane {
private void initialise(Device device) {
if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice);
unlockButton.setVisible(true);
} else if(device.isNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
@@ -229,7 +236,6 @@ public class DevicePane extends TitledDescriptionPane {
private void createSignButton() {
signButton = new Button("Sign");
signButton.setDefaultButton(true);
signButton.setAlignment(Pos.CENTER_RIGHT);
signButton.setMinWidth(44);
signButton.setOnAction(event -> {
@@ -242,7 +248,6 @@ public class DevicePane extends TitledDescriptionPane {
private void createDisplayAddressButton() {
displayAddressButton = new Button("Display Address");
displayAddressButton.setDefaultButton(true);
displayAddressButton.setAlignment(Pos.CENTER_RIGHT);
displayAddressButton.setOnAction(event -> {
displayAddressButton.setDisable(true);
@@ -259,7 +264,6 @@ public class DevicePane extends TitledDescriptionPane {
private void createSignMessageButton() {
signMessageButton = new Button("Sign Message");
signMessageButton.setDefaultButton(true);
signMessageButton.setAlignment(Pos.CENTER_RIGHT);
signMessageButton.setOnAction(event -> {
signMessageButton.setDisable(true);
@@ -284,7 +288,9 @@ public class DevicePane extends TitledDescriptionPane {
vBox.setMaxHeight(120);
vBox.setSpacing(42);
pinField = (CustomPasswordField)TextFields.createClearablePasswordField();
Platform.runLater(() -> pinField.requestFocus());
enterPinButton = new Button("Enter PIN");
enterPinButton.setDefaultButton(true);
enterPinButton.setOnAction(event -> {
enterPinButton.setDisable(true);
sendPin(pinField.getText());
@@ -324,9 +330,15 @@ public class DevicePane extends TitledDescriptionPane {
CustomPasswordField passphraseField = (CustomPasswordField)TextFields.createClearablePasswordField();
passphrase.bind(passphraseField.textProperty());
HBox.setHgrow(passphraseField, Priority.ALWAYS);
passphraseField.setOnAction(event -> {
setExpanded(false);
setDescription("Confirm passphrase on device...");
sendPassphrase(passphrase.get());
});
SplitMenuButton sendPassphraseButton = new SplitMenuButton();
sendPassphraseButton.setText("Send Passphrase");
sendPassphraseButton.getStyleClass().add("default-button");
sendPassphraseButton.setOnAction(event -> {
setExpanded(false);
setDescription("Confirm passphrase on device...");
@@ -534,7 +546,7 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
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);
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_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);
@@ -604,17 +616,23 @@ public class DevicePane extends TitledDescriptionPane {
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
importButton.getStyleClass().add("default-button");
}
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(true);
setContent(getDerivationEntry(wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
signButton.setDefaultButton(defaultDevice);
signButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.DISPLAY_ADDRESS)) {
displayAddressButton.setDefaultButton(defaultDevice);
displayAddressButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.SIGN_MESSAGE)) {
signMessageButton.setDefaultButton(defaultDevice);
signMessageButton.setVisible(true);
showHideLink.setVisible(false);
}
@@ -627,11 +645,11 @@ public class DevicePane extends TitledDescriptionPane {
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importDerivationButton = new Button("Import");
importDerivationButton.setOnAction(event -> {
@@ -22,8 +22,8 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(psbt, device);
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(psbt, device, defaultDevice);
}
@Subscribe
@@ -26,8 +26,8 @@ public class DeviceSignMessageDialog extends DeviceDialog<String> {
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, message, keyDerivation, device);
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(wallet, message, keyDerivation, device, defaultDevice);
}
@Subscribe
@@ -3,7 +3,9 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
@@ -72,9 +74,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
Button viewTransactionButton = new Button("");
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
viewTransactionButton.setGraphic(searchGlyph);
viewTransactionButton.setGraphic(getViewTransactionGlyph());
viewTransactionButton.setOnAction(event -> {
EventManager.get().post(new ViewTransactionEvent(this.getScene().getWindow(), transactionEntry.getBlockTransaction()));
});
@@ -83,9 +83,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
Glyph increaseFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_MEDICAL);
increaseFeeGlyph.setFontSize(12);
increaseFeeButton.setGraphic(increaseFeeGlyph);
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
increaseFeeButton.setOnAction(event -> {
increaseFee(transactionEntry);
});
@@ -94,9 +92,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
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.setGraphic(getIncreaseFeeCPFPGlyph());
cpfpButton.setOnAction(event -> {
createCpfp(transactionEntry);
});
@@ -108,7 +104,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), null));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
Tooltip tooltip = new Tooltip();
tooltip.setText(nodeEntry.getNode().getDerivationPath().replace("m", ".."));
setTooltip(tooltip);
@@ -116,27 +112,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
Button receiveButton = new Button("");
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
receiveGlyph.setFontSize(12);
receiveButton.setGraphic(receiveGlyph);
receiveButton.setGraphic(getReceiveGlyph());
receiveButton.setOnAction(event -> {
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
actionBox.getChildren().add(receiveButton);
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
(nodeEntry.getWallet().getKeystores().get(0).hasPrivateKey() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
if(canSignMessage(nodeEntry.getWallet())) {
Button signMessageButton = new Button("");
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
signMessageButton.setGraphic(signMessageGlyph);
signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
actionBox.getChildren().add(signMessageButton);
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
}
setGraphic(actionBox);
@@ -150,9 +140,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
Button viewTransactionButton = new Button("");
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
viewTransactionButton.setGraphic(searchGlyph);
viewTransactionButton.setGraphic(getViewTransactionGlyph());
viewTransactionButton.setOnAction(event -> {
EventManager.get().post(new ViewTransactionEvent(this.getScene().getWindow(), hashIndexEntry.getBlockTransaction(), hashIndexEntry));
});
@@ -160,9 +148,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable() && !hashIndexEntry.getHashIndex().isSpent()) {
Button spendUtxoButton = new Button("");
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
sendGlyph.setFontSize(12);
spendUtxoButton.setGraphic(sendGlyph);
spendUtxoButton.setGraphic(getSendGlyph());
spendUtxoButton.setOnAction(event -> {
sendSelectedUtxos(getTreeTableView(), hashIndexEntry);
});
@@ -182,6 +168,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(TransactionInput::isReplaceByFeeEnabled)
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
@@ -249,7 +236,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
private static Double getMaxFeeRate() {
if(AppServices.getTargetBlockFeeRates().isEmpty()) {
if(AppServices.getTargetBlockFeeRates() == null || AppServices.getTargetBlockFeeRates().isEmpty()) {
return 100.0;
}
@@ -280,6 +267,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), blockTransaction.getFee(), false)));
}
private static boolean canSignMessage(Wallet wallet) {
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB);
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
return transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
@@ -304,31 +296,98 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(hashIndexEntry.getWallet(), spendingUtxos)));
}
private static void freezeUtxo(HashIndexEntry hashIndexEntry) {
hashIndexEntry.getHashIndex().setStatus(Status.FROZEN);
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), hashIndexEntry.getHashIndex()));
private static void freezeUtxo(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
List<BlockTransactionHashIndex> utxos = treeTableView.getSelectionModel().getSelectedCells().stream()
.map(tp -> tp.getTreeItem().getValue())
.filter(e -> e instanceof HashIndexEntry && ((HashIndexEntry)e).getType().equals(HashIndexEntry.Type.OUTPUT))
.map(e -> ((HashIndexEntry)e).getHashIndex())
.filter(ref -> ref.getStatus() != Status.FROZEN)
.collect(Collectors.toList());
utxos.forEach(ref -> ref.setStatus(Status.FROZEN));
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), utxos));
}
private static void unfreezeUtxo(HashIndexEntry hashIndexEntry) {
hashIndexEntry.getHashIndex().setStatus(null);
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), hashIndexEntry.getHashIndex()));
private static void unfreezeUtxo(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
List<BlockTransactionHashIndex> utxos = treeTableView.getSelectionModel().getSelectedCells().stream()
.map(tp -> tp.getTreeItem().getValue())
.filter(e -> e instanceof HashIndexEntry && ((HashIndexEntry)e).getType().equals(HashIndexEntry.Type.OUTPUT))
.map(e -> ((HashIndexEntry)e).getHashIndex())
.filter(ref -> ref.getStatus() == Status.FROZEN)
.collect(Collectors.toList());
utxos.forEach(ref -> ref.setStatus(null));
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), utxos));
}
private static Glyph getViewTransactionGlyph() {
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
return searchGlyph;
}
private static Glyph getIncreaseFeeRBFGlyph() {
Glyph increaseFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_MEDICAL);
increaseFeeGlyph.setFontSize(12);
return increaseFeeGlyph;
}
private static Glyph getIncreaseFeeCPFPGlyph() {
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
cpfpGlyph.setFontSize(12);
return cpfpGlyph;
}
private static Glyph getReceiveGlyph() {
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
receiveGlyph.setFontSize(12);
return receiveGlyph;
}
private static Glyph getSignMessageGlyph() {
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
return signMessageGlyph;
}
private static Glyph getSendGlyph() {
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
sendGlyph.setFontSize(12);
return sendGlyph;
}
private static Glyph getCopyGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COPY);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getFreezeGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SNOWFLAKE);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getUnfreezeGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SUN);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
viewTransaction.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
getItems().add(copyTxid);
getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
hide();
increaseFee(transactionEntry);
@@ -339,6 +398,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(containsWalletOutputs(transactionEntry)) {
MenuItem createCpfp = new MenuItem("Increase Effective Fee (CPFP)");
createCpfp.setGraphic(getIncreaseFeeCPFPGlyph());
createCpfp.setOnAction(AE -> {
hide();
createCpfp(transactionEntry);
@@ -346,11 +406,28 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(createCpfp);
}
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyTxid);
}
}
private static class TransactionContextMenu extends ContextMenu {
public TransactionContextMenu(String date, BlockTransaction blockTransaction) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
viewTransaction.setOnAction(AE -> {
hide();
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -375,12 +452,32 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyDate, copyTxid, copyHeight);
getItems().addAll(viewTransaction, copyDate, copyTxid, copyHeight);
}
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
receiveToAddress.setOnAction(event -> {
hide();
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
getItems().add(receiveToAddress);
if(nodeEntry != null && canSignMessage(nodeEntry.getWallet())) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setGraphic(getSignMessageGlyph());
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(AE -> {
hide();
@@ -406,34 +503,22 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
}
}
private static class HashIndexEntryContextMenu extends ContextMenu {
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input");
MenuItem copyHashIndex = new MenuItem(label);
copyHashIndex.setOnAction(AE -> {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
viewTransaction.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(hashIndexEntry.getHashIndex().toString());
Clipboard.getSystemClipboard().setContent(content);
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), hashIndexEntry.getBlockTransaction()));
});
getItems().add(copyHashIndex);
getItems().add(viewTransaction);
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable() && !hashIndexEntry.getHashIndex().isSpent()) {
MenuItem sendSelected = new MenuItem("Send Selected");
sendSelected.setGraphic(getSendGlyph());
sendSelected.setOnAction(AE -> {
hide();
sendSelectedUtxos(treeTableView, hashIndexEntry);
@@ -444,20 +529,32 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && !hashIndexEntry.getHashIndex().isSpent()) {
if(hashIndexEntry.getHashIndex().getStatus() == null || hashIndexEntry.getHashIndex().getStatus() != Status.FROZEN) {
MenuItem freezeUtxo = new MenuItem("Freeze UTXO");
freezeUtxo.setGraphic(getFreezeGlyph());
freezeUtxo.setOnAction(AE -> {
hide();
freezeUtxo(hashIndexEntry);
freezeUtxo(treeTableView, hashIndexEntry);
});
getItems().add(freezeUtxo);
} else {
MenuItem unfreezeUtxo = new MenuItem("Unfreeze UTXO");
unfreezeUtxo.setGraphic(getUnfreezeGlyph());
unfreezeUtxo.setOnAction(AE -> {
hide();
unfreezeUtxo(hashIndexEntry);
unfreezeUtxo(treeTableView, hashIndexEntry);
});
getItems().add(unfreezeUtxo);
}
}
String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input");
MenuItem copyHashIndex = new MenuItem(label);
copyHashIndex.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(hashIndexEntry.getHashIndex().toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyHashIndex);
}
}
@@ -4,6 +4,7 @@ import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -113,8 +114,9 @@ public abstract class FileImportPane extends TitledDescriptionPane {
importButton.setDisable(true);
setExpanded(true);
} else {
InputStream inputStream = new BufferedInputStream(new FileInputStream(file));
importFile(file.getName(), inputStream, password);
try(InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
importFile(file.getName(), inputStream, password);
};
}
} catch (Exception e) {
log.error("Error importing file", e);
@@ -171,11 +173,15 @@ public abstract class FileImportPane extends TitledDescriptionPane {
if(wallets != null) {
for(Wallet wallet : wallets) {
if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
return wallet.getKeystores().get(0);
Keystore keystore = wallet.getKeystores().get(0);
keystore.setLabel(importer.getName().replace(" Multisig", ""));
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setWalletModel(importer.getWalletModel());
return keystore;
}
}
throw new ImportException("Script type " + scriptType + " is not supported");
throw new ImportException("Script type " + scriptType.getDescription() + " is not supported in this QR. Check you are displaying the correct QR code.");
}
return null;
@@ -9,10 +9,7 @@ import com.sparrowwallet.sparrow.event.StorageEvent;
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 com.sparrowwallet.sparrow.io.*;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
@@ -83,8 +80,8 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(wallet);
fileChooser.setInitialFileName(wallet.getName() + "-" +
exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "") +
fileChooser.setInitialFileName(wallet.getName() +
(exporter instanceof Sparrow ? "" : "-" + exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "")) +
(extension == null || extension.isEmpty() ? "" : "." + extension));
AppServices.moveToActiveWindowScreen(window, 800, 450);
@@ -100,10 +97,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
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();
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
try {
@@ -113,10 +110,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
}
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
setError("Export Error", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
@@ -127,16 +124,17 @@ public class FileWalletExportPane extends TitledDescriptionPane {
private void exportWallet(File file, Wallet exportWallet) {
try {
if(file != null) {
OutputStream outputStream = new FileOutputStream(file);
exporter.exportWallet(exportWallet, outputStream);
EventManager.get().post(new WalletExportEvent(exportWallet));
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(exportWallet, outputStream);
EventManager.get().post(new WalletExportEvent(exportWallet));
}
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(exportWallet, outputStream);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig) {
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.io.ByteStreams;
import com.google.common.io.Files;
import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -27,6 +28,8 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.stream.Collectors;
public class FileWalletKeystoreImportPane extends FileImportPane {
private static final Logger log = LoggerFactory.getLogger(FileWalletKeystoreImportPane.class);
@@ -42,35 +45,62 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
this.fileName = fileName;
try {
fileBytes = ByteStreams.toByteArray(inputStream);
} catch(IOException e) {
throw new ImportException("Could not read file", e);
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
if(wallets != null && !wallets.isEmpty()) {
if(wallets.size() == 1 && scriptTypes.contains(wallets.get(0).getScriptType())) {
Wallet wallet = wallets.get(0);
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, wallet.getScriptType(), wallet.getKeystores(), null));
wallet.setName(importer.getName());
EventManager.get().post(new WalletImportEvent(wallets.get(0)));
} else {
scriptTypes.retainAll(wallets.stream().map(Wallet::getScriptType).collect(Collectors.toList()));
if(scriptTypes.isEmpty()) {
throw new ImportException("No singlesig script types present in QR code");
}
}
} else {
try {
fileBytes = ByteStreams.toByteArray(inputStream);
} catch(IOException e) {
throw new ImportException("Could not read file", e);
}
}
setContent(getScriptTypeEntry());
setContent(getScriptTypeEntry(scriptTypes));
setExpanded(true);
importButton.setDisable(true);
}
private void importWallet(ScriptType scriptType) throws ImportException {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
if(wallets != null && !wallets.isEmpty()) {
Wallet wallet = wallets.stream().filter(wallet1 -> wallet1.getScriptType() == scriptType).findFirst().orElseThrow(ImportException::new);
wallet.setName(importer.getName());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, wallet.getScriptType(), wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
Wallet wallet = new Wallet();
wallet.setName(fileName);
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
Wallet wallet = new Wallet();
wallet.setName(Files.getNameWithoutExtension(fileName));
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));
EventManager.get().post(new WalletImportEvent(wallet));
}
}
private Node getScriptTypeEntry() {
private Node getScriptTypeEntry(List<ScriptType> scriptTypes) {
Label label = new Label("Script Type:");
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE)));
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(scriptTypes));
if(scriptTypes.contains(ScriptType.P2WPKH)) {
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
}
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
@@ -6,6 +6,7 @@ import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTreeTableCell;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DataFormat;
import javafx.util.converter.DefaultStringConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,10 +29,11 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
setText(null);
setGraphic(null);
} else {
EntryCell.applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue());
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
setText(label);
setContextMenu(new LabelContextMenu(label));
setContextMenu(new LabelContextMenu(entry, label));
}
}
@@ -80,7 +82,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
private static class LabelContextMenu extends ContextMenu {
public LabelContextMenu(String label) {
public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label");
copyLabel.setOnAction(AE -> {
hide();
@@ -88,8 +90,20 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
content.putString(label);
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyLabel);
Object content = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
if(content instanceof String) {
MenuItem pasteLabel = new MenuItem("Paste Label");
pasteLabel.setOnAction(AE -> {
hide();
Object currentContent = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
if(currentContent instanceof String) {
entry.labelProperty().set((String)currentContent);
}
});
getItems().add(pasteLabel);
}
}
}
}
@@ -28,6 +28,7 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
public static final int MAX_PERIOD_HOURS = 2;
private static final double Y_VALUE_BREAK_MVB = 3.0;
private Tooltip tooltip;
@@ -68,19 +69,6 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
categoryAxis.setTickLabelRotation(0);
NumberAxis numberAxis = (NumberAxis)getYAxis();
numberAxis.setTickLabelFormatter(new StringConverter<Number>() {
@Override
public String toString(Number object) {
long vSizeBytes = object.longValue();
return (vSizeBytes / (1000 * 1000)) + " MvB";
}
@Override
public Number fromString(String string) {
return null;
}
});
this.setOnMouseMoved(mouseEvent -> {
Point2D sceneCoords = this.localToScene(mouseEvent.getX(), mouseEvent.getY());
String category = categoryAxis.getValueForDisplay(categoryAxis.sceneToLocal(sceneCoords).getX());
@@ -116,6 +104,24 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
previousFeeRate = feeRate;
}
final double maxMvB = getMaxMvB(getData());
numberAxis.setTickLabelFormatter(new StringConverter<Number>() {
@Override
public String toString(Number object) {
long vSizeBytes = object.longValue();
if(maxMvB > Y_VALUE_BREAK_MVB) {
return (vSizeBytes / (1000 * 1000)) + " MvB";
} else {
return (vSizeBytes / (1000)) + " kvB";
}
}
@Override
public Number fromString(String string) {
return null;
}
});
if(categories.keySet().iterator().hasNext()) {
String time = categories.values().iterator().next();
tooltip.setGraphic(new ChartTooltip(time, time, getData()));
@@ -159,20 +165,42 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
return categories;
}
private static double getMaxMvB(List<Series<String, Number>> seriesList) {
double maxTotal = 0d;
for(Series<String, Number> series : seriesList) {
maxTotal = Math.max(maxTotal, getMaxMvB(series));
}
return maxTotal;
}
private static double getMaxMvB(Series<String, Number> series) {
double total = 0d;
for(XYChart.Data<String, Number> data : series.getData()) {
double mvb = data.getYValue().doubleValue() / (1000 * 1000);
total += mvb;
}
return total;
}
private static class ChartTooltip extends VBox {
public ChartTooltip(String category, String time, List<Series<String, Number>> seriesList) {
Label title = new Label("At " + time);
HBox titleBox = new HBox(title);
title.setStyle("-fx-alignment: center; -fx-font-size: 12px; -fx-padding: 0 0 5 0;");
getChildren().add(titleBox);
double maxMvB = getMaxMvB(seriesList);
for(int i = seriesList.size() - 1; i >= 0; i--) {
Series<String, Number> series = seriesList.get(i);
for(XYChart.Data<String, Number> data : series.getData()) {
if(data.getXValue().equals(category)) {
double mvb = data.getYValue().doubleValue() / (1000 * 1000);
if(mvb >= 0.01) {
Label label = new Label(series.getName() + ": " + String.format("%.2f", mvb) + " MvB");
double kvb = data.getYValue().doubleValue() / 1000;
double mvb = kvb / 1000;
if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) {
String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB");
Label label = new Label(series.getName() + ": " + amount);
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
if(i < 8) {
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
@@ -24,6 +24,7 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
@@ -34,6 +35,7 @@ import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.security.SignatureException;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -43,8 +45,13 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private final TextField address;
private final TextArea message;
private final TextArea signature;
private final ToggleGroup formatGroup;
private final ToggleButton formatTrezor;
private final ToggleButton formatElectrum;
private final Wallet wallet;
private WalletNode walletNode;
private boolean electrumSignatureFormat;
private boolean closed;
/**
* Verification only constructor
@@ -69,6 +76,19 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
* @param walletNode Wallet node to derive address from
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode) {
this(wallet, walletNode, null, null);
}
/**
* Sign and verify with preset address, and supplied title, message and dialog buttons
*
* @param wallet Wallet to sign with
* @param walletNode Wallet node to derive address from
* @param title Header text of dialog
* @param msg Message to sign (all fields will be made uneditable)
* @param buttons The dialog buttons to display. If one contains the text "sign" it will trigger the signing process
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
if(wallet != null) {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
@@ -84,7 +104,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(wallet == null ? "Verify Message" : "Sign/Verify Message");
dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title);
Image image = new Image("image/seed.png", 50, 50, false, false);
if (!image.isError()) {
@@ -129,51 +149,90 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
signature.setWrapText(true);
signatureField.getInputs().add(signature);
fieldset.getChildren().addAll(addressField, messageField, signatureField);
Field formatField = new Field();
formatField.setText("Format:");
formatGroup = new ToggleGroup();
formatElectrum = new ToggleButton("Standard (Electrum)");
formatTrezor = new ToggleButton("BIP137 (Trezor)");
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor);
formatButtons.setToggleGroup(formatGroup);
formatField.getInputs().add(formatButtons);
formatGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
electrumSignatureFormat = (newValue == formatElectrum);
});
formatButtons.setDisable(wallet != null && walletNode != null && wallet.getScriptType() == ScriptType.P2PKH);
fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
if(msg != null) {
message.setText(msg);
address.setEditable(false);
message.setEditable(false);
signature.setEditable(false);
formatButtons.setDisable(true);
}
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE);
Button signButton = (Button)dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setOnAction(event -> {
signMessage();
});
if(buttons.length > 0) {
dialogPane.getButtonTypes().addAll(buttons);
Button verifyButton = (Button)dialogPane.lookupButton(verifyButtonType);
verifyButton.setDefaultButton(false);
verifyButton.setOnAction(event -> {
verifyMessage();
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
} catch(InvalidAddressException e) {
//can't happen
}
ButtonType customSignButtonType = Arrays.stream(buttons).filter(buttonType -> buttonType.getText().toLowerCase().contains("sign")).findFirst().orElse(null);
if(customSignButtonType != null) {
Button customSignButton = (Button)dialogPane.lookupButton(customSignButtonType);
customSignButton.setDefaultButton(true);
customSignButton.setOnAction(event -> {
customSignButton.setDisable(true);
signMessage();
setResult(ButtonBar.ButtonData.CANCEL_CLOSE);
});
}
});
} else {
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
Button signButton = (Button) dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setOnAction(event -> {
signMessage();
});
Button verifyButton = (Button) dialogPane.lookupButton(verifyButtonType);
verifyButton.setDefaultButton(false);
verifyButton.setOnAction(event -> {
verifyMessage();
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
} catch(InvalidAddressException e) {
//can't happen
}
}
});
}
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -182,21 +241,49 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return;
}
EventManager.get().unregister(this);
if(!closed) {
EventManager.get().unregister(this);
closed = true;
}
});
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(ButtonBar.ButtonData.CANCEL_CLOSE));
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
Platform.runLater(() -> {
if(address.getText().isEmpty()) {
address.requestFocus();
} else if(message.getText().isEmpty()) {
message.requestFocus();
}
formatGroup.selectToggle(formatElectrum);
});
}
private Address getAddress()throws InvalidAddressException {
return Address.fromString(address.getText());
}
public String getSignature() {
return signature.getText();
}
/**
* Use the Electrum signing format, which uses the non-segwit compressed signing parameters for both segwit types (p2sh-p2wpkh and p2wpkh)
*
* @param electrumSignatureFormat
*/
public void setElectrumSignatureFormat(boolean electrumSignatureFormat) {
formatGroup.selectToggle(electrumSignatureFormat ? formatElectrum : formatTrezor);
this.electrumSignatureFormat = electrumSignatureFormat;
}
private boolean isValidAddress() {
try {
getAddress();
return true;
Address address = getAddress();
return address.getScriptType() != ScriptType.P2TR;
} catch (InvalidAddressException e) {
return false;
}
@@ -228,7 +315,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//Note we can expect a single keystore due to the check above
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();
String signatureText = privKey.signMessage(message.getText().trim(), scriptType);
signature.clear();
signature.appendText(signatureText);
privKey.clear();
@@ -316,16 +404,16 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), 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"));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
}
@@ -20,6 +20,7 @@ import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.*;
import javafx.util.Callback;
import org.controlsfx.control.textfield.AutoCompletionBinding;
@@ -36,7 +37,6 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
@@ -159,15 +159,21 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
ObservableList<String> wordEntryList = FXCollections.observableArrayList(words);
wordEntriesProperty = new SimpleListProperty<>(wordEntryList);
List<WordEntry> wordEntries = new ArrayList<>(numWords);
for(int i = 0; i < numWords; i++) {
WordEntry wordEntry = new WordEntry(i, wordEntryList);
wordsPane.getChildren().add(wordEntry);
wordEntries.add(new WordEntry(i, wordEntryList));
}
for(int i = 0; i < numWords - 1; i++) {
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
wordEntries.get(i).setNextField(wordEntries.get(i + 1).getEditor());
}
wordsPane.getChildren().addAll(wordEntries);
vBox.getChildren().add(wordsPane);
if(!displayWordsOnly) {
PassphraseEntry passphraseEntry = new PassphraseEntry();
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
@@ -397,6 +403,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private static class WordEntry extends HBox {
private static List<String> wordList;
private final TextField wordField;
private WordEntry nextEntry;
private TextField nextField;
public WordEntry(int wordNumber, ObservableList<String> wordEntryList) {
super();
@@ -406,7 +414,29 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
Label label = new Label((wordNumber+1) + ".");
label.setPrefWidth(22);
label.setAlignment(Pos.CENTER_RIGHT);
wordField = new TextField();
wordField = new TextField() {
@Override
public void paste() {
Clipboard clipboard = Clipboard.getSystemClipboard();
if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) {
String[] words = clipboard.getString().split("[\\n\\s]");
WordEntry entry = WordEntry.this;
for(String word : words) {
if(entry.nextField != null) {
entry.nextField.requestFocus();
}
entry.wordField.setText(word);
entry = entry.nextEntry;
if(entry == null) {
break;
}
}
} else {
super.paste();
}
}
};
wordField.setMaxWidth(100);
TextFormatter<?> formatter = new TextFormatter<>((TextFormatter.Change change) -> {
String text = change.getText();
@@ -425,13 +455,18 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
autoCompletionBinding.setDelay(50);
autoCompletionBinding.setOnAutoCompleted(event -> {
if(nextField != null) {
nextField.requestFocus();
}
});
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(wordField, Validator.combine(
Validator.createEmptyValidator("Word is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
wordEntryList.set(wordNumber, newValue);
@@ -444,6 +479,14 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
return wordField;
}
public void setNextEntry(WordEntry nextEntry) {
this.nextEntry = nextEntry;
}
public void setNextField(TextField field) {
this.nextField = field;
}
public static boolean isValid(String word) {
return wordList.contains(word);
}
@@ -461,10 +504,6 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
List<String> suggestions = new ArrayList<>();
if(!request.getUserText().isEmpty()) {
for(String word : wordList) {
if(word.equals(request.getUserText())) {
return Collections.emptyList();
}
if(word.startsWith(request.getUserText())) {
suggestions.add(word);
}
@@ -476,13 +515,15 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
private class PassphraseEntry extends HBox {
private final CustomTextField passphraseField;
public PassphraseEntry() {
super();
setAlignment(Pos.CENTER_LEFT);
setSpacing(10);
Label passphraseLabel = new Label("Passphrase:");
CustomTextField passphraseField = (CustomTextField) TextFields.createClearableTextField();
passphraseField = (CustomTextField) TextFields.createClearableTextField();
passphraseProperty.bind(passphraseField.textProperty());
passphraseField.setPromptText("Leave blank for none");
@@ -492,6 +533,10 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
getChildren().addAll(passphraseLabel, passphraseField, helpLabel);
}
public TextField getEditor() {
return passphraseField;
}
}
private Node getDerivationEntry(List<ChildNumber> derivation) {
@@ -501,11 +546,11 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
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);
@@ -33,9 +33,9 @@ public class QRDisplayDialog extends Dialog<UR> {
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int MAX_FRAGMENT_LENGTH = 100;
private static final int MAX_FRAGMENT_LENGTH = 250;
private static final int ANIMATION_PERIOD_MILLIS = 400;
private static final int ANIMATION_PERIOD_MILLIS = 200;
private static final int QR_WIDTH = 480;
private static final int QR_HEIGHT = 480;
@@ -232,7 +232,7 @@ public class QRDisplayDialog extends Dialog<UR> {
if(useLegacyEncoding) {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
} else {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE));
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
@@ -1,9 +1,6 @@
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.github.sarxos.webcam.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
@@ -41,6 +38,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.slf4j.Logger;
@@ -74,6 +72,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0);
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
public QRScanDialog() {
this.decoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder();
@@ -82,7 +82,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
webcamResolutionProperty.set(WebcamResolution.HD);
}
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener(), new ScanDelayCalculator());
this.webcamService = new WebcamService(webcamResolutionProperty.get(), null, new QRScanListener(), new ScanDelayCalculator());
webcamService.setPeriod(Duration.millis(SCAN_PERIOD_MILLIS));
webcamService.setRestartOnFailure(false);
WebcamView webcamView = new WebcamView(webcamService);
@@ -104,6 +104,15 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(newValue ? 0.0 : -1.0));
}
Platform.runLater(() -> {
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
for(WebcamDevice device : WebcamScanDriver.getFoundDevices()) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
webcamDeviceProperty.set(device);
}
}
}
});
});
VBox vBox = new VBox(20);
@@ -113,7 +122,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> {
Platform.runLater(() -> setResult(new Result(failedEvent.getSource().getException())));
Throwable exception = failedEvent.getSource().getException();
Throwable nested = exception;
while(nested.getCause() != null) {
nested = nested.getCause();
}
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS &&
nested.getMessage().startsWith("Library 'OpenIMAJGrabber' was not loaded successfully from file")) {
exception = new WebcamDependencyException("Your system is missing a dependency required for the webcam. Follow the link below for more details.\n\n[https://sparrowwallet.com/docs/faq.html#your-system-is-missing-a-dependency-for-the-webcam]", exception);
} else if(nested.getMessage().startsWith("Cannot start native grabber") && Config.get().getWebcamDevice() != null) {
exception = new WebcamOpenException("Cannot open configured webcam " + Config.get().getWebcamDevice() + ", reverting to the default webcam");
Config.get().setWebcamDevice(null);
}
final Throwable result = exception;
Platform.runLater(() -> setResult(new Result(result)));
});
webcamService.start();
webcamResolutionProperty.addListener((observable, oldValue, newResolution) -> {
@@ -122,6 +146,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
webcamService.cancel();
});
webcamDeviceProperty.addListener((observable, oldValue, newValue) -> {
Config.get().setWebcamDevice(newValue.getName());
if(!Objects.equals(webcamService.getDevice(), newValue)) {
webcamService.cancel();
}
});
setOnCloseRequest(event -> {
boolean isHdCapture = (webcamResolutionProperty.get() == WebcamResolution.HD);
@@ -134,7 +164,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().addAll(hdButtonType, cancelButtonType);
final ButtonType camButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
AppServices.moveToActiveWindowScreen(this);
@@ -168,7 +199,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
} else {
decoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getEstimatedPercentComplete()));
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0));
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
@@ -197,7 +228,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(parts.stream().filter(Objects::nonNull).count() == n) {
String complete = String.join("", parts);
try {
PSBT psbt = PSBT.fromString(complete);
PSBT psbt = PSBT.fromString(complete, false);
result = new Result(psbt);
return;
} catch(PSBTParseException e) {
@@ -249,7 +280,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
try {
psbt = PSBT.fromString(qrtext);
psbt = PSBT.fromString(qrtext, false);
result = new Result(psbt);
return;
} catch(PSBTParseException e) {
@@ -261,7 +292,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
try {
psbt = new PSBT(qrResult.getRawBytes());
psbt = new PSBT(qrResult.getRawBytes(), false);
result = new Result(psbt);
return;
} catch(Exception e) {
@@ -286,7 +317,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Try Base43 used by Electrum
try {
psbt = new PSBT(Base43.decode(qrtext));
psbt = new PSBT(Base43.decode(qrtext), false);
result = new Result(psbt);
return;
} catch(Exception e) {
@@ -312,7 +343,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(urRegistryType.equals(RegistryType.BYTES)) {
byte[] urBytes = (byte[])ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(urBytes);
PSBT psbt = new PSBT(urBytes, false);
return new Result(psbt);
} catch(PSBTParseException e) {
if(PSBT.isPSBT(urBytes)) {
@@ -342,7 +373,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else if(urRegistryType.equals(RegistryType.CRYPTO_PSBT)) {
CryptoPSBT cryptoPSBT = (CryptoPSBT)ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(cryptoPSBT.getPsbt());
PSBT psbt = new PSBT(cryptoPSBT.getPsbt(), false);
return new Result(psbt);
} catch(Exception e) {
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
@@ -457,7 +488,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey();
wallet.setScriptType(outputDescriptor.getScriptType());
Keystore keystore = new Keystore();
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath()));
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(outputDescriptor.getKeyDerivation(extendedKey).getDerivation())));
keystore.setExtendedPublicKey(extendedKey);
wallet.getKeystores().add(keystore);
wallets.add(wallet);
@@ -508,6 +539,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public void webcamClosed(WebcamEvent webcamEvent) {
if(webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> {
if(!webcamService.isRunning()) {
webcamService.reset();
@@ -546,10 +578,32 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
});
button = hd;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<WebcamDevice> devicesCombo = new ComboBox<>(WebcamScanDriver.getFoundDevices());
devicesCombo.setConverter(new StringConverter<>() {
@Override
public String toString(WebcamDevice device) {
return device instanceof WebcamScanDevice ? ((WebcamScanDevice)device).getDeviceName() : "Default Camera";
}
@Override
public WebcamDevice fromString(String string) {
throw new UnsupportedOperationException();
}
});
devicesCombo.valueProperty().bindBidirectional(webcamDeviceProperty);
ButtonBar.setButtonData(devicesCombo, ButtonBar.ButtonData.LEFT);
button = devicesCombo;
} else {
button = super.createButton(buttonType);
}
if(button instanceof Region) {
((Region)button).setPrefWidth(150);
((Region)button).setMaxWidth(150);
}
button.disableProperty().bind(webcamService.openingProperty());
return button;
}
@@ -558,7 +612,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(isHd) {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
} else {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE));
hd.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
@@ -715,6 +769,42 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
}
public static class WebcamDependencyException extends ScanException {
public WebcamDependencyException() {
super();
}
public WebcamDependencyException(String message) {
super(message);
}
public WebcamDependencyException(Throwable cause) {
super(cause);
}
public WebcamDependencyException(String message, Throwable cause) {
super(message, cause);
}
}
public static class WebcamOpenException extends ScanException {
public WebcamOpenException() {
super();
}
public WebcamOpenException(String message) {
super(message);
}
public WebcamOpenException(Throwable cause) {
super(cause);
}
public WebcamOpenException(String message, Throwable cause) {
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);
@@ -0,0 +1,302 @@
package com.sparrowwallet.sparrow.control;
import com.csvreader.CsvReader;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
private final SpreadsheetView spreadsheetView;
public static final AddressCellType ADDRESS = new AddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit) {
this.bitcoinUnit = bitcoinUnit;
final DialogPane dialogPane = new SendToManyDialogPane();
setDialogPane(dialogPane);
setTitle("Send to Many");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary.");
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
List<Payment> initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList());
Grid grid = getGrid(initialPayments);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.getColumns().get(0).setPrefWidth(400);
spreadsheetView.getColumns().get(1).setPrefWidth(150);
spreadsheetView.getColumns().get(2).setPrefWidth(247);
StackPane stackPane = new StackPane();
stackPane.getChildren().add(spreadsheetView);
dialogPane.setContent(stackPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load CSV", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getPayments() : null;
});
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
private Grid getGrid(List<Payment> payments) {
int rowCount = payments.size();
int columnCount = 3;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
for(int row = 0; row < grid.getRowCount(); ++row) {
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
SpreadsheetCell addressCell = ADDRESS.createCell(row, 0, 1, 1, payments.get(row).getAddress());
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
double amount = (double)payments.get(row).getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount);
amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###");
amountCell.getStyleClass().add("number-value");
if(Platform.getCurrent() == Platform.OSX) {
amountCell.getStyleClass().add("number-field");
}
list.add(amountCell);
list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, payments.get(row).getLabel()));
rows.add(list);
}
grid.setRows(rows);
grid.getColumnHeaders().setAll("Address", "Amount (" + bitcoinUnit.getLabel() + ")", "Label");
return grid;
}
private List<Payment> getPayments() {
List<Payment> payments = new ArrayList<>();
Grid grid = spreadsheetView.getGrid();
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
Address address = (Address)rowCells.get(0).getItem();
Double value = (Double)rowCells.get(1).getItem();
String label = (String)rowCells.get(2).getItem();
if(firstLabel == null) {
firstLabel = label;
}
if(label == null || label.isEmpty()) {
label = firstLabel;
}
if(address != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(new Payment(address, label, value.longValue(), false));
}
}
return payments;
}
private class SendToManyDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open CSV");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("CSV", "*.csv")
);
AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450);
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try {
List<Payment> csvPayments = new ArrayList<>();
try(Reader reader = new FileReader(file, StandardCharsets.UTF_8)) {
CsvReader csvReader = new CsvReader(reader);
while(csvReader.readRecord()) {
if(csvReader.getColumnCount() < 2) {
continue;
}
try {
long amount;
if(bitcoinUnit == BitcoinUnit.BTC) {
double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", ""));
amount = (long)(doubleAmount * Transaction.SATOSHIS_PER_BITCOIN);
} else {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
}
Address address = Address.fromString(csvReader.get(0));
String label = csvReader.get(2);
csvPayments.add(new Payment(address, label, amount, false));
} catch(NumberFormatException e) {
//ignore and continue - probably a header line
} catch(InvalidAddressException e) {
AppServices.showErrorDialog("Invalid Address", e.getMessage());
}
}
if(csvPayments.isEmpty()) {
AppServices.showErrorDialog("No recipients found", "No valid recipients were found. Use a CSV file with three columns, and ensure amounts are in " + bitcoinUnit.getLabel() + ".");
return;
}
spreadsheetView.setGrid(getGrid(csvPayments));
}
} catch(IOException e) {
AppServices.showErrorDialog("Cannot load CSV", e.getMessage());
}
}
});
button = loadButton;
} 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;
}
}
public static class AddressCellType extends SpreadsheetCellType<Address> {
public AddressCellType() {
this(new StringConverterWithFormat<>(new AddressStringConverter()) {
@Override
public String toString(Address item) {
return toStringFormat(item, ""); //$NON-NLS-1$
}
@Override
public Address fromString(String str) {
if(str == null || str.isEmpty()) { //$NON-NLS-1$
return null;
} else {
return myConverter.fromString(str);
}
}
@Override
public String toStringFormat(Address item, String format) {
try {
if(item == null) {
return ""; //$NON-NLS-1$
} else {
return item.toString();
}
} catch (Exception ex) {
return myConverter.toString(item);
}
}
});
}
public AddressCellType(StringConverter<Address> converter) {
super(converter);
}
@Override
public String toString() {
return "address";
}
public SpreadsheetCell createCell(final int row, final int column, final int rowSpan, final int columnSpan,
final Address value) {
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
cell.setItem(value);
return cell;
}
@Override
public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
return new SpreadsheetCellEditor.StringEditor(view);
}
@Override
public boolean match(Object value, Object... options) {
if(value instanceof Address)
return true;
else {
try {
converter.fromString(value == null ? null : value.toString());
return true;
} catch (Exception e) {
return false;
}
}
}
@Override
public Address convertValue(Object value) {
if(value instanceof Address)
return (Address)value;
else {
try {
return converter.fromString(value == null ? null : value.toString());
} catch (Exception e) {
return null;
}
}
}
@Override
public String toString(Address item) {
return converter.toString(item);
}
@Override
public String toString(Address item, String format) {
return ((StringConverterWithFormat<Address>)converter).toStringFormat(item, format);
}
};
}
@@ -12,10 +12,14 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
public class TextAreaDialog extends Dialog<String> {
private static final Logger log = LoggerFactory.getLogger(TextAreaDialog.class);
private final TextArea textArea;
private final String defaultValue;
@@ -99,6 +103,7 @@ public class TextAreaDialog extends Dialog<String> {
} else if(result.outputDescriptor != null) {
textArea.setText(result.outputDescriptor.toString(true));
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
AppServices.showErrorDialog("Error scanning QR", result.exception.getMessage());
}
}
@@ -16,6 +16,7 @@ import java.util.Arrays;
import java.util.OptionalDouble;
public class TitledDescriptionPane extends TitledPane {
private Label mainLabel;
private Label descriptionLabel;
protected Hyperlink showHideLink;
protected HBox buttonBox;
@@ -50,7 +51,7 @@ public class TitledDescriptionPane extends TitledPane {
VBox labelsBox = new VBox();
labelsBox.setSpacing(5);
labelsBox.setAlignment(Pos.CENTER_LEFT);
Label mainLabel = new Label();
mainLabel = new Label();
mainLabel.setText(title);
mainLabel.getStyleClass().add("main-label");
labelsBox.getChildren().add(mainLabel);
@@ -99,6 +100,10 @@ public class TitledDescriptionPane extends TitledPane {
return null;
}
public String getTitle() {
return mainLabel.getText();
}
protected void setDescription(String text) {
descriptionLabel.getStyleClass().remove("description-error");
descriptionLabel.getStyleClass().add("description-label");
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -10,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -200,6 +202,7 @@ public class TransactionDiagram extends GridPane {
private Pane getInputsLabels(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
VBox inputsBox = new VBox();
inputsBox.setMaxWidth(150);
inputsBox.setPrefWidth(150);
inputsBox.setPadding(new Insets(0, 10, 0, 10));
inputsBox.minHeightProperty().bind(minHeightProperty());
inputsBox.setAlignment(Pos.CENTER_RIGHT);
@@ -388,14 +391,33 @@ public class TransactionDiagram extends GridPane {
}
if(walletTx.getChangeNode() != null) {
WalletNode defaultChangeNode = walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE);
boolean overGapLimit = (walletTx.getChangeNode().getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit();
HBox actionBox = new HBox();
String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "...";
Label changeLabel = new Label(changeDesc, getChangeGlyph());
Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph());
changeLabel.getStyleClass().addAll("output-label", "change-label");
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString());
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
changeTooltip.getStyleClass().add("change-label");
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
changeLabel.setTooltip(changeTooltip);
outputsBox.getChildren().add(changeLabel);
Button nextChangeAddressButton = new Button("");
nextChangeAddressButton.setGraphic(getChangeReplaceGlyph());
nextChangeAddressButton.setOnAction(event -> {
EventManager.get().post(new ReplaceChangeAddressEvent(walletTx));
});
Tooltip replaceChangeTooltip = new Tooltip("Use next change address");
nextChangeAddressButton.setTooltip(replaceChangeTooltip);
Label replaceChangeLabel = new Label("", nextChangeAddressButton);
replaceChangeLabel.getStyleClass().add("replace-change-label");
replaceChangeLabel.setVisible(false);
actionBox.setOnMouseEntered(event -> replaceChangeLabel.setVisible(true));
actionBox.setOnMouseExited(event -> replaceChangeLabel.setVisible(false));
actionBox.getChildren().addAll(changeLabel, replaceChangeLabel);
outputsBox.getChildren().add(actionBox);
outputsBox.getChildren().add(createSpacer());
}
@@ -484,6 +506,20 @@ public class TransactionDiagram extends GridPane {
return changeGlyph;
}
public static Glyph getChangeWarningGlyph() {
Glyph changeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
changeWarningGlyph.getStyleClass().add("change-warning-icon");
changeWarningGlyph.setFontSize(12);
return changeWarningGlyph;
}
public static Glyph getChangeReplaceGlyph() {
Glyph changeReplaceGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
changeReplaceGlyph.getStyleClass().add("change-replace-icon");
changeReplaceGlyph.setFontSize(12);
return changeReplaceGlyph;
}
private Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
feeGlyph.getStyleClass().add("fee-icon");
@@ -4,7 +4,11 @@ import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import javafx.geometry.Point2D;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.stage.Popup;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.event.MouseOverTextEvent;
@@ -33,7 +37,8 @@ public class TransactionHexArea extends CodeArea {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
transaction.bitcoinSerializeToStream(baos);
String hex = Utils.bytesToHex(baos.toByteArray());
String fullHex = Utils.bytesToHex(baos.toByteArray());
String hex = fullHex;
if(hex.length() > TRUNCATE_AT) {
hex = hex.substring(0, TRUNCATE_AT);
hex += "[truncated]";
@@ -42,6 +47,7 @@ public class TransactionHexArea extends CodeArea {
clear();
appendText(hex);
previousSegmentList = new ArrayList<>();
setContextMenu(new TransactionHexContextMenu(fullHex));
} catch (IOException e) {
throw new IllegalStateException("Can't happen");
}
@@ -266,4 +272,18 @@ public class TransactionHexArea extends CodeArea {
return Objects.hash(start, length, style);
}
}
private static class TransactionHexContextMenu extends ContextMenu {
public TransactionHexContextMenu(String hex) {
MenuItem copy = new MenuItem("Copy All");
copy.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(hex);
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copy);
}
}
}
@@ -45,12 +45,12 @@ public class TransactionIdDialog extends Dialog<Sha256Hash> {
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(txid, Validator.combine(
Validator.createEmptyValidator("Transaction id is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID length incorrect", newValue.length() != 64),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID must be hexadecimal", !Utils.isHex(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Open Transaction", ButtonBar.ButtonData.OK_DONE);
@@ -44,16 +44,18 @@ public class UtxosChart extends BarChart<String, Number> {
totalUtxos = utxoDataList.size();
if(utxoDataList.size() > MAX_BARS) {
Long otherTotal = utxoDataList.subList(MAX_BARS - 1, utxoDataList.size()).stream().mapToLong(data -> data.getYValue().longValue()).sum();
List<Data<String, Number>> otherDataList = utxoDataList.subList(MAX_BARS - 1, utxoDataList.size());
List<Entry> otherEntries = otherDataList.stream().map(data -> (Entry)data.getExtraValue()).collect(Collectors.toList());
Long otherTotal = otherDataList.stream().mapToLong(data -> data.getYValue().longValue()).sum();
utxoDataList = utxoDataList.subList(0, MAX_BARS - 1);
utxoDataList.add(new XYChart.Data<>(OTHER_CATEGORY, otherTotal));
utxoDataList.add(new XYChart.Data<>(OTHER_CATEGORY, otherTotal, otherEntries));
}
for(int i = 0; i < utxoDataList.size(); i++) {
XYChart.Data<String, Number> newData = utxoDataList.get(i);
if(i < utxoSeries.getData().size()) {
XYChart.Data<String, Number> existingData = utxoSeries.getData().get(i);
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() != null && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() instanceof Entry && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
utxoSeries.getData().set(i, newData);
}
} else {
@@ -87,7 +89,8 @@ public class UtxosChart extends BarChart<String, Number> {
for(int i = 0; i < utxoSeries.getData().size(); i++) {
XYChart.Data<String, Number> data = utxoSeries.getData().get(i);
if((data.getExtraValue() != null && entries.contains((Entry)data.getExtraValue())) || (data.getExtraValue() == null && entries.size() == totalUtxos)) {
if((data.getExtraValue() instanceof Entry && entries.contains((Entry)data.getExtraValue())) ||
(data.getExtraValue() instanceof List && entries.containsAll((List)data.getExtraValue()))) {
Node bar = lookup(".data" + i);
if(bar != null) {
bar.getStyleClass().add("selected");
@@ -52,10 +52,10 @@ public class WalletBirthDateDialog extends Dialog<Date> {
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(birthDatePicker, Validator.combine(
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", newValue == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
@@ -11,6 +11,7 @@ import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Comparator;
import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> {
@@ -43,7 +44,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
if(wallet.getPolicyType() == PolicyType.SINGLE) {
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 PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -53,6 +54,8 @@ public class WalletExportDialog extends Dialog<Wallet> {
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
exportAccordion.getPanes().add(exportPane);
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(exportAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -16,6 +16,7 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import java.util.Comparator;
import java.util.List;
public class WalletImportDialog extends Dialog<Wallet> {
@@ -47,17 +48,19 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new SpecterDIY());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
}
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig());
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
}
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(importAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -92,7 +95,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
List<Device> devices = enumerateService.getValue();
importAccordion.getPanes().removeIf(titledPane -> titledPane instanceof DevicePane);
for(Device device : devices) {
DevicePane devicePane = new DevicePane(new Wallet(), device);
DevicePane devicePane = new DevicePane(new Wallet(), device, devices.size() == 1);
importAccordion.getPanes().add(0, devicePane);
}
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
@@ -93,6 +93,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(name, Validator.combine(
Validator.createEmptyValidator("Wallet name is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Wallet name is not unique", Storage.walletExists(newValue))
@@ -100,7 +101,6 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
validationSupport.registerValidator(existingPicker, Validator.combine(
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", existingCheck.isSelected() && newValue == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
@@ -77,8 +77,8 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.registerValidator(passwordConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Password confirmation does not match", !passwordConfirm.getText().equals(password.getText())));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(passwordConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Password confirmation does not match", !passwordConfirm.getText().equals(password.getText())));
});
okButtonType = new javafx.scene.control.ButtonType(requirement.okButtonText, ButtonBar.ButtonData.OK_DONE);
@@ -14,6 +14,7 @@ import java.awt.image.*;
import java.nio.ByteBuffer;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -312,6 +313,23 @@ public class WebcamScanDevice implements WebcamDevice, WebcamDevice.BufferAccess
return this.fps;
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(o == null || getClass() != o.getClass()) {
return false;
}
WebcamScanDevice that = (WebcamScanDevice) o;
return Objects.equals(fullname, that.fullname);
}
@Override
public int hashCode() {
return Objects.hash(fullname);
}
static {
DIMENSIONS = new Dimension[]{WebcamResolution.QQVGA.getSize(), WebcamResolution.QVGA.getSize(), WebcamResolution.VGA.getSize()};
BAND_OFFSETS = new int[]{0, 1, 2};
@@ -3,20 +3,43 @@ 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 javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import java.util.ArrayList;
import java.util.List;
public class WebcamScanDriver extends WebcamDefaultDriver {
private static final ObservableList<WebcamDevice> webcamDevices = FXCollections.observableArrayList();
private static boolean rescan;
@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()));
if(rescan || webcamDevices.isEmpty()) {
List<WebcamDevice> devices = super.getDevices();
List<WebcamDevice> scanDevices = new ArrayList<>();
for(WebcamDevice device : devices) {
WebcamDefaultDevice defaultDevice = (WebcamDefaultDevice)device;
WebcamScanDevice scanDevice = new WebcamScanDevice(defaultDevice.getDeviceRef());
if(scanDevices.stream().noneMatch(dev -> ((WebcamScanDevice)dev).getDeviceName().equals(scanDevice.getDeviceName()))) {
scanDevices.add(scanDevice);
}
}
List<WebcamDevice> newDevices = new ArrayList<>(scanDevices);
newDevices.removeAll(webcamDevices);
webcamDevices.addAll(newDevices);
webcamDevices.removeIf(device -> !scanDevices.contains(device));
}
return scanDevices;
return webcamDevices;
}
public static ObservableList<WebcamDevice> getFoundDevices() {
return webcamDevices;
}
public static void rescan() {
rescan = true;
}
}
@@ -1,12 +1,11 @@
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.github.sarxos.webcam.*;
import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -23,26 +22,30 @@ import java.util.concurrent.TimeUnit;
public class WebcamService extends ScheduledService<Image> {
private WebcamResolution resolution;
private WebcamDevice device;
private final WebcamListener listener;
private final WebcamUpdater.DelayCalculator delayCalculator;
private final BooleanProperty opening = new SimpleBooleanProperty(false);
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
private static final int QR_SAMPLE_PERIOD_MILLIS = 400;
private static final int QR_SAMPLE_PERIOD_MILLIS = 200;
private Webcam cam;
private long lastQrSampleTime;
private final Reader qrReader;
static {
Webcam.setDriver(new WebcamScanDriver());
}
public WebcamService(WebcamResolution resolution, WebcamListener listener, WebcamUpdater.DelayCalculator delayCalculator) {
public WebcamService(WebcamResolution resolution, WebcamDevice device, WebcamListener listener, WebcamUpdater.DelayCalculator delayCalculator) {
this.resolution = resolution;
this.device = device;
this.listener = listener;
this.delayCalculator = delayCalculator;
this.lastQrSampleTime = System.currentTimeMillis();
this.qrReader = new QRCodeReader();
}
@Override
@@ -58,6 +61,23 @@ public class WebcamService extends ScheduledService<Image> {
}
cam = webcams.get(0);
if(device != null) {
for(Webcam webcam : webcams) {
if(webcam.getDevice().getName().equals(device.getName())) {
cam = webcam;
}
}
} else if(Config.get().getWebcamDevice() != null) {
for(Webcam webcam : webcams) {
if(webcam.getDevice().getName().equals(Config.get().getWebcamDevice())) {
cam = webcam;
}
}
}
device = cam.getDevice();
cam.setCustomViewSizes(resolution.getSize());
cam.setViewSize(resolution.getSize());
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
@@ -70,6 +90,10 @@ public class WebcamService extends ScheduledService<Image> {
}
BufferedImage bimg = cam.getImage();
if(bimg == null) {
return null;
}
Image image = SwingFXUtils.toFXImage(bimg, null);
updateValue(image);
@@ -106,9 +130,9 @@ public class WebcamService extends ScheduledService<Image> {
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
Result result = new MultiFormatReader().decode(bitmap);
Result result = qrReader.decode(bitmap);
resultProperty.set(result);
} catch(NotFoundException e) {
} catch(ReaderException e) {
// fall thru, it means there is no QR code in image
}
}
@@ -133,6 +157,14 @@ public class WebcamService extends ScheduledService<Image> {
this.resolution = resolution;
}
public WebcamDevice getDevice() {
return device;
}
public void setDevice(WebcamDevice device) {
this.device = device;
}
public boolean isOpening() {
return opening.get();
}
@@ -135,11 +135,11 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
}
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
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);
@@ -176,11 +176,11 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
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);
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class KeystoreEncryptionChangedEvent extends WalletSettingsChangedEvent {
private List<Keystore> changedKeystores;
public KeystoreEncryptionChangedEvent(Wallet wallet, Wallet pastWallet, String walletId, List<Keystore> changedKeystores) {
super(wallet, pastWallet, walletId);
this.changedKeystores = changedKeystores;
}
public List<Keystore> getChangedKeystores() {
return changedKeystores;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
/**
* This event is trigger when one or more keystores on a wallet are updated, and the wallet is saved
*/
public class KeystoreLabelsChangedEvent extends WalletSettingsChangedEvent {
private final List<Keystore> changedKeystores;
public KeystoreLabelsChangedEvent(Wallet wallet, Wallet pastWallet, String walletId, List<Keystore> changedKeystores) {
super(wallet, pastWallet, walletId);
this.changedKeystores = changedKeystores;
}
public List<Keystore> getChangedKeystores() {
return changedKeystores;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class LoadingLogChangedEvent {
private final boolean visible;
public LoadingLogChangedEvent(boolean visible) {
this.visible = visible;
}
public boolean isVisible() {
return visible;
}
}
@@ -1,15 +1,20 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
public class ReceiveActionEvent {
private final NodeEntry receiveEntry;
private final Wallet wallet;
public ReceiveActionEvent(NodeEntry receiveEntry) {
this.receiveEntry = receiveEntry;
this.wallet = receiveEntry.getWallet();
}
public NodeEntry getReceiveEntry() {
return receiveEntry;
public ReceiveActionEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.net.Aopp;
public class ReceiveProofEvent {
private final Wallet wallet;
private final Aopp aopp;
public ReceiveProofEvent(Wallet wallet, Aopp aopp) {
this.wallet = wallet;
this.aopp = aopp;
}
public Wallet getWallet() {
return wallet;
}
public Aopp getAopp() {
return aopp;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
public class ReplaceChangeAddressEvent {
private final WalletTransaction walletTransaction;
public ReplaceChangeAddressEvent(WalletTransaction walletTx) {
this.walletTransaction = walletTx;
}
public WalletTransaction getWalletTransaction() {
return walletTransaction;
}
}
@@ -2,17 +2,30 @@ package com.sparrowwallet.sparrow.event;
import javafx.stage.Window;
import java.io.File;
/**
* Event class used to request the transaction open file dialog
*/
public class RequestTransactionOpenEvent {
private final Window window;
private final File file;
public RequestTransactionOpenEvent(Window window) {
this.window = window;
this.file = null;
}
public RequestTransactionOpenEvent(Window window, File file) {
this.window = window;
this.file = file;
}
public Window getWindow() {
return window;
}
public File getFile() {
return file;
}
}
@@ -2,17 +2,30 @@ package com.sparrowwallet.sparrow.event;
import javafx.stage.Window;
import java.io.File;
/**
* Event class used to request the wallet open dialog
*/
public class RequestWalletOpenEvent {
private final Window window;
private final File file;
public RequestWalletOpenEvent(Window window) {
this.window = window;
this.file = null;
}
public RequestWalletOpenEvent(Window window, File file) {
this.window = window;
this.file = file;
}
public Window getWindow() {
return window;
}
public File getFile() {
return file;
}
}
@@ -0,0 +1,24 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class SendPaymentsEvent {
private final Wallet wallet;
private final List<Payment> payments;
public SendPaymentsEvent(Wallet wallet, List<Payment> payments) {
this.wallet = wallet;
this.payments = payments;
}
public Wallet getWallet() {
return wallet;
}
public List<Payment> getPayments() {
return payments;
}
}
@@ -8,9 +8,9 @@ import java.util.Map;
public class StorageEvent extends TimedEvent {
private static boolean firstRunDone = false;
private static final Map<File, Long> eventTime = new HashMap<>();
private static final Map<String, Long> eventTime = new HashMap<>();
public StorageEvent(File file, Action action, String status) {
public StorageEvent(String walletId, Action action, String status) {
super(action, status);
Integer keyDerivationPeriod = Config.get().getKeyDerivationPeriod();
@@ -19,10 +19,10 @@ public class StorageEvent extends TimedEvent {
}
if(action == Action.START) {
eventTime.put(file, System.currentTimeMillis());
eventTime.put(walletId, System.currentTimeMillis());
timeMills = keyDerivationPeriod;
} else if(action == Action.END) {
long start = eventTime.get(file);
long start = eventTime.get(walletId);
if(firstRunDone) {
keyDerivationPeriod = (int)(System.currentTimeMillis() - start);
Config.get().setKeyDerivationPeriod(keyDerivationPeriod);
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class UtxosChartChangedEvent {
private final boolean visible;
public UtxosChartChangedEvent(boolean visible) {
this.visible = visible;
}
public boolean isVisible() {
return visible;
}
}
@@ -11,8 +11,8 @@ import java.io.File;
* 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);
public class WalletAddressesChangedEvent extends WalletHistoryClearedEvent {
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -1,21 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.Entry;
/**
* This event is fired when a wallet entry (transaction, txi or txo) label is changed.
* Extends WalletDataChangedEvent so triggers a background save.
*/
public class WalletEntryLabelChangedEvent extends WalletDataChangedEvent {
private final Entry entry;
public WalletEntryLabelChangedEvent(Wallet wallet, Entry entry) {
super(wallet);
this.entry = entry;
}
public Entry getEntry() {
return entry;
}
}
@@ -0,0 +1,27 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.Entry;
import java.util.List;
/**
* This event is fired when a wallet entry (transaction, txi or txo) label is changed.
*/
public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
private final List<Entry> entries;
public WalletEntryLabelsChangedEvent(Wallet wallet, Entry entry) {
super(wallet);
this.entries = List.of(entry);
}
public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries) {
super(wallet);
this.entries = entries;
}
public List<Entry> getEntries() {
return entries;
}
}
@@ -23,8 +23,8 @@ public class WalletHistoryChangedEvent extends WalletChangedEvent {
this.historyChangedNodes = historyChangedNodes;
}
public File getWalletFile() {
return storage.getWalletFile();
public String getWalletId() {
return storage.getWalletId(getWallet());
}
public List<WalletNode> getHistoryChangedNodes() {
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryClearedEvent extends WalletSettingsChangedEvent {
public WalletHistoryClearedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -6,7 +6,7 @@ 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());
super(wallet, exception.getMessage());
this.exception = exception;
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletPasswordChangedEvent extends WalletSettingsChangedEvent {
public WalletPasswordChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -5,25 +5,24 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
/**
* 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
* This is the base class for events posted when a wallet's settings are changed
* Do not listen for this event directly - listen for a subclass, for example KeystoreLabelsChangedEvent or WalletAddressesChangedEvent
*/
public class WalletSettingsChangedEvent extends WalletChangedEvent {
private final Wallet pastWallet;
private final File walletFile;
private final String walletId;
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet);
this.pastWallet = pastWallet;
this.walletFile = walletFile;
this.walletId = walletId;
}
public Wallet getPastWallet() {
return pastWallet;
}
public File getWalletFile() {
return walletFile;
public String getWalletId() {
return walletId;
}
}
@@ -3,15 +3,17 @@ package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletUtxoStatusChangedEvent extends WalletDataChangedEvent {
private final BlockTransactionHashIndex utxo;
import java.util.List;
public WalletUtxoStatusChangedEvent(Wallet wallet, BlockTransactionHashIndex utxo) {
public class WalletUtxoStatusChangedEvent extends WalletChangedEvent {
private final List<BlockTransactionHashIndex> utxos;
public WalletUtxoStatusChangedEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
super(wallet);
this.utxo = utxo;
this.utxos = utxos;
}
public BlockTransactionHashIndex getUtxo() {
return utxo;
public List<BlockTransactionHashIndex> getUtxos() {
return utxos;
}
}
@@ -19,11 +19,13 @@ public class FontAwesome5 extends GlyphFont {
ANGLE_DOUBLE_RIGHT('\uf101'),
ARROW_DOWN('\uf063'),
ARROW_UP('\uf062'),
BAN('\uf05e'),
BTC('\uf15a'),
CAMERA('\uf030'),
CHECK_CIRCLE('\uf058'),
CIRCLE('\uf111'),
COINS('\uf51e'),
COPY('\uf0c5'),
EXCHANGE_ALT('\uf362'),
EXCLAMATION_CIRCLE('\uf06a'),
EXCLAMATION_TRIANGLE('\uf071'),
@@ -50,6 +52,8 @@ public class FontAwesome5 extends GlyphFont {
SEARCH('\uf002'),
SIGN_OUT_ALT('\uf2f5'),
SQUARE('\uf0c8'),
SNOWFLAKE('\uf2dc'),
SUN('\uf185'),
TIMES_CIRCLE('\uf057'),
TOGGLE_OFF('\uf204'),
TOGGLE_ON('\uf205'),
@@ -0,0 +1,535 @@
/**
* Copyright 2019 Pratanu Mandal
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.sparrowwallet.sparrow.instance;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.RandomAccessFile;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
/**
* The <code>Instance</code> class is the primary logical entry point to the library.<br>
* It allows to create an application lock or free it and send and receive messages between first and subsequent instances.<br><br>
*
* <pre>
* // unique application ID
* String APP_ID = "tk.pratanumandal.unique4j-mlsdvo-20191511-#j.6";
*
* // create Instance instance
* Instance unique = new Instance(APP_ID) {
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected void receiveMessage(String message) {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// print received message (timestamp)
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;System.out.println(message);
* &nbsp;&nbsp;&nbsp;&nbsp;}
* &nbsp;&nbsp;&nbsp;&nbsp;
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected String sendMessage() {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// send timestamp as message
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Timestamp ts = new Timestamp(new Date().getTime());
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return "Another instance launch attempted: " + ts.toString();
* &nbsp;&nbsp;&nbsp;&nbsp;}
* };
*
* // try to obtain lock
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.acquireLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
*
* ...
*
* // try to free the lock before exiting program
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.freeLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
* </pre>
*
* @author Pratanu Mandal
* @since 1.3
*
*/
public abstract class Instance {
private static final Logger log = LoggerFactory.getLogger(Instance.class);
// starting position of port check
private static final int PORT_START = 7221;
// system temporary directory path
private static final String TEMP_DIR = System.getProperty("java.io.tmpdir");
/**
* Unique string representing the application ID.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*/
public final String APP_ID;
// auto exit from application or not
private final boolean AUTO_EXIT;
// lock server port
private int port;
// lock server socket
private ServerSocket server;
// lock file RAF object
private RandomAccessFile lockRAF;
// file lock for the lock file RAF object
private FileLock fileLock;
/**
* Parameterized constructor.<br>
* This constructor configures to automatically exit the application for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @param APP_ID Unique string representing the application ID
*/
public Instance(final String APP_ID) {
this(APP_ID, true);
}
/**
* Parameterized constructor.<br>
* This constructor allows to explicitly specify the exit strategy for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @since 1.2
*
* @param APP_ID Unique string representing the application ID
* @param AUTO_EXIT If true, automatically exit the application for subsequent instances
*/
public Instance(final String APP_ID, final boolean AUTO_EXIT) {
this.APP_ID = APP_ID;
this.AUTO_EXIT = AUTO_EXIT;
}
/**
* Try to obtain lock. If not possible, send data to first instance.
*
* @deprecated Use <code>acquireLock()</code> instead.
* @throws InstanceException throws InstanceException if it is unable to start a server or connect to server
*/
@Deprecated
public void lock() throws InstanceException {
acquireLock();
}
/**
* Try to obtain lock. If not possible, send data to first instance.
*
* @since 1.2
*
* @return true if able to acquire lock, false otherwise
* @throws InstanceException throws InstanceException if it is unable to start a server or connect to server
*/
public boolean acquireLock() throws InstanceException {
// try to obtain port number from lock file
port = lockFile();
if (port == -1) {
// failed to fetch port number
// try to start server
startServer();
}
else {
// port number fetched from lock file
// try to start client
doClient();
}
return (server != null);
}
// start the server
private void startServer() throws InstanceException {
// try to create server
port = PORT_START;
while (true) {
try {
server = new ServerSocket(port, 50, InetAddress.getByName(null));
break;
} catch (IOException e) {
port++;
}
}
// try to lock file
lockFile(port);
// server created successfully; this is the first instance
// keep listening for data from other instances
Thread thread = new Thread() {
@Override
public void run() {
while (!server.isClosed()) {
try {
// establish connection
final Socket socket = server.accept();
// handle socket on a different thread to allow parallel connections
Thread thread = new Thread() {
@Override
public void run() {
try {
// open writer
OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
// open reader
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is);
// read message length from client
int length = dis.readInt();
// read message string from client
String message = null;
if (length > -1) {
byte[] messageBytes = new byte[length];
int bytesRead = dis.read(messageBytes, 0, length);
message = new String(messageBytes, 0, bytesRead, "UTF-8");
}
// write response to client
if (APP_ID == null) {
dos.writeInt(-1);
}
else {
byte[] appId = APP_ID.getBytes("UTF-8");
dos.writeInt(appId.length);
dos.write(appId);
}
dos.flush();
// close writer and reader
dos.close();
dis.close();
// perform user action on message
receiveMessage(message);
// close socket
socket.close();
} catch (IOException e) {
handleException(new InstanceException(e));
}
}
};
// start socket thread
thread.start();
} catch (SocketException e) {
if (!server.isClosed()) {
handleException(new InstanceException(e));
}
} catch (IOException e) {
handleException(new InstanceException(e));
}
}
}
};
thread.start();
}
// do client tasks
private void doClient() throws InstanceException {
// get localhost address
InetAddress address = null;
try {
address = InetAddress.getByName(null);
} catch (UnknownHostException e) {
throw new InstanceException(e);
}
// try to establish connection to server
Socket socket = null;
try {
socket = new Socket(address, port);
} catch (IOException e) {
// connection failed try to start server
startServer();
}
// connection successful try to connect to server
if (socket != null) {
try {
// get message to be sent to first instance
String message = sendMessage();
// open writer
OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
// open reader
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is);
// write message to server
if (message == null) {
dos.writeInt(-1);
}
else {
byte[] messageBytes = message.getBytes("UTF-8");
dos.writeInt(messageBytes.length);
dos.write(messageBytes);
}
dos.flush();
// read response length from server
int length = dis.readInt();
// read response string from server
String response = null;
if (length > -1) {
byte[] responseBytes = new byte[length];
int bytesRead = dis.read(responseBytes, 0, length);
response = new String(responseBytes, 0, bytesRead, "UTF-8");
}
// close writer and reader
dos.close();
dis.close();
if (response.equals(APP_ID)) {
// validation successful
if (AUTO_EXIT) {
// perform pre-exit tasks
beforeExit();
// exit this instance
System.exit(0);
}
}
else {
// validation failed, this is the first instance
startServer();
}
} catch (IOException e) {
throw new InstanceException(e);
} finally {
// close socket
try {
if (socket != null) socket.close();
} catch (IOException e) {
throw new InstanceException(e);
}
}
}
}
// try to get port from lock file
private int lockFile() throws InstanceException {
// lock file path
String filePath = TEMP_DIR + File.separator + APP_ID + ".lock";
File file = new File(filePath);
// try to get port from lock file
if (file.exists()) {
BufferedReader br = null;
try {
br = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
return Integer.parseInt(br.readLine());
} catch (IOException e) {
throw new InstanceException(e);
} catch (NumberFormatException e) {
// do nothing
} finally {
try {
if (br != null) br.close();
} catch (IOException e) {
throw new InstanceException(e);
}
}
}
return -1;
}
// try to write port to lock file
private void lockFile(int port) throws InstanceException {
// lock file path
String filePath = TEMP_DIR + File.separator + APP_ID + ".lock";
File file = new File(filePath);
// try to write port to lock file
BufferedWriter bw = null;
try {
bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file)));
bw.write(String.valueOf(port));
} catch (IOException e) {
throw new InstanceException(e);
} finally {
try {
if (bw != null) bw.close();
} catch (IOException e) {
throw new InstanceException(e);
}
}
// try to obtain file lock
try {
lockRAF = new RandomAccessFile(file, "rw");
FileChannel fc = lockRAF.getChannel();
fileLock = fc.tryLock(0, Long.MAX_VALUE, true);
if (fileLock == null) {
throw new InstanceException("Failed to obtain file lock");
}
} catch (FileNotFoundException e) {
throw new InstanceException(e);
} catch (IOException e) {
throw new InstanceException(e);
}
}
/**
* Free the lock if possible. This is only required to be called from the first instance.
*
* @deprecated Use <code>freeLock()</code> instead.
* @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock
*/
@Deprecated
public void free() throws InstanceException {
freeLock();
}
/**
* Free the lock if possible. This is only required to be called from the first instance.
*
* @since 1.2
*
* @return true if able to release lock, false otherwise
* @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock
*/
public boolean freeLock() throws InstanceException {
try {
// close server socket
if (server != null) {
server.close();
// lock file path
String filePath = TEMP_DIR + File.separator + APP_ID + ".lock";
File file = new File(filePath);
// try to release file lock
if (fileLock != null) {
fileLock.release();
}
// try to close lock file RAF object
if (lockRAF != null) {
lockRAF.close();
}
// try to delete lock file
if (file.exists()) {
file.delete();
}
return true;
}
return false;
} catch (IOException e) {
throw new InstanceException(e);
}
}
/**
* Method used in first instance to receive messages from subsequent instances.<br><br>
*
* This method is not synchronized.
*
* @param message message received by first instance from subsequent instances
*/
protected abstract void receiveMessage(String message);
/**
* Method used in subsequent instances to send message to first instance.<br><br>
*
* It is not recommended to perform blocking (long running) tasks here. Use <code>beforeExit()</code> method instead.<br>
* One exception to this rule is if you intend to perform some user interaction before sending the message.<br><br>
*
* This method is not synchronized.
*
* @return message sent from subsequent instances
*/
protected abstract String sendMessage();
/**
* Method to receive and handle exceptions occurring while first instance is listening for subsequent instances.<br><br>
*
* By default prints stack trace of all exceptions. Override this method to handle exceptions explicitly.<br><br>
*
* This method is not synchronized.
*
* @param exception exception occurring while first instance is listening for subsequent instances
*/
protected void handleException(Exception exception) {
log.error("Error listening for instances", exception);
}
/**
* This method is called before exiting from subsequent instances.<br><br>
*
* Override this method to perform blocking tasks before exiting from subsequent instances.<br>
* This method is not invoked if auto exit is turned off.<br><br>
*
* This method is not synchronized.
*
* @since 1.2
*/
protected void beforeExit() {}
}
@@ -0,0 +1,69 @@
/**
* Copyright 2019 Pratanu Mandal
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.sparrowwallet.sparrow.instance;
/**
* The <code>InstanceException</code> class is a wrapper for all exceptions thrown from Instance.
*
* @author Pratanu Mandal
* @since 1.1
*
*/
public class InstanceException extends Exception {
private static final long serialVersionUID = 268060627071973613L;
/**
* Constructs a new exception with null as its detail message.
*/
public InstanceException() {
super();
}
/**
* Constructs a new exception with the specified detail message.<br>
* The cause is not initialized, and may subsequently be initialized by a call to {@link #initCause}.
*
* @param message the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method.
*/
public InstanceException(String message) {
super(message);
}
/**
* Constructs a new exception with the specified detail message and cause.<br><br>
* Note that the detail message associated with cause is not automatically incorporated in this exception's detail message.
*
* @param message the detail message (which is saved for later retrieval by the {@link #getMessage()} method).
* @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). (A null value is permitted, and indicates that the cause is nonexistent or unknown.)
*/
public InstanceException(String message, Throwable cause) {
super(message, cause);
}
/**
* Constructs a new exception with the specified cause and a detail message of (cause==null ? null : cause.toString()) (which typically contains the class and detail message of cause).<br>
* This constructor is useful for exceptions that are little more than wrappers for other throwables (for example, {@link java.security.PrivilegedActionException}).
*
* @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). (A null value is permitted, and indicates that the cause is nonexistent or unknown.)
*/
public InstanceException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,172 @@
package com.sparrowwallet.sparrow.instance;
import java.util.ArrayList;
import java.util.List;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
/**
* The <code>InstanceList</code> class is a logical entry point to the library which extends the functionality of the <code>Instance</code> class.<br>
* It allows to create an application lock or free it and send and receive messages between first and subsequent instances.<br><br>
*
* This class is intended for passing a list of strings instead of a single string from the subsequent instance to the first instance.<br><br>
*
* <pre>
* // unique application ID
* String APP_ID = "tk.pratanumandal.unique4j-mlsdvo-20191511-#j.6";
*
* // create Instance instance
* Instance unique = new InstanceList(APP_ID) {
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected List&lt;String&gt; sendMessageList() {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;List&lt;String&gt; messageList = new ArrayList&lt;String&gt;();
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 1");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 2");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 3");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 4");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return messageList;
* &nbsp;&nbsp;&nbsp;&nbsp;}
*
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected void receiveMessageList(List&lt;String&gt; messageList) {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;for (String message : messageList) {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;System.out.println(message);
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
* &nbsp;&nbsp;&nbsp;&nbsp;}
* };
*
* // try to obtain lock
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.acquireLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
*
* ...
*
* // try to free the lock before exiting program
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.freeLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
* </pre>
*
* @author Pratanu Mandal
* @since 1.3
*
*/
public abstract class InstanceList extends Instance {
/**
* Parameterized constructor.<br>
* This constructor configures to automatically exit the application for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @param APP_ID Unique string representing the application ID
*/
public InstanceList(String APP_ID) {
super(APP_ID);
}
/**
* Parameterized constructor.<br>
* This constructor allows to explicitly specify the exit strategy for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @param APP_ID Unique string representing the application ID
* @param AUTO_EXIT If true, automatically exit the application for subsequent instances
*/
public InstanceList(String APP_ID, boolean AUTO_EXIT) {
super(APP_ID, AUTO_EXIT);
}
/**
* Internal method used in first instance to receive and parse messages from subsequent instances.<br>
* The use of this method directly in <code>InstanceList</code> is discouraged. Use <code>receiveMessageList()</code> instead.<br><br>
*
* This method is not synchronized.
*
* @param message message received by first instance from subsequent instances
*/
@Override
protected final void receiveMessage(String message) {
if (message == null) {
receiveMessageList(null);
}
else {
// parse the JSON array string into an array of string arguments
JsonArray jsonArgs = JsonParser.parseString(message).getAsJsonArray();
List<String> stringArgs = new ArrayList<String>(jsonArgs.size());
for (int i = 0; i < jsonArgs.size(); i++) {
JsonElement element = jsonArgs.get(i);
stringArgs.add(element.getAsString());
}
// return the parsed string list
receiveMessageList(stringArgs);
}
}
/**
* Internal method used in subsequent instances to parse and send message to first instance.<br>
* The use of this method directly in <code>InstanceList</code> is discouraged. Use <code>sendMessageList()</code> instead.<br><br>
*
* It is not recommended to perform blocking (long running) tasks here. Use <code>beforeExit()</code> method instead.<br>
* One exception to this rule is if you intend to perform some user interaction before sending the message.<br><br>
*
* This method is not synchronized.
*
* @return message sent from subsequent instances
*/
@Override
protected final String sendMessage() {
// convert arguments to JSON array string
JsonArray jsonArgs = new JsonArray();
List<String> stringArgs = sendMessageList();
if (stringArgs == null) return null;
for (String arg : stringArgs) {
jsonArgs.add(arg);
}
// return the JSON array string
return jsonArgs.toString();
}
/**
* Method used in first instance to receive list of messages from subsequent instances.<br><br>
*
* This method is not synchronized.
*
* @param messageList list of messages received by first instance from subsequent instances
*/
protected abstract void receiveMessageList(List<String> messageList);
/**
* Method used in subsequent instances to send list of messages to first instance.<br><br>
*
* It is not recommended to perform blocking (long running) tasks here. Use <code>beforeExit()</code> method instead.<br>
* One exception to this rule is if you intend to perform some user interaction before sending the message.<br><br>
*
* This method is not synchronized.
*
* @return list of messages sent from subsequent instances
*/
protected abstract List<String> sendMessageList();
}
@@ -0,0 +1,180 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
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.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class CaravanMultisig implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(ColdcardMultisig.class);
@Override
public String getName() {
return "Caravan Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.CARAVAN;
}
@Override
public String getWalletImportDescription() {
return "Import the file created via the Download Wallet Details button in Caravan.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
CaravanFile cf = JsonPersistence.getGson().fromJson(reader, CaravanFile.class);
Wallet wallet = new Wallet();
wallet.setName(cf.name);
wallet.setPolicyType(PolicyType.MULTI);
for(ExtPublicKey extKey : cf.extendedPublicKeys) {
Keystore keystore = new Keystore(extKey.name);
keystore.setKeyDerivation(new KeyDerivation(extKey.xfp, extKey.bip32Path));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(extKey.xpub));
WalletModel walletModel = WalletModel.fromType(extKey.method);
if(walletModel == null) {
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setSource(KeystoreSource.SW_WATCH);
} else {
keystore.setWalletModel(walletModel);
keystore.setSource(KeystoreSource.HW_USB);
}
wallet.getKeystores().add(keystore);
}
ScriptType scriptType = ScriptType.valueOf(cf.addressType);
wallet.setScriptType(scriptType);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.MULTI, scriptType, wallet.getKeystores(), cf.quorum.requiredSigners));
return wallet;
} catch(Exception e) {
log.error("Error importing " + getName() + " wallet", e);
throw new ImportException("Error importing " + getName() + " wallet", e);
}
}
@Override
public boolean isWalletImportScannable() {
return false;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
if(!wallet.isValid()) {
throw new ExportException("Cannot export an incomplete wallet");
}
if(!wallet.getPolicyType().equals(PolicyType.MULTI)) {
throw new ExportException(getName() + " import requires a multisig wallet");
}
try {
CaravanFile cf = new CaravanFile();
cf.name = wallet.getName();
cf.addressType = wallet.getScriptType().toString().replace('-', '_');
cf.network = Network.get().getName();
cf.client = new Client();
Quorum quorum = new Quorum();
quorum.requiredSigners = wallet.getDefaultPolicy().getNumSignaturesRequired();
quorum.totalSigners = wallet.getKeystores().size();
cf.quorum = quorum;
cf.extendedPublicKeys = new ArrayList<>();
for(Keystore keystore : wallet.getKeystores()) {
ExtPublicKey extKey = new ExtPublicKey();
extKey.name = keystore.getLabel();
extKey.bip32Path = keystore.getKeyDerivation().getDerivationPath();
extKey.xpub = keystore.getExtendedPublicKey().toString();
extKey.xfp = keystore.getKeyDerivation().getMasterFingerprint();
extKey.method = keystore.getWalletModel().getType();
cf.extendedPublicKeys.add(extKey);
}
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
String json = gson.toJson(cf);
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch(Exception e) {
log.error("Error exporting " + getName() + " wallet", e);
throw new ExportException("Error exporting " + getName() + " wallet", e);
}
}
@Override
public String getWalletExportDescription() {
return "Export a file that can be imported via the Import Wallet Configuration button in Caravan.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "json";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public boolean isEncrypted(File file) {
return false;
}
private static final class CaravanFile {
public String name;
public String addressType;
public String network;
public Client client;
public Quorum quorum;
public List<ExtPublicKey> extendedPublicKeys;
public int startingAddressIndex = 0;
}
private static final class Client {
public String type = "public";
}
private static final class Quorum {
public int requiredSigners;
public int totalSigners;
}
private static final class ExtPublicKey {
public String name;
public String bip32Path;
public String xpub;
public String xfp;
public String method;
}
}
@@ -57,8 +57,8 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
return keystore;
} catch (Exception e) {
log.error("Error getting Cobo Vault keystore", e);
throw new ImportException("Error getting Cobo Vault keystore", e);
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
}
@@ -81,7 +81,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Cobo Vault wallet was invalid: " + e.getMessage());
throw new ImportException("Imported " + getName() + " wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -36,7 +36,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
ColdcardKeystore cck = Storage.getGson().fromJson(reader, ColdcardKeystore.class);
ColdcardKeystore cck = JsonPersistence.getGson().fromJson(reader, ColdcardKeystore.class);
Keystore keystore = new Keystore("Coldcard");
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
@@ -145,13 +145,13 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
wallet.setScriptType(scriptType);
if(!wallet.isValid()) {
throw new IllegalStateException("This file does not describe a valid wallet. Please use the Settings > Multisig Wallets > Export XPUB feature on your Coldcard.");
throw new IllegalStateException("This file does not describe a valid wallet. " + getKeystoreImportDescription());
}
return wallet;
} catch(Exception e) {
log.error("Error importing Coldcard multisig wallet", e);
throw new ImportException("Error importing Coldcard multisig wallet", e);
log.error("Error importing " + getName() + " wallet", e);
throw new ImportException("Error importing " + getName() + " wallet", e);
}
}
@@ -180,7 +180,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.append("# " + getName() + " setup file (created by Sparrow)\n");
writer.append("#\n");
writer.append("Name: ").append(wallet.getName()).append("\n");
@@ -202,10 +202,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
writer.flush();
writer.close();
} catch(Exception e) {
log.error("Error exporting Coldcard multisig wallet", e);
throw new ExportException("Error exporting Coldcard multisig wallet", e);
log.error("Error exporting " + getName() + " wallet", e);
throw new ExportException("Error exporting " + getName() + " wallet", e);
}
}
@@ -86,8 +86,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
}
} catch (Exception e) {
log.error("Error getting Coldcard keystore", e);
throw new ImportException("Error getting Coldcard keystore", e);
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
throw new ImportException("Correct derivation not found for script type: " + scriptType);
@@ -11,6 +11,8 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Arrays;
import java.util.Currency;
import java.util.List;
@@ -38,10 +40,13 @@ public class Config {
private boolean openWalletsInNewWindows = false;
private boolean hideEmptyUsedAddresses = false;
private boolean showTransactionHex = true;
private boolean showLoadingLog = false;
private boolean showUtxosChart = true;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private File hwi;
private Boolean hdCapture;
private String webcamDevice;
private ServerType serverType;
private String publicElectrumServer;
private String coreServer;
@@ -66,10 +71,6 @@ public class Config {
private static File getConfigFile() {
File sparrowDir = Storage.getSparrowDir();
if(!sparrowDir.exists()) {
sparrowDir.mkdirs();
}
return new File(sparrowDir, CONFIG_FILENAME);
}
@@ -249,6 +250,24 @@ public class Config {
flush();
}
public boolean isShowLoadingLog() {
return showLoadingLog;
}
public void setShowLoadingLog(boolean showLoadingLog) {
this.showLoadingLog = showLoadingLog;
flush();
}
public boolean isShowUtxosChart() {
return showUtxosChart;
}
public void setShowUtxosChart(boolean showUtxosChart) {
this.showUtxosChart = showUtxosChart;
flush();
}
public List<File> getRecentWalletFiles() {
return recentWalletFiles;
}
@@ -289,6 +308,15 @@ public class Config {
flush();
}
public String getWebcamDevice() {
return webcamDevice;
}
public void setWebcamDevice(String webcamDevice) {
this.webcamDevice = webcamDevice;
flush();
}
public ServerType getServerType() {
return serverType;
}
@@ -435,6 +463,10 @@ public class Config {
Gson gson = getGson();
try {
File configFile = getConfigFile();
if(!configFile.exists()) {
Storage.createOwnerOnlyFile(configFile);
}
Writer writer = new FileWriter(configFile);
gson.toJson(this, writer);
writer.flush();
@@ -367,7 +367,6 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
String json = gson.toJson(eJson);
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
} catch (Exception e) {
log.error("Error exporting Electrum Wallet", e);
throw new ExportException("Error exporting Electrum Wallet", e);
@@ -30,7 +30,10 @@ import java.util.zip.ZipInputStream;
public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String VERSION_PREFIX = "hwi-2.0.1";
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.0.2";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -51,7 +54,7 @@ public class Hwi {
}
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
log.error("Error executing " + VERSION_PREFIX, e);
log.error("Error executing " + HWI_VERSION_DIR, e);
throw new ImportException(e);
} finally {
isPromptActive = false;
@@ -116,8 +119,8 @@ public class Hwi {
public String displayAddress(Device device, String passphrase, ScriptType scriptType, OutputDescriptor outputDescriptor) throws DisplayAddressException {
try {
if(!Arrays.asList(ScriptType.SINGLE_HASH_TYPES).contains(scriptType)) {
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only single hash types supported");
if(!Arrays.asList(ScriptType.ADDRESSABLE_TYPES).contains(scriptType)) {
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only addressable types supported");
}
String descriptor = outputDescriptor.toString();
@@ -222,11 +225,12 @@ public class Hwi {
private synchronized File getHwiExecutable(Command command) {
File hwiExecutable = Config.get().getHwi();
if(hwiExecutable != null && hwiExecutable.exists()) {
File homeDir = getHwiHomeDir();
String tmpDir = System.getProperty("java.io.tmpdir");
String hwiPath = hwiExecutable.getAbsolutePath();
if(command.isTestFirst() && hwiPath.startsWith(tmpDir) && (!hwiPath.contains(VERSION_PREFIX) || !testHwi(hwiExecutable))) {
if(command.isTestFirst() && (hwiPath.contains(tmpDir) || hwiPath.startsWith(homeDir.getAbsolutePath())) && (!hwiPath.contains(HWI_VERSION_DIR) || !testHwi(hwiExecutable))) {
if(Platform.getCurrent() == Platform.OSX) {
deleteDirectory(hwiExecutable.getParentFile());
IOUtils.deleteDirectory(hwiExecutable.getParentFile());
} else {
hwiExecutable.delete();
}
@@ -243,55 +247,66 @@ 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/" + 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());
File tempExec = null;
ZipInputStream zis = new ZipInputStream(inputStream);
ZipEntry zipEntry = zis.getNextEntry();
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;
}
}
zipEntry = zis.getNextEntry();
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + HWI_VERSION_DIR + "-mac-amd64-signed.zip");
if(inputStream == null) {
throw new IllegalStateException("Cannot load " + HWI_VERSION_DIR + " from classpath");
}
zis.closeEntry();
zis.close();
hwiExecutable = tempExec;
File hwiHomeDir = getHwiHomeDir();
File hwiVersionDir = new File(hwiHomeDir, HWI_VERSION_DIR);
IOUtils.deleteDirectory(hwiVersionDir);
if(!hwiVersionDir.exists()) {
Files.createDirectories(hwiVersionDir.toPath(), PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
}
log.debug("Using HWI path: " + hwiVersionDir.getAbsolutePath());
File hwiExec = null;
try(ZipInputStream zis = new ZipInputStream(inputStream)) {
ZipEntry zipEntry = zis.getNextEntry();
while(zipEntry != null) {
if(zipEntry.isDirectory()) {
newDirectory(hwiVersionDir, zipEntry, ownerExecutableWritable);
} else {
File newFile = newFile(hwiVersionDir, zipEntry, ownerExecutableWritable);
try(FileOutputStream fos = new FileOutputStream(newFile)) {
ByteStreams.copy(zis, new FileOutputStream(newFile));
fos.flush();
};
if(zipEntry.getName().equals("hwi")) {
hwiExec = newFile;
}
}
zipEntry = zis.getNextEntry();
}
zis.closeEntry();
}
hwiExecutable = hwiExec;
} else {
InputStream inputStream;
Path tempExecPath;
if(platform == Platform.WINDOWS) {
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(VERSION_PREFIX, null);
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null);
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(VERSION_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
}
if(inputStream == null) {
throw new IllegalStateException("Cannot load " + HWI_VERSION_DIR + " from classpath");
}
File tempExec = tempExecPath.toFile();
tempExec.deleteOnExit();
OutputStream tempExecStream = new BufferedOutputStream(new FileOutputStream(tempExec));
ByteStreams.copy(inputStream, tempExecStream);
inputStream.close();
tempExecStream.flush();
tempExecStream.close();
try(OutputStream tempExecStream = new BufferedOutputStream(new FileOutputStream(tempExec))) {
ByteStreams.copy(inputStream, tempExecStream);
inputStream.close();
tempExecStream.flush();
};
hwiExecutable = tempExec;
}
@@ -305,6 +320,14 @@ public class Hwi {
return hwiExecutable;
}
private File getHwiHomeDir() {
if(Platform.getCurrent() == Platform.OSX) {
return new File(Storage.getSparrowDir(), HWI_HOME_DIR);
}
return new File(System.getProperty("java.io.tmpdir"));
}
private boolean testHwi(File hwiExecutable) {
try {
List<String> command = List.of(hwiExecutable.getAbsolutePath(), "--version");
@@ -317,17 +340,6 @@ public class Hwi {
}
}
private boolean deleteDirectory(File directoryToBeDeleted) {
File[] allContents = directoryToBeDeleted.listFiles();
if (allContents != null) {
for (File file : allContents) {
deleteDirectory(file);
}
}
return directoryToBeDeleted.delete();
}
public static File newDirectory(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
@@ -1,8 +1,17 @@
package com.sparrowwallet.sparrow.io;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class IOUtils {
public static FileType getFileType(File file) {
@@ -16,10 +25,12 @@ public class IOUtils {
if(file.exists()) {
try(BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line = br.readLine();
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
if(line != null) {
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
}
}
}
}
@@ -36,4 +47,77 @@ public class IOUtils {
return FileType.UNKNOWN;
}
/**
* List directory contents for a resource folder. Not recursive.
* This is basically a brute-force implementation.
* Works for regular files, JARs and Java modules.
*
* @param clazz Any java class that lives in the same place as the resources you want.
* @param path Should end with "/", but not start with one.
* @return Just the name of each member item, not the full paths.
* @throws URISyntaxException
* @throws IOException
*/
public static String[] getResourceListing(Class clazz, String path) throws URISyntaxException, IOException {
URL dirURL = clazz.getClassLoader().getResource(path);
if(dirURL != null && dirURL.getProtocol().equals("file")) {
/* A file path: easy enough */
return new File(dirURL.toURI()).list();
}
if(dirURL == null) {
/*
* In case of a jar file, we can't actually find a directory.
* Have to assume the same jar as clazz.
*/
String me = clazz.getName().replace(".", "/")+".class";
dirURL = clazz.getClassLoader().getResource(me);
}
if(dirURL.getProtocol().equals("jar")) {
/* A JAR path */
String jarPath = dirURL.getPath().substring(5, dirURL.getPath().indexOf("!")); //strip out only the JAR file
JarFile jar = new JarFile(URLDecoder.decode(jarPath, "UTF-8"));
Enumeration<JarEntry> entries = jar.entries(); //gives ALL entries in jar
Set<String> result = new HashSet<String>(); //avoid duplicates in case it is a subdirectory
while(entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if(name.startsWith(path)) { //filter according to the path
String entry = name.substring(path.length());
int checkSubdir = entry.indexOf("/");
if (checkSubdir >= 0) {
// if it is a subdirectory, we just return the directory name
entry = entry.substring(0, checkSubdir);
}
if(!entry.isEmpty()) {
result.add(entry);
}
}
}
return result.toArray(new String[result.size()]);
}
if(dirURL.getProtocol().equals("jrt")) {
java.nio.file.FileSystem jrtFs = FileSystems.newFileSystem(URI.create("jrt:/"), Collections.emptyMap());
Path resourcePath = jrtFs.getPath("modules/com.sparrowwallet.sparrow", path);
return Files.list(resourcePath).map(filePath -> filePath.getFileName().toString()).toArray(String[]::new);
}
throw new UnsupportedOperationException("Cannot list files for URL " + dirURL);
}
public static boolean deleteDirectory(File directory) {
try {
Files.walk(directory.toPath())
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch(IOException e) {
return false;
}
return true;
}
}
@@ -0,0 +1,484 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
import static com.sparrowwallet.drongo.crypto.Argon2KeyDeriver.SPRW1_PARAMETERS;
public class JsonPersistence implements Persistence {
public static final String HEADER_MAGIC_1 = "SPRW1";
public static final int BINARY_HEADER_LENGTH = 28;
private final Gson gson;
private AsymmetricKeyDeriver keyDeriver;
public JsonPersistence() {
this.gson = getGson();
}
@Override
public WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException {
Wallet wallet;
try(Reader reader = new FileReader(storage.getWalletFile())) {
wallet = gson.fromJson(reader, Wallet.class);
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, null);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
File backupFile = storage.getTempBackup();
Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, null);
return new WalletBackupAndKey(wallet, backupWallet, null, null, childWallets);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException {
return loadWallet(storage, password, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
Wallet wallet;
ECKey encryptionKey;
try(InputStream fileStream = new FileInputStream(storage.getWalletFile())) {
encryptionKey = getEncryptionKey(password, fileStream, alreadyDerivedKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
wallet = gson.fromJson(reader, Wallet.class);
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, encryptionKey);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
File backupFile = storage.getTempBackup();
Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, encryptionKey);
return new WalletBackupAndKey(wallet, backupWallet, encryptionKey, keyDeriver, childWallets);
}
private Map<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException {
File[] walletFiles = getChildWalletFiles(storage.getWalletFile(), masterWallet);
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
for(File childFile : walletFiles) {
Wallet childWallet = loadWallet(childFile, encryptionKey);
Storage childStorage = new Storage(childFile);
childStorage.setEncryptionPubKey(encryptionKey == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(encryptionKey));
childStorage.setKeyDeriver(getKeyDeriver());
childWallet.setMasterWallet(masterWallet);
childWallets.put(childStorage, new WalletBackupAndKey(childWallet, null, encryptionKey, keyDeriver, Collections.emptyMap()));
}
return childWallets;
}
private Wallet loadWallet(File walletFile, ECKey encryptionKey) throws IOException, StorageException {
if(encryptionKey != null) {
try(InputStream fileStream = new FileInputStream(walletFile)) {
encryptionKey = getEncryptionKey(null, fileStream, encryptionKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
return gson.fromJson(reader, Wallet.class);
}
} else {
try(Reader reader = new FileReader(walletFile)) {
return gson.fromJson(reader, Wallet.class);
}
}
}
private File[] getChildWalletFiles(File walletFile, Wallet masterWallet) {
File childDir = new File(walletFile.getParentFile(), masterWallet.getName() + "-child");
if(!childDir.exists()) {
return new File[0];
}
File[] childFiles = childDir.listFiles(pathname -> {
FileType fileType = IOUtils.getFileType(pathname);
return pathname.getName().startsWith(masterWallet.getName()) && (fileType == FileType.BINARY || fileType == FileType.JSON);
});
return childFiles == null ? new File[0] : childFiles;
}
@Override
public File storeWallet(Storage storage, Wallet wallet) throws IOException {
File walletFile = storage.getWalletFile();
if(!walletFile.getName().endsWith(".json")) {
File jsonFile = new File(walletFile.getParentFile(), walletFile.getName() + ".json");
if(walletFile.exists()) {
if(!walletFile.renameTo(jsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + jsonFile.getName());
}
}
walletFile = jsonFile;
}
if(!walletFile.exists()) {
Storage.createOwnerOnlyFile(walletFile);
}
try(Writer writer = new FileWriter(walletFile)) {
gson.toJson(wallet, writer);
}
return walletFile;
}
@Override
public File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException {
File walletFile = storage.getWalletFile();
if(walletFile.getName().endsWith(".json")) {
File noJsonFile = new File(walletFile.getParentFile(), walletFile.getName().substring(0, walletFile.getName().lastIndexOf('.')));
if(walletFile.exists()) {
if(!walletFile.renameTo(noJsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + noJsonFile.getName());
}
}
walletFile = noJsonFile;
}
if(!walletFile.exists()) {
Storage.createOwnerOnlyFile(walletFile);
}
try(OutputStream outputStream = new FileOutputStream(walletFile)) {
writeBinaryHeader(outputStream);
OutputStreamWriter writer = new OutputStreamWriter(new DeflaterOutputStream(new ECIESOutputStream(outputStream, encryptionPubKey, getEncryptionMagic())), StandardCharsets.UTF_8);
gson.toJson(wallet, writer);
//Close the writer explicitly as the try-resources block will not do so
writer.close();
}
return walletFile;
}
@Override
public void updateWallet(Storage storage, Wallet wallet) throws IOException {
storeWallet(storage, wallet);
}
@Override
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException {
storeWallet(storage, wallet, encryptionPubKey);
}
private void writeBinaryHeader(OutputStream outputStream) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(21);
buf.put(HEADER_MAGIC_1.getBytes(StandardCharsets.UTF_8));
buf.put(keyDeriver.getSalt());
byte[] encoded = Base64.getEncoder().encode(buf.array());
if(encoded.length != BINARY_HEADER_LENGTH) {
throw new IllegalStateException("Header length not " + BINARY_HEADER_LENGTH + " bytes");
}
outputStream.write(encoded);
}
private static byte[] getEncryptionMagic() {
return "BIE1".getBytes(StandardCharsets.UTF_8);
}
@Override
public ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException {
return getEncryptionKey(password, null, null);
}
private ECKey getEncryptionKey(CharSequence password, InputStream inputStream, ECKey alreadyDerivedKey) throws IOException, StorageException {
if(password != null && password.equals("")) {
return Storage.NO_PASSWORD_KEY;
}
AsymmetricKeyDeriver keyDeriver = getKeyDeriver(inputStream);
return alreadyDerivedKey == null ? keyDeriver.deriveECKey(password) : alreadyDerivedKey;
}
@Override
public AsymmetricKeyDeriver getKeyDeriver() {
return keyDeriver;
}
@Override
public void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
this.keyDeriver = keyDeriver;
}
private AsymmetricKeyDeriver getKeyDeriver(InputStream inputStream) throws IOException, StorageException {
if(keyDeriver == null) {
keyDeriver = getWalletKeyDeriver(inputStream);
} else if(inputStream != null) {
inputStream.skip(BINARY_HEADER_LENGTH);
}
return keyDeriver;
}
private AsymmetricKeyDeriver getWalletKeyDeriver(InputStream inputStream) throws IOException, StorageException {
byte[] salt = new byte[SPRW1_PARAMETERS.saltLength];
if(inputStream != null) {
byte[] header = new byte[BINARY_HEADER_LENGTH];
int read = inputStream.read(header);
if(read != BINARY_HEADER_LENGTH) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
try {
byte[] decodedHeader = Base64.getDecoder().decode(header);
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
throw new StorageException("Not a Sparrow wallet - invalid magic");
}
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
} catch(IllegalArgumentException e) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
} else {
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
}
return new Argon2KeyDeriver(salt);
}
@Override
public PersistenceType getType() {
return PersistenceType.JSON;
}
@Override
public boolean isEncrypted(File walletFile) throws IOException {
FileType fileType = IOUtils.getFileType(walletFile);
if(FileType.JSON.equals(fileType)) {
return false;
} else if(FileType.BINARY.equals(fileType)) {
try(FileInputStream fileInputStream = new FileInputStream(walletFile)) {
getWalletKeyDeriver(fileInputStream);
return true;
} catch(StorageException e) {
return false;
}
}
throw new IOException("Unsupported file type");
}
@Override
public String getWalletId(Storage storage, Wallet wallet) {
return storage.getWalletFile().getParentFile().getAbsolutePath() + File.separator + getWalletName(storage.getWalletFile(), null) + ":" + (wallet == null || wallet.isMasterWallet() ? "master" : wallet.getName());
}
@Override
public String getWalletName(File walletFile, Wallet wallet) {
String name = walletFile.getName();
if(name.endsWith("." + getType().getExtension())) {
name = name.substring(0, name.lastIndexOf('.'));
}
return name;
}
@Override
public void copyWallet(File walletFile, OutputStream outputStream) throws IOException {
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public void close() {
//Nothing required
}
public static Gson getGson() {
return getGson(true);
}
private static Gson getGson(boolean includeWalletSerializers) {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeySerializer());
gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeyDeserializer());
gsonBuilder.registerTypeAdapter(byte[].class, new ByteArraySerializer());
gsonBuilder.registerTypeAdapter(byte[].class, new ByteArrayDeserializer());
gsonBuilder.registerTypeAdapter(Sha256Hash.class, new Sha256HashSerializer());
gsonBuilder.registerTypeAdapter(Sha256Hash.class, new Sha256HashDeserializer());
gsonBuilder.registerTypeAdapter(Date.class, new DateSerializer());
gsonBuilder.registerTypeAdapter(Date.class, new DateDeserializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionSerializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionDeserializer());
if(includeWalletSerializers) {
gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeDeserializer());
}
gsonBuilder.addSerializationExclusionStrategy(new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes field) {
return field.getName().equals("id") || field.getName().equals("masterWallet") || field.getName().equals("childWallets");
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return false;
}
});
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
private static class ExtendedPublicKeySerializer implements JsonSerializer<ExtendedKey> {
@Override
public JsonElement serialize(ExtendedKey src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class ExtendedPublicKeyDeserializer implements JsonDeserializer<ExtendedKey> {
@Override
public ExtendedKey deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return ExtendedKey.fromDescriptor(json.getAsJsonPrimitive().getAsString());
}
}
private static class ByteArraySerializer implements JsonSerializer<byte[]> {
@Override
public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(Utils.bytesToHex(src));
}
}
private static class ByteArrayDeserializer implements JsonDeserializer<byte[]> {
@Override
public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Utils.hexToBytes(json.getAsJsonPrimitive().getAsString());
}
}
private static class Sha256HashSerializer implements JsonSerializer<Sha256Hash> {
@Override
public JsonElement serialize(Sha256Hash src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class Sha256HashDeserializer implements JsonDeserializer<Sha256Hash> {
@Override
public Sha256Hash deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Sha256Hash.wrap(json.getAsJsonPrimitive().getAsString());
}
}
private static class DateSerializer implements JsonSerializer<Date> {
@Override
public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.getTime());
}
}
private static class DateDeserializer implements JsonDeserializer<Date> {
@Override
public Date deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return new Date(json.getAsJsonPrimitive().getAsLong());
}
}
private static class TransactionSerializer implements JsonSerializer<Transaction> {
@Override
public JsonElement serialize(Transaction src, Type typeOfSrc, JsonSerializationContext context) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
src.bitcoinSerializeToStream(baos);
return new JsonPrimitive(Utils.bytesToHex(baos.toByteArray()));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
private static class TransactionDeserializer implements JsonDeserializer<Transaction> {
@Override
public Transaction deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
byte[] rawTx = Utils.hexToBytes(json.getAsJsonPrimitive().getAsString());
return new Transaction(rawTx);
}
}
private static class KeystoreSerializer implements JsonSerializer<Keystore> {
@Override
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore);
if(keystore.hasPrivateKey()) {
jsonObject.remove("extendedPublicKey");
jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint");
}
return jsonObject;
}
}
private static class NodeSerializer implements JsonSerializer<WalletNode> {
@Override
public JsonElement serialize(WalletNode node, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(node);
JsonArray children = jsonObject.getAsJsonArray("children");
Iterator<JsonElement> iter = children.iterator();
while(iter.hasNext()) {
JsonObject childObject = (JsonObject)iter.next();
removeEmptyCollection(childObject, "children");
removeEmptyCollection(childObject, "transactionOutputs");
if(childObject.get("label") == null && childObject.get("children") == null && childObject.get("transactionOutputs") == null) {
iter.remove();
}
}
return jsonObject;
}
private void removeEmptyCollection(JsonObject jsonObject, String memberName) {
if(jsonObject.get(memberName) != null && jsonObject.getAsJsonArray(memberName).size() == 0) {
jsonObject.remove(memberName);
}
}
}
private static class NodeDeserializer implements JsonDeserializer<WalletNode> {
@Override
public WalletNode deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
WalletNode node = getGson(false).fromJson(json, typeOfT);
node.parseDerivation();
for(WalletNode childNode : node.getChildren()) {
childNode.parseDerivation();
if(childNode.getChildren() == null) {
childNode.setChildren(new TreeSet<>());
}
if(childNode.getTransactionOutputs() == null) {
childNode.setTransactionOutputs(new TreeSet<>());
}
}
return node;
}
}
}
@@ -0,0 +1,70 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class KeystoneMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Keystone Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYSTONE;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Keystone");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Keystone.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
Wallet wallet = super.importWallet(inputStream, password);
for(Keystore keystore : wallet.getKeystores()) {
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Keystone"));
keystore.setWalletModel(WalletModel.KEYSTONE);
}
return wallet;
}
@Override
public String getWalletImportDescription() {
return "Import file or QR created by using the Multisig Wallet > ... > Create Multisig Wallet feature on your Keystone.";
}
@Override
public String getWalletExportDescription() {
return "Export file or QR that can be read by your Keystone using the Multisig Wallet > ... > Import Multisig Wallet feature.";
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public boolean isWalletExportScannable() {
return true;
}
}
@@ -0,0 +1,112 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
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.BufferedReader;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
public class KeystoneSinglesig implements KeystoreFileImport, WalletImport {
private static final Logger log = LoggerFactory.getLogger(KeystoneSinglesig.class);
@Override
public String getName() {
return "Keystone";
}
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the My Keystone > ... > Export Wallet feature on your Keystone. Make sure to set the Watch-only Wallet to Sparrow in the Settings first.";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYSTONE;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
String outputDescriptor = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor);
if(descriptor.isMultisig()) {
throw new IllegalArgumentException("Output descriptor describes a multisig wallet");
}
if(descriptor.getScriptType() != scriptType) {
throw new IllegalArgumentException("Output descriptor describes a " + descriptor.getScriptType().getDescription() + " wallet");
}
ExtendedKey xpub = descriptor.getSingletonExtendedPublicKey();
KeyDerivation keyDerivation = descriptor.getKeyDerivation(xpub);
Keystore keystore = new Keystore();
keystore.setLabel(getName());
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setWalletModel(WalletModel.KEYSTONE);
keystore.setKeyDerivation(keyDerivation);
keystore.setExtendedPublicKey(xpub);
return keystore;
} catch (IllegalArgumentException e) {
log.error("Error getting " + getName() + " keystore - not an output descriptor");
throw new ImportException("Error getting " + getName() + " keystore", e);
} catch (Exception e) {
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
}
@Override
public String getWalletImportDescription() {
return getKeystoreImportDescription();
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
//Use default of P2WPKH
Keystore keystore = getKeystore(ScriptType.P2WPKH, inputStream, "");
Wallet wallet = new Wallet();
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(ScriptType.P2WPKH);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported " + getName() + " wallet was invalid: " + e.getMessage());
}
return wallet;
}
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
}
@@ -0,0 +1,28 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
public interface Persistence {
WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException;
WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException;
WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException;
File storeWallet(Storage storage, Wallet wallet) throws IOException, StorageException;
File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException;
void updateWallet(Storage storage, Wallet wallet) throws IOException, StorageException;
void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException;
ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException;
AsymmetricKeyDeriver getKeyDeriver();
void setKeyDeriver(AsymmetricKeyDeriver keyDeriver);
PersistenceType getType();
boolean isEncrypted(File walletFile) throws IOException;
String getWalletId(Storage storage, Wallet wallet);
String getWalletName(File walletFile, Wallet wallet);
void copyWallet(File walletFile, OutputStream outputStream) throws IOException;
void close();
}
@@ -0,0 +1,42 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.sparrow.io.db.DbPersistence;
public enum PersistenceType {
JSON("json") {
@Override
public String getExtension() {
return getName();
}
@Override
public Persistence getInstance() {
return new JsonPersistence();
}
},
DB("db") {
@Override
public String getExtension() {
return "mv.db";
}
@Override
public Persistence getInstance() {
return new DbPersistence();
}
};
private final String name;
private PersistenceType(String name) {
this.name = name;
}
public String getName() {
return name;
}
public abstract String getExtension();
public abstract Persistence getInstance();
}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public class SeedSigner extends SpecterDIY {
@Override
public String getName() {
return "SeedSigner";
}
@Override
public String getKeystoreImportDescription() {
return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports P2WSH Multisig wallets.";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SEEDSIGNER;
}
}

Some files were not shown because too many files have changed in this diff Show More