Compare commits

...
80 Commits
Author SHA1 Message Date
Craig Raw e7f6f7f3db add show version to command line args 2022-10-27 11:49:20 +02:00
Craig Raw dd9868c918 v1.7.0 2022-10-27 11:10:50 +02:00
Craig Raw c2d3afae59 fix exception when clearing a server alias 2022-10-27 09:34:32 +02:00
Craig Raw 04a516d56b improve error messaging for payjoin requests 2022-10-27 08:11:58 +02:00
Craig Raw b27709e96f show network in main tab header background when not using mainnet 2022-10-26 15:54:59 +02:00
Craig Raw ebb7d23a05 hide mix failed after timeout 2022-10-26 12:40:17 +02:00
Craig Raw 97d121244f add support for deprecating importers and exporters, and deprecate cobo vault 2022-10-26 12:28:21 +02:00
Craig Raw 60dbc8ed84 add cancel transaction via rbf to unconfirmed tx context menu 2022-10-26 11:33:36 +02:00
Craig Raw 29cd321724 avoid showing usb signing dialog for watch only keystores in multisig wallets 2022-10-26 08:31:36 +02:00
Craig Raw 467b834955 add jade as airgapped keystore importer 2022-10-25 14:41:10 +02:00
Craig Raw 3bfa5d3dc4 specify arch in release tarball name 2022-10-25 12:02:00 +02:00
Craig Raw f501f08e17 only build headless when specifically requested 2022-10-25 11:27:37 +02:00
Craig Raw 7d796369d6 upgrade and fix action 2022-10-25 10:45:48 +02:00
Craig Raw c572011578 followup 2022-10-25 10:23:17 +02:00
Craig Raw dbc1e7746b configure headless build 2022-10-25 10:04:03 +02:00
Craig Raw 9325a1968b explicitly detect java.awt.headless in build 2022-10-25 09:38:16 +02:00
Craig Raw 85166635b4 followup 2022-10-25 08:52:23 +02:00
Craig Raw ed69a86529 improve detection and handling on headless systems 2022-10-25 08:34:31 +02:00
Craig Raw ab2c77695b show warning dialog when a legacy multi output descriptor is entered 2022-10-24 16:50:08 +02:00
Craig Raw 6ad81e1228 suppress verbose whirlpool connection related logging errors 2022-10-24 15:31:42 +02:00
Craig Raw ff340c2449 followup to test taskbar user attention feature support 2022-10-24 14:43:53 +02:00
Craig Raw f2b0f8ca9e followup to test taskbar api platform support 2022-10-24 14:06:38 +02:00
Craig Raw 0c213294ad request user attention via taskbar when soroban communication requires action 2022-10-24 14:00:20 +02:00
Craig Raw 7cdb7319ee multisig backup dialog tweaks 2022-10-24 13:05:44 +02:00
Craig Raw d1ff8d6e3e fix show/edit descriptor button height 2022-10-24 12:51:21 +02:00
Craig Raw d9ddc74d73 disable rbf if allow unconfirmed preference is disabled 2022-10-24 08:18:17 +02:00
Craig Raw 603df6d0f6 terminal - fix add account encryption and normalize ui widths 2022-10-20 12:45:09 +02:00
Craig Raw cbf847a57f terminal - lock wallet 2022-10-19 18:41:23 +02:00
Craig Raw e8fb676a24 terminal - show seed 2022-10-19 18:19:49 +02:00
Craig Raw 273f3043fb terminal - add account 2022-10-19 18:04:55 +02:00
Craig Raw 8f165b05c7 terminal - create watch only wallet 2022-10-19 15:37:14 +02:00
Craig Raw 8eb092a8d6 terminal - create bip39 wallet 2022-10-19 09:44:44 +02:00
Craig Raw 8dd1850905 add settings dialog and other terminal improvements 2022-10-18 12:42:44 +02:00
Craig Raw 0fa6bd56e2 add interface enum 2022-10-17 13:52:40 +02:00
Craig Raw c4c581525a remove stdout appender in terminal mode 2022-10-17 13:30:21 +02:00
Craig Raw d6a3824690 add linux aarch64 hwi 2022-10-12 11:25:49 +02:00
Craig Raw 7dba141073 add linux aarch64 bwt 2022-10-12 10:56:24 +02:00
Craig Raw f2f6e639dc minor fixes 2022-10-11 15:40:50 +02:00
Craig Raw 78afc5e4d5 fix no recent wallets issue 2022-10-11 15:26:14 +02:00
Craig Raw b2d85b6c78 detect and configure build for headless environments 2022-10-11 15:12:40 +02:00
Craig Raw f3b0d37c54 replace and customize javafx plugin for headless environments 2022-10-11 15:07:43 +02:00
Craig Raw 6768ad2028 improve close timing of wallet loading dialog 2022-10-11 12:45:24 +02:00
Craig Raw 81cde4756a fix threading issue on terminal wallet load 2022-10-11 12:16:18 +02:00
Craig Raw 778564a954 refactor and rename launch and application classes 2022-10-11 11:41:49 +02:00
Craig Raw 1e4c8c3837 separate application from main 2022-10-10 16:37:35 +02:00
Craig Raw 22408103ea show additional inputs in tx diagram as labels or abbreviated txid:index 2022-10-10 09:55:38 +02:00
Craig Raw 383d3954d8 add linux aarch64 libsecp256k1 2022-10-06 14:35:49 +02:00
Craig Raw 77a4e4aa50 suppress unnecessary unchecked cast warning 2022-10-06 13:38:32 +02:00
Craig Raw 19dedfa070 implement terminal mode 2022-10-06 13:10:18 +02:00
Craig Raw 52696b014f import wallet from output descriptor pdf, ignore newline characters in output descriptor dialog 2022-09-26 13:48:49 +02:00
Craig Raw 8fb6de85f1 add unit format menu selection for alternative grouping and decimal separators 2022-09-21 11:36:13 +02:00
Craig Raw 8270eb71db install context menu handler for anywhere within transaction diagram 2022-09-16 15:08:03 +02:00
Craig Raw edcf12de5a follow up 2022-09-16 12:46:39 +02:00
Craig Raw da3399468c save transaction diagram as image through context menu on transaction label 2022-09-16 12:12:25 +02:00
Craig Raw 923c61fceb fix import of electrum wallet without keystore labels 2022-09-14 11:15:15 +02:00
Craig Raw d3d939889e increase maximum gap limit, but display warning when gap limit is over 999 2022-09-14 11:06:15 +02:00
Craig Raw 2b4d3fac6c recommend backup of output descriptor when saving new multisig wallets 2022-09-14 10:42:41 +02:00
Craig Raw 1f67692727 add support for configuring server aliases, and switching servers via the tools menu 2022-09-12 15:44:47 +02:00
Craig Raw bacbdb848b add move left and right context menu items to reorder wallet and transaction tabs 2022-09-06 12:42:18 +02:00
Craig Raw 51ba7fc4cf add context menu item to addresses table to spend all utxos for an address 2022-09-05 14:37:05 +02:00
Craig Raw 59efed9e42 verify signatures immediately after signing as per recommendation in bip340 2022-09-02 11:27:30 +02:00
craigrawandGitHub 193fea4b69 Merge pull request #675 from nyxnor/repro-docs
improve reproducible docs
2022-08-30 09:24:43 +02:00
nyxnor b5cc2ff4c3 improve reproducible docs 2022-08-29 16:58:05 +00:00
Craig Raw 252ec58065 fix typo in build doc 2022-08-29 08:51:19 +02:00
Craig Raw b8979ed8b0 use compact parameter to improve paynym search performance 2022-08-24 11:46:53 +02:00
Craig Raw c24f953e52 export all related wallets when exporting to electrum personal server 2022-08-23 08:48:35 +02:00
Craig Raw d139ca2706 add wallet export to electrum personal server config file 2022-08-22 14:33:03 +02:00
Craig Raw bd421e877a followup 2022-08-22 11:35:04 +02:00
Craig Raw 25e1250710 add context menu item in transaction diagram to show input and output addresses as qrs 2022-08-22 11:34:03 +02:00
Craig Raw fad1dad76e add trace logging to electrum server calls 2022-08-17 11:11:39 +02:00
Craig Raw b3bd42b8f6 add logging to all external api calls 2022-08-17 10:59:33 +02:00
Craig Raw 5aea538f09 cancel whirlpool startup service when disconnecting 2022-08-16 10:45:01 +02:00
Craig Raw 16755e3140 freeze and unfreeze any utxos from address cell context menu 2022-08-11 10:26:23 +02:00
Craig Raw cbfb7230a8 set transaction label on soroban collaborative transactions 2022-08-10 15:25:44 +02:00
Craig Raw e438389953 allow soroban initiator to try again if meeting request fails 2022-08-10 14:15:40 +02:00
Craig Raw 6534ccb07e accept output descriptor fragments in pubkey qr scanner 2022-08-10 12:31:43 +02:00
Craig Raw ca782dfc69 avoid resolution of onion hosts when creating proxied server socket addresses 2022-08-10 12:02:23 +02:00
Craig Raw f1a662ba8a fix name length on coldcard multisig export and update help text 2022-08-08 13:46:37 +02:00
Craig Raw 33fb2a38fc fix reproducibility issue by avoiding objcopy use during build, remove unnecessary java native commands from binaries 2022-08-08 11:23:29 +02:00
Craig Raw 8b22e057bf update documentation to reference java 18.0.1 as current release jdk 2022-08-04 14:35:59 +02:00
165 changed files with 7986 additions and 822 deletions
+18 -7
View File
@@ -19,12 +19,6 @@ jobs:
java-version: '18.0.1'
- name: Show Build Versions
run: ./gradlew -v
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
restore-keys: ${{ runner.os }}-gradle
- name: Build with Gradle
run: ./gradlew jpackage
- name: Package zip distribution
@@ -34,9 +28,26 @@ jobs:
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: Sparrow Build - ${{ runner.os }}
path: |
build/jpackage/*
!build/jpackage/Sparrow/
- name: Headless build with Gradle
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true clean jpackage
- name: Package headless tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Rename Headless Artifacts
if: ${{ runner.os == 'Linux' }}
run: for f in build/jpackage/sparrow*; do mv -v "$f" "${f/sparrow/sparrow-server}"; done;
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v3
with:
name: Sparrow Build - ${{ runner.os }} Headless
path: |
build/jpackage/*
!build/jpackage/Sparrow/
+8 -8
View File
@@ -16,8 +16,8 @@ or for those without SSH credentials:
`git clone --recursive https://github.com/sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 17 to be installed.
The release binaries are built with [Eclipse Temurin 17.0.2+8](https://github.com/adoptium/temurin17-binaries/releases/tag/jdk-17.0.2%2B8).
In order to build, Sparrow requires Java 17 or higher to be installed.
The release binaries are built with [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10).
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
@@ -36,7 +36,7 @@ When updating to the latest HEAD
The release binaries are reproducible from v1.5.0 onwards (pre codesigning and installer packaging). More detailed [instructions on reproducing the binaries](docs/reproducible.md) are provided.
> A video documention of your built process uploaded to [bitcoinbinary.org](https://bitcoinbinary.org/) is appreciated. Alternatively check the site if you wish to see if someone else already verified the provided binaries.
> Video documentation of your build process uploaded to [bitcoinbinary.org](https://bitcoinbinary.org/) is appreciated. Alternatively check the site if you wish to see if someone else already verified the provided binaries.
## Running
@@ -77,11 +77,11 @@ Note that if you are connecting to an Electrum server when using testnet, that s
When not explicitly configured using the command line argument above, Sparrow stores its mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
Platform | Location
-------- | --------
OSX | ~/.sparrow
Linux | ~/.sparrow
Windows | %APPDATA%/Sparrow
| Platform | Location |
|----------| -------- |
| OSX | ~/.sparrow |
| Linux | ~/.sparrow |
| Windows | %APPDATA%/Sparrow |
Testnet, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
+17 -5
View File
@@ -1,11 +1,13 @@
import java.awt.GraphicsEnvironment
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.0.13'
id 'org-openjfx-javafxplugin'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.25.0'
}
def sparrowVersion = '1.6.6'
def sparrowVersion = '1.7.0'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -13,10 +15,13 @@ if(os.macOsX) {
}
def targetName = ""
def osArch = "x64"
def releaseArch = "x86_64"
if(System.getProperty("os.arch") == "aarch64") {
osArch = "aarch64"
releaseArch = "aarch64"
targetName = "-" + osArch
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
group "com.sparrowwallet"
version "${sparrowVersion}"
@@ -104,12 +109,13 @@ dependencies {
implementation('org.apache.commons:commons-lang3:3.7')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.27')
implementation('com.googlecode.lanterna:lanterna:3.1.1')
testImplementation('junit:junit:4.12')
}
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.MainApp'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
}
compileJava {
@@ -158,6 +164,9 @@ run {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
if(headless) {
applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
}
}
jlink {
@@ -177,7 +186,7 @@ jlink {
uses 'org.eclipse.jetty.websocket.common.RemoteEndpointFactory'
}
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
launcher {
name = 'sparrow'
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
@@ -210,6 +219,9 @@ jlink {
if(os.macOsX) {
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
if(headless) {
jvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
}
}
addExtraDependencies("javafx")
jpackage {
@@ -251,7 +263,7 @@ task packageZipDistribution(type: Zip) {
task packageTarDistribution(type: Tar) {
dependsOn removeGroupWritePermission
archiveFileName = "sparrow-${sparrowVersion}.tar.gz"
archiveFileName = "sparrow-${sparrowVersion}-${releaseArch}.tar.gz"
destinationDirectory = file("$buildDir/jpackage")
compression = Compression.GZIP
from("$buildDir/jpackage/") {
+9
View File
@@ -4,10 +4,15 @@ plugins {
dependencies {
implementation 'org.ow2.asm:asm:8.0.1'
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.0'
implementation 'org.javamodularity:moduleplugin:1.8.12'
}
repositories {
mavenCentral()
maven {
url "https://plugins.gradle.org/m2/"
}
}
gradlePlugin {
@@ -17,5 +22,9 @@ gradlePlugin {
id = "extra-java-module-info"
implementationClass = "org.gradle.sample.transform.javamodules.ExtraModuleInfoPlugin"
}
register("org-openjfx-javafxplugin") {
id = "org-openjfx-javafxplugin"
implementationClass = "org.openjfx.gradle.JavaFXPlugin"
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2018, 2020, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import org.gradle.api.GradleException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public enum JavaFXModule {
BASE,
GRAPHICS(BASE),
CONTROLS(BASE, GRAPHICS),
FXML(BASE, GRAPHICS),
MEDIA(BASE, GRAPHICS),
SWING(BASE, GRAPHICS),
WEB(BASE, CONTROLS, GRAPHICS, MEDIA);
static final String PREFIX_MODULE = "javafx.";
private static final String PREFIX_ARTIFACT = "javafx-";
private List<JavaFXModule> dependentModules;
JavaFXModule(JavaFXModule...dependentModules) {
this.dependentModules = List.of(dependentModules);
}
public static Optional<JavaFXModule> fromModuleName(String moduleName) {
return Stream.of(JavaFXModule.values())
.filter(javaFXModule -> moduleName.equals(javaFXModule.getModuleName()))
.findFirst();
}
public String getModuleName() {
return PREFIX_MODULE + name().toLowerCase(Locale.ROOT);
}
public String getModuleJarFileName() {
return getModuleName() + ".jar";
}
public String getArtifactName() {
return PREFIX_ARTIFACT + name().toLowerCase(Locale.ROOT);
}
public boolean compareJarFileName(JavaFXPlatform platform, String jarFileName) {
Pattern p = Pattern.compile(getArtifactName() + "-.+-" + platform.getClassifier() + "\\.jar");
return p.matcher(jarFileName).matches();
}
public static Set<JavaFXModule> getJavaFXModules(List<String> moduleNames) {
validateModules(moduleNames);
return moduleNames.stream()
.map(JavaFXModule::fromModuleName)
.flatMap(Optional::stream)
.flatMap(javaFXModule -> javaFXModule.getMavenDependencies().stream())
.collect(Collectors.toSet());
}
public static void validateModules(List<String> moduleNames) {
var invalidModules = moduleNames.stream()
.filter(module -> JavaFXModule.fromModuleName(module).isEmpty())
.collect(Collectors.toList());
if (! invalidModules.isEmpty()) {
throw new GradleException("Found one or more invalid JavaFX module names: " + invalidModules);
}
}
public List<JavaFXModule> getDependentModules() {
return dependentModules;
}
public List<JavaFXModule> getMavenDependencies() {
List<JavaFXModule> dependencies = new ArrayList<>(dependentModules);
dependencies.add(0, this);
return dependencies;
}
}
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2018, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import org.gradle.api.Project;
import org.gradle.api.artifacts.repositories.FlatDirectoryArtifactRepository;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.openjfx.gradle.JavaFXModule.PREFIX_MODULE;
public class JavaFXOptions {
private static final String MAVEN_JAVAFX_ARTIFACT_GROUP_ID = "org.openjfx";
private static final String JAVAFX_SDK_LIB_FOLDER = "lib";
private final Project project;
private final JavaFXPlatform platform;
private String version = "16";
private String sdk;
private String configuration = "implementation";
private String lastUpdatedConfiguration;
private List<String> modules = new ArrayList<>();
private FlatDirectoryArtifactRepository customSDKArtifactRepository;
public JavaFXOptions(Project project) {
this.project = project;
this.platform = JavaFXPlatform.detect(project);
}
public JavaFXPlatform getPlatform() {
return platform;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
updateJavaFXDependencies();
}
/**
* If set, the JavaFX modules will be taken from this local
* repository, and not from Maven Central
* @param sdk, the path to the local JavaFX SDK folder
*/
public void setSdk(String sdk) {
this.sdk = sdk;
updateJavaFXDependencies();
}
public String getSdk() {
return sdk;
}
/** Set the configuration name for dependencies, e.g.
* 'implementation', 'compileOnly' etc.
* @param configuration The configuration name for dependencies
*/
public void setConfiguration(String configuration) {
this.configuration = configuration;
updateJavaFXDependencies();
}
public String getConfiguration() {
return configuration;
}
public List<String> getModules() {
return modules;
}
public void setModules(List<String> modules) {
this.modules = modules;
updateJavaFXDependencies();
}
public void modules(String...moduleNames) {
setModules(List.of(moduleNames));
}
private void updateJavaFXDependencies() {
clearJavaFXDependencies();
String configuration = getConfiguration();
JavaFXModule.getJavaFXModules(this.modules).stream()
.sorted()
.forEach(javaFXModule -> {
if (customSDKArtifactRepository != null) {
project.getDependencies().add(configuration, Map.of("name", javaFXModule.getModuleName()));
} else {
project.getDependencies().add(configuration,
String.format("%s:%s:%s:%s", MAVEN_JAVAFX_ARTIFACT_GROUP_ID, javaFXModule.getArtifactName(),
getVersion(), getPlatform().getClassifier()));
}
});
lastUpdatedConfiguration = configuration;
}
private void clearJavaFXDependencies() {
if (customSDKArtifactRepository != null) {
project.getRepositories().remove(customSDKArtifactRepository);
customSDKArtifactRepository = null;
}
if (sdk != null && ! sdk.isEmpty()) {
Map<String, String> dirs = new HashMap<>();
dirs.put("name", "customSDKArtifactRepository");
if (sdk.endsWith(File.separator)) {
dirs.put("dirs", sdk + JAVAFX_SDK_LIB_FOLDER);
} else {
dirs.put("dirs", sdk + File.separator + JAVAFX_SDK_LIB_FOLDER);
}
customSDKArtifactRepository = project.getRepositories().flatDir(dirs);
}
if (lastUpdatedConfiguration == null) {
return;
}
var configuration = project.getConfigurations().findByName(lastUpdatedConfiguration);
if (configuration != null) {
if (customSDKArtifactRepository != null) {
configuration.getDependencies()
.removeIf(dependency -> dependency.getName().startsWith(PREFIX_MODULE));
}
configuration.getDependencies()
.removeIf(dependency -> MAVEN_JAVAFX_ARTIFACT_GROUP_ID.equals(dependency.getGroup()));
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2018, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetector;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import java.awt.*;
import java.util.Arrays;
import java.util.stream.Collectors;
public enum JavaFXPlatform {
LINUX("linux", "linux-x86_64"),
LINUX_MONOCLE("linux-monocle", "linux-x86_64-monocle"),
LINUX_AARCH64("linux-aarch64", "linux-aarch_64"),
LINUX_AARCH64_MONOCLE("linux-aarch64-monocle", "linux-aarch_64-monocle"),
WINDOWS("win", "windows-x86_64"),
WINDOWS_MONOCLE("win-monocle", "windows-x86_64-monocle"),
OSX("mac", "osx-x86_64"),
OSX_MONOCLE("mac-monocle", "osx-x86_64-monocle"),
OSX_AARCH64("mac-aarch64", "osx-aarch_64"),
OSX_AARCH64_MONOCLE("mac-aarch64-monocle", "osx-aarch_64-monocle");
private final String classifier;
private final String osDetectorClassifier;
JavaFXPlatform( String classifier, String osDetectorClassifier ) {
this.classifier = classifier;
this.osDetectorClassifier = osDetectorClassifier;
}
public String getClassifier() {
return classifier;
}
public static JavaFXPlatform detect(Project project) {
String osClassifier = project.getExtensions().getByType(OsDetector.class).getClassifier();
if("true".equals(System.getProperty("java.awt.headless"))) {
osClassifier += "-monocle";
}
for ( JavaFXPlatform platform: values()) {
if ( platform.osDetectorClassifier.equals(osClassifier)) {
return platform;
}
}
String supportedPlatforms = Arrays.stream(values())
.map(p->p.osDetectorClassifier)
.collect(Collectors.joining("', '", "'", "'"));
throw new GradleException(
String.format(
"Unsupported JavaFX platform found: '%s'! " +
"This plugin is designed to work on supported platforms only." +
"Current supported platforms are %s.", osClassifier, supportedPlatforms )
);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2018, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetectorPlugin;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.javamodularity.moduleplugin.ModuleSystemPlugin;
import org.openjfx.gradle.tasks.ExecTask;
public class JavaFXPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().apply(OsDetectorPlugin.class);
project.getPlugins().apply(ModuleSystemPlugin.class);
project.getExtensions().create("javafx", JavaFXOptions.class, project);
project.getTasks().create("configJavafxRun", ExecTask.class, project);
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2019, 2021, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle.tasks;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.api.file.FileCollection;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.ApplicationPlugin;
import org.gradle.api.tasks.JavaExec;
import org.gradle.api.tasks.TaskAction;
import org.javamodularity.moduleplugin.extensions.RunModuleOptions;
import org.openjfx.gradle.JavaFXModule;
import org.openjfx.gradle.JavaFXOptions;
import org.openjfx.gradle.JavaFXPlatform;
import javax.inject.Inject;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TreeSet;
public class ExecTask extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(ExecTask.class);
private final Project project;
private JavaExec execTask;
@Inject
public ExecTask(Project project) {
this.project = project;
project.getPluginManager().withPlugin(ApplicationPlugin.APPLICATION_PLUGIN_NAME, e -> {
execTask = (JavaExec) project.getTasks().findByName(ApplicationPlugin.TASK_RUN_NAME);
if (execTask != null) {
execTask.dependsOn(this);
} else {
throw new GradleException("Run task not found.");
}
});
}
@TaskAction
public void action() {
if (execTask != null) {
JavaFXOptions javaFXOptions = project.getExtensions().getByType(JavaFXOptions.class);
JavaFXModule.validateModules(javaFXOptions.getModules());
var definedJavaFXModuleNames = new TreeSet<>(javaFXOptions.getModules());
if (!definedJavaFXModuleNames.isEmpty()) {
RunModuleOptions moduleOptions = execTask.getExtensions().findByType(RunModuleOptions.class);
final FileCollection classpathWithoutJavaFXJars = execTask.getClasspath().filter(
jar -> Arrays.stream(JavaFXModule.values()).noneMatch(javaFXModule -> jar.getName().contains(javaFXModule.getArtifactName()))
);
final FileCollection javaFXPlatformJars = execTask.getClasspath().filter(jar -> isJavaFXJar(jar, javaFXOptions.getPlatform()));
if (moduleOptions != null) {
LOGGER.info("Modular JavaFX application found");
// Remove empty JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
definedJavaFXModuleNames.forEach(javaFXModule -> moduleOptions.getAddModules().add(javaFXModule));
} else {
LOGGER.info("Non-modular JavaFX application found");
// Remove all JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars);
var javaFXModuleJvmArgs = List.of("--module-path", javaFXPlatformJars.getAsPath());
var jvmArgs = new ArrayList<String>();
jvmArgs.add("--add-modules");
jvmArgs.add(String.join(",", definedJavaFXModuleNames));
List<String> execJvmArgs = execTask.getJvmArgs();
if (execJvmArgs != null) {
jvmArgs.addAll(execJvmArgs);
}
jvmArgs.addAll(javaFXModuleJvmArgs);
execTask.setJvmArgs(jvmArgs);
}
}
} else {
throw new GradleException("Run task not found. Please, make sure the Application plugin is applied");
}
}
private static boolean isJavaFXJar(File jar, JavaFXPlatform platform) {
return jar.isFile() &&
Arrays.stream(JavaFXModule.values()).anyMatch(javaFXModule ->
javaFXModule.compareJarFileName(platform, jar.getName()) ||
javaFXModule.getModuleJarFileName().equals(jar.getName()));
}
}
+57 -22
View File
@@ -1,56 +1,88 @@
## Reproducible builds
# Reproducible builds
Reproducibility is a goal of the Sparrow Wallet project.
Reproducibility is a goal of the Sparrow Wallet project.
As of v1.5.0 and later, it is possible to recreate the exact binaries in the Github releases (specifically, the contents of the `.tar.gz` and `.zip` files).
Due to minor variances, it is not yet possible to reproduce the installer packages (`.deb`, `.rpm` and `.exe`).
In addition, the OSX binary is code signed and thus can't be directly reproduced yet.
In addition, the OSX binary is code signed and thus can't be directly reproduced yet.
Work on resolving both of these issues is ongoing.
### Reproducing a release
## Reproducing a release
#### Install Java
### Install Java
Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release.
For v1.6.4 and later, this is Eclipse Temurin 17.0.2+8.
It is available for all supported platforms from [Eclipse Temurin 17.0.2+8](https://github.com/adoptium/temurin17-binaries/releases/tag/jdk-17.0.2%2B8).
For v1.6.6 and later, this is Eclipse Temurin 18.0.1+10.
#### Java from Adoptium github repo
It is available for all supported platforms from [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10).
For reference, the downloads are as follows:
- [Linux x64](https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.2%2B8/OpenJDK17U-jdk_x64_linux_hotspot_17.0.2_8.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.2%2B8/OpenJDK17U-jdk_x64_mac_hotspot_17.0.2_8.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.2%2B8/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.2_8.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.2%2B8/OpenJDK17U-jdk_x64_windows_hotspot_17.0.2_8.zip)
- [Linux x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_linux_hotspot_18.0.1_10.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_mac_hotspot_18.0.1_10.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_aarch64_mac_hotspot_18.0.1_10.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_windows_hotspot_18.0.1_10.zip)
#### Java from Adoptium deb repo
It is also possible to install via a package manager on *nix systems. For example, on Debian/Ubuntu systems:
```shell
sudo apt-get install -y wget apt-transport-https gnupg
wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | sudo apt-key add -
echo "deb https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
- Install dependencies:
```sh
sudo apt-get install -y wget curl apt-transport-https gnupg
```
Download Adoptium public PGP key:
```sh
curl --tlsv1.2 --proto =https --location -o adoptium.asc https://packages.adoptium.net/artifactory/api/gpg/key/public
```
Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
```
gpg --import --import-options show-only adoptium.asc
```
If key doesn't match, do not procede.
Add Adoptium PGP key to a the keyring shared folder:
```sh
sudo cp adoptium.asc /usr/share/keyrings/
```
Add Adoptium debian repository:
```sh
echo "deb [signed-by=/usr/share/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
```
Update cache, install the desired temurin version and configure java to be linked to this same version:
```
sudo apt update -y
sudo apt-get install -y temurin-17-jdk=17.0.2+8
sudo apt-get install -y temurin-18-jdk=18.0.1+10
sudo update-alternatives --config java
```
#### Java from SDK
A alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
See the installation [instructions here](https://sdkman.io/install).
Once installed, run
```shell
sdk install java 17.0.2-tem
sdk install java 18.0.1-tem
```
#### Other requirements
### Other requirements
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
```shell
sudo apt install -y rpm fakeroot binutils
```
#### Building the binaries
### Building the binaries
The project can cloned for a specific release tag as follows:
```shell
GIT_TAG="1.6.6"
git clone --recursive --branch "${GIT_TAG}" git@github.com:sparrowwallet/sparrow.git
GIT_TAG="1.7.0"
git clone --recursive --branch "${GIT_TAG}" https://github.com/sparrowwallet/sparrow.git
```
Thereafter, building should be straightforward:
@@ -62,7 +94,9 @@ cd sparrow
The binaries (and installers) will be placed in the `build/jpackage` folder.
#### Verifying the binaries are identical
### Verifying the binaries are identical
Verify the built binaries against the released binaries on https://github.com/sparrowwallet/sparrow/releases.
Note that you will be verifying the files in the `build/jpackage/Sparrow` folder against either the `.tar.gz` or `.zip` releases.
Download either of these depending on your platform and extract the contents to a folder (in the following example, `/tmp`).
@@ -74,3 +108,4 @@ diff -r build/jpackage/Sparrow /tmp/Sparrow
This command should have no output indicating that the two folders (and all their contents) are identical.
If there is output, please open an issue with detailed instructions to reproduce, including build system platform.
+1 -1
Submodule drongo updated: 8cdea77562...7c34ec7c3b
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.6.6</string>
<string>1.7.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -12,7 +12,7 @@ public class AboutController {
private Label title;
public void initializeView() {
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION + MainApp.APP_VERSION_SUFFIX);
title.setText(SparrowWallet.APP_NAME + " " + SparrowWallet.APP_VERSION + SparrowWallet.APP_VERSION_SUFFIX);
}
public void setStage(Stage stage) {
@@ -35,7 +35,6 @@ import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.WalletController;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import de.codecentric.centerdevice.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
@@ -132,6 +131,9 @@ public class AppController implements Initializable {
@FXML
private ToggleGroup bitcoinUnit;
@FXML
private ToggleGroup unitFormat;
@FXML
private ToggleGroup theme;
@@ -182,6 +184,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem showPayNym;
@FXML
private Menu switchServer;
@FXML
private CheckMenuItem preventSleep;
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
@@ -322,6 +327,15 @@ public class AppController implements Initializable {
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
format = UnitFormat.DOT;
Config.get().setUnitFormat(format);
}
final UnitFormat selectedFormat = format;
Optional<Toggle> selectedFormatToggle = unitFormat.getToggles().stream().filter(toggle -> selectedFormat.equals(toggle.getUserData())).findFirst();
selectedFormatToggle.ifPresent(toggle -> unitFormat.selectToggle(toggle));
Theme configTheme = Config.get().getTheme();
if(configTheme == null) {
configTheme = Theme.LIGHT;
@@ -367,6 +381,7 @@ public class AppController implements Initializable {
findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
configureSwitchServer();
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
serverToggle.setDisable(Config.get().getServerType() == null);
@@ -377,6 +392,7 @@ public class AppController implements Initializable {
});
openTransactionIdItem.disableProperty().bind(onlineProperty().not());
setNetworkLabel();
}
private void setPlatformApplicationMenu() {
@@ -386,10 +402,10 @@ public class AppController implements Initializable {
MenuItem preferences = new MenuItem("Preferences...");
preferences.setOnAction(this::openPreferences);
preferences.setAccelerator(new KeyCodeCombination(KeyCode.COMMA, KeyCombination.META_DOWN));
Menu defaultApplicationMenu = new Menu("Apple", null, tk.createAboutMenuItem(MainApp.APP_NAME, getAboutStage()), new SeparatorMenuItem(),
Menu defaultApplicationMenu = new Menu("Apple", null, tk.createAboutMenuItem(SparrowWallet.APP_NAME, getAboutStage()), new SeparatorMenuItem(),
preferences, new SeparatorMenuItem(),
tk.createHideMenuItem(MainApp.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
tk.createQuitMenuItem(MainApp.APP_NAME));
tk.createHideMenuItem(SparrowWallet.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
tk.createQuitMenuItem(SparrowWallet.APP_NAME));
tk.setApplicationMenu(defaultApplicationMenu);
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
@@ -404,12 +420,27 @@ public class AppController implements Initializable {
}
}
private void setNetworkLabel() {
if(Network.get() != Network.MAINNET) {
Platform.runLater(() -> {
StackPane tabBackground = (StackPane)tabs.lookup(".tab-header-background");
if(tabBackground != null) {
HBox hBox = new HBox();
Label label = new Label(Network.get().toDisplayString());
label.setPadding(new Insets(0, 10, 0, 0));
hBox.getChildren().add(label);
hBox.setAlignment(Pos.CENTER_RIGHT);
tabBackground.getChildren().add(hBox);
}
});
}
}
public void showIntroduction(ActionEvent event) {
WelcomeDialog welcomeDialog = new WelcomeDialog();
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
preferencesDialog.showAndWait();
openPreferences(PreferenceGroup.SERVER);
}
}
@@ -450,7 +481,7 @@ public class AppController implements Initializable {
}
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle("About " + MainApp.APP_NAME);
stage.setTitle("About " + SparrowWallet.APP_NAME);
stage.initOwner(tabs.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
@@ -855,6 +886,13 @@ public class AppController implements Initializable {
EventManager.get().post(new BitcoinUnitChangedEvent(unit));
}
public void setUnitFormat(ActionEvent event) {
MenuItem item = (MenuItem)event.getSource();
UnitFormat format = (UnitFormat)item.getUserData();
Config.get().setUnitFormat(format);
EventManager.get().post(new UnitFormatChangedEvent(format));
}
public void preventSleep(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setPreventSleep(item.isSelected());
@@ -902,7 +940,7 @@ public class AppController implements Initializable {
private String getServerToggleTooltipText(Integer currentBlockHeight) {
if(AppServices.isConnected()) {
return "Connected to " + Config.get().getServerAddress() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") +
return "Connected to " + Config.get().getServerDisplayName() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") +
(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? "\nWarning! You are connected to a public server and sharing your transaction data with it.\nFor better privacy, consider using your own Bitcoin Core node or private Electrum server." : "");
}
@@ -996,7 +1034,7 @@ public class AppController implements Initializable {
} catch(Exception e) {
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?");
showErrorDialog("Error Opening Wallet", "The wallet file is locked. Is another instance of " + SparrowWallet.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());
@@ -1006,8 +1044,7 @@ public class AppController implements Initializable {
private void openWallet(Storage storage, WalletAndKey walletAndKey, AppController appController, boolean forceSameWindow) {
try {
checkWalletNetwork(walletAndKey.getWallet());
restorePublicKeysFromSeed(storage, walletAndKey.getWallet(), walletAndKey.getKey());
storage.restorePublicKeysFromSeed(walletAndKey.getWallet(), walletAndKey.getKey());
if(!walletAndKey.getWallet().isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
@@ -1026,97 +1063,6 @@ public class AppController implements Initializable {
}
}
private void checkWalletNetwork(Wallet wallet) {
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
}
}
private void restorePublicKeysFromSeed(Storage storage, Wallet wallet, Key key) throws MnemonicException {
if(wallet.containsMasterPrivateKeys()) {
//Derive xpub and master fingerprint from seed, potentially with passphrase
Wallet copy = wallet.copy();
for(int i = 0; i < copy.getKeystores().size(); i++) {
Keystore copyKeystore = copy.getKeystores().get(i);
if(copyKeystore.hasSeed() && copyKeystore.getSeed().getPassphrase() == null) {
if(copyKeystore.getSeed().needsPassphrase()) {
if(!wallet.isMasterWallet() && wallet.getMasterWallet().getKeystores().size() == copy.getKeystores().size() && wallet.getMasterWallet().getKeystores().get(i).hasSeed()) {
copyKeystore.getSeed().setPassphrase(wallet.getMasterWallet().getKeystores().get(i).getSeed().getPassphrase());
} else {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(wallet.getFullDisplayName(), copyKeystore);
Optional<String> optionalPassphrase = passphraseDialog.showAndWait();
if(optionalPassphrase.isPresent()) {
copyKeystore.getSeed().setPassphrase(optionalPassphrase.get());
} else {
return;
}
}
} else {
copyKeystore.getSeed().setPassphrase("");
}
}
}
if(wallet.isEncrypted()) {
if(key == null) {
throw new IllegalStateException("Wallet was not encrypted, but seed is");
}
copy.decrypt(key);
}
if(wallet.isWhirlpoolMasterWallet()) {
String walletId = storage.getWalletId(wallet);
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
whirlpool.setScode(wallet.getMasterMixConfig().getScode());
whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
if(standardAccount != null && standardAccount.getMinimumGapLimit() != null && wallet.gapLimit() == null) {
wallet.setGapLimit(standardAccount.getMinimumGapLimit());
}
for(int i = 0; i < wallet.getKeystores().size(); i++) {
Keystore keystore = wallet.getKeystores().get(i);
if(keystore.hasSeed()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getSeed().clear();
} else if(keystore.hasMasterPrivateExtendedKey()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getMasterPrivateKey().clear();
}
}
}
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
try {
Keystore masterKeystore = wallet.getKeystores().get(0);
Keystore keystore = childWallet.getKeystores().get(0);
keystore.setBip47ExtendedPrivateKey(masterKeystore.getBip47ExtendedPrivateKey());
List<ChildNumber> derivation = keystore.getKeyDerivation().getDerivation();
keystore.setKeyDerivation(new KeyDerivation(masterKeystore.getKeyDerivation().getMasterFingerprint(), derivation));
DeterministicKey pubKey = keystore.getBip47ExtendedPrivateKey().getKey().dropPrivateBytes().dropParent();
keystore.setExtendedPublicKey(new ExtendedKey(pubKey, keystore.getBip47ExtendedPrivateKey().getParentFingerprint(), derivation.get(derivation.size() - 1)));
} catch(Exception e) {
log.error("Cannot prepare BIP47 keystore", e);
}
}
}
}
public void importWallet(ActionEvent event) {
WalletImportDialog dlg = new WalletImportDialog();
Optional<Wallet> optionalWallet = dlg.showAndWait();
@@ -1136,6 +1082,10 @@ public class AppController implements Initializable {
new KeystoneSinglesig(), new KeystoneMultisig(),
new CaravanMultisig());
for(WalletImport importer : walletImporters) {
if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) {
continue;
}
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
@@ -1212,14 +1162,12 @@ public class AppController implements Initializable {
try {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(storage, wallet, null);
storage.restorePublicKeysFromSeed(wallet, null);
addWalletTabOrWindow(storage, wallet, false);
for(Wallet childWallet : wallet.getChildWallets()) {
storage.saveWallet(childWallet);
checkWalletNetwork(childWallet);
restorePublicKeysFromSeed(storage, childWallet, null);
storage.restorePublicKeysFromSeed(childWallet, null);
addWalletTabOrWindow(storage, childWallet, false);
}
Platform.runLater(() -> selectTab(wallet));
@@ -1239,8 +1187,7 @@ public class AppController implements Initializable {
wallet.encrypt(key);
storage.setEncryptionPubKey(encryptionPubKey);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(storage, wallet, key);
storage.restorePublicKeysFromSeed(wallet, key);
addWalletTabOrWindow(storage, wallet, false);
for(Wallet childWallet : wallet.getChildWallets()) {
@@ -1248,8 +1195,7 @@ public class AppController implements Initializable {
childWallet.encrypt(key);
}
storage.saveWallet(childWallet);
checkWalletNetwork(childWallet);
restorePublicKeysFromSeed(storage, childWallet, key);
storage.restorePublicKeysFromSeed(childWallet, key);
addWalletTabOrWindow(storage, childWallet, false);
}
Platform.runLater(() -> selectTab(wallet));
@@ -1284,13 +1230,17 @@ public class AppController implements Initializable {
}
public void openPreferences(ActionEvent event) {
PreferencesDialog preferencesDialog = new PreferencesDialog();
preferencesDialog.showAndWait();
openPreferences(PreferenceGroup.GENERAL);
}
public void openServerPreferences(ActionEvent event) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
openPreferences(PreferenceGroup.SERVER);
}
private void openPreferences(PreferenceGroup preferenceGroup) {
PreferencesDialog preferencesDialog = new PreferencesDialog(preferenceGroup);
preferencesDialog.showAndWait();
configureSwitchServer();
}
public void signVerifyMessage(ActionEvent event) {
@@ -1860,6 +1810,27 @@ public class AppController implements Initializable {
contextMenu.getItems().addAll(lock);
}
MenuItem moveRight = new MenuItem("Move Right");
moveRight.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index + 1, tab);
tabs.getSelectionModel().select(tab);
});
MenuItem moveLeft = new MenuItem("Move Left");
moveLeft.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index - 1, tab);
tabs.getSelectionModel().select(tab);
});
contextMenu.getItems().addAll(moveRight, moveLeft);
contextMenu.setOnShowing(event -> {
moveRight.setDisable(tabs.getTabs().indexOf(tab) == tabs.getTabs().size() - 1);
moveLeft.setDisable(tabs.getTabs().indexOf(tab) == 0);
});
MenuItem close = new MenuItem("Close");
close.setOnAction(event -> {
tabs.getTabs().remove(tab);
@@ -1877,12 +1848,16 @@ public class AppController implements Initializable {
tabs.getTabs().removeAll(tabs.getTabs());
});
MenuItem delete = new MenuItem("Delete...");
delete.setOnAction(event -> {
deleteWallet(getSelectedWalletForm());
});
contextMenu.getItems().addAll(new SeparatorMenuItem(), close, closeOthers, closeAll);
if(tab.getUserData() instanceof WalletTabData) {
MenuItem delete = new MenuItem("Delete...");
delete.setOnAction(event -> {
deleteWallet(getSelectedWalletForm());
});
contextMenu.getItems().addAll(new SeparatorMenuItem(), delete);
}
contextMenu.getItems().addAll(close, closeOthers, closeAll, delete);
return contextMenu;
}
@@ -1971,6 +1946,48 @@ public class AppController implements Initializable {
return contextMenu;
}
private void configureSwitchServer() {
switchServer.getItems().clear();
Config config = Config.get();
if(config.getServerType() == ServerType.BITCOIN_CORE && config.getRecentCoreServers() != null && config.getRecentCoreServers().size() > 1) {
for(Server server : config.getRecentCoreServers()) {
switchServer.getItems().add(getSwitchServerMenuItem(ServerType.BITCOIN_CORE, server));
}
} else if(config.getServerType() == ServerType.ELECTRUM_SERVER && config.getRecentElectrumServers() != null && config.getRecentElectrumServers().size() > 1) {
for(Server server : config.getRecentElectrumServers()) {
switchServer.getItems().add(getSwitchServerMenuItem(ServerType.ELECTRUM_SERVER, server));
}
}
switchServer.setVisible(!switchServer.getItems().isEmpty());
}
private CheckMenuItem getSwitchServerMenuItem(ServerType serverType, Server server) {
CheckMenuItem checkMenuItem = new CheckMenuItem(server.getDisplayName());
boolean selected = (serverType == ServerType.BITCOIN_CORE ? server.equals(Config.get().getCoreServer()) : server.equals(Config.get().getElectrumServer()));
checkMenuItem.setSelected(selected);
checkMenuItem.setOnAction(event -> {
if(!selected) {
boolean online = onlineProperty().get();
onlineProperty().set(false);
if(serverType == ServerType.BITCOIN_CORE) {
Config.get().setCoreServer(server);
} else if(serverType == ServerType.ELECTRUM_SERVER) {
Config.get().setElectrumServer(server);
}
Platform.runLater(() -> {
onlineProperty().set(online);
configureSwitchServer();
});
} else {
checkMenuItem.setSelected(true);
}
});
return checkMenuItem;
}
public void setServerType(ServerType serverType) {
if(serverType == ServerType.PUBLIC_ELECTRUM_SERVER && !serverToggle.getStyleClass().contains("public-server")) {
serverToggle.getStyleClass().add("public-server");
@@ -2399,11 +2416,12 @@ public class AppController implements Initializable {
@Subscribe
public void connection(ConnectionEvent event) {
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
String status = "Connected to " + Config.get().getServerDisplayName() + " at height " + event.getBlockHeight();
statusUpdated(new StatusEvent(status));
setServerToggleTooltip(event.getBlockHeight());
serverToggleStopAnimation();
setTorIcon();
configureSwitchServer();
}
@Subscribe
@@ -2623,6 +2641,12 @@ public class AppController implements Initializable {
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
}
@Subscribe
public void unitFormatChanged(UnitFormatChangedEvent event) {
Optional<Toggle> selectedToggle = unitFormat.getToggles().stream().filter(toggle -> event.getUnitFormat().equals(toggle.getUserData())).findFirst();
selectedToggle.ifPresent(toggle -> unitFormat.selectToggle(toggle));
}
@Subscribe
public void openWalletsInNewWindowsStatusChanged(OpenWalletsNewWindowsStatusEvent event) {
openWalletsInNewWindows.setSelected(event.isOpenWalletsInNewWindows());
@@ -20,7 +20,6 @@ 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.sparrow.control.TextUtils;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
@@ -28,6 +27,7 @@ import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -42,7 +42,6 @@ import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
@@ -52,7 +51,6 @@ 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.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -69,8 +67,6 @@ 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 {
@@ -97,9 +93,11 @@ public class AppServices {
private final SorobanServices sorobanServices = new SorobanServices();
private InteractionServices interactionServices;
private static PayNymService payNymService;
private final MainApp application;
private final Application application;
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
@@ -173,8 +171,9 @@ public class AppServices {
openFiles(event.getFiles(), null);
};
public AppServices(MainApp application) {
private AppServices(Application application, InteractionServices interactionServices) {
this.application = application;
this.interactionServices = interactionServices;
EventManager.get().register(this);
EventManager.get().register(whirlpoolServices);
EventManager.get().register(sorobanServices);
@@ -203,7 +202,7 @@ public class AppServices {
private void restartServices() {
Config config = Config.get();
if(config.hasServerAddress()) {
if(config.hasServer()) {
restartService(connectionService);
}
@@ -211,7 +210,7 @@ public class AppServices {
restartService(ratesService);
}
if(config.isCheckNewVersions() && Network.get() == Network.MAINNET) {
if(config.isCheckNewVersions() && Network.get() == Network.MAINNET && Interface.get() == Interface.DESKTOP) {
restartService(versionCheckService);
}
@@ -268,7 +267,7 @@ public class AppServices {
connectionService.setOnRunning(workerStateEvent -> {
connectionService.setDelay(Duration.ZERO);
if(!ElectrumServer.isConnected()) {
EventManager.get().post(new ConnectionStartEvent(Config.get().getServerAddress()));
EventManager.get().post(new ConnectionStartEvent(Config.get().getServerDisplayName()));
}
});
connectionService.setOnSucceeded(successEvent -> {
@@ -499,8 +498,12 @@ public class AppServices {
return proxy;
}
static void initialize(MainApp application) {
INSTANCE = new AppServices(application);
public static void initialize(Application application) {
INSTANCE = new AppServices(application, new DefaultInteractionServices());
}
public static void initialize(Application application, InteractionServices interactionServices) {
INSTANCE = new AppServices(application, interactionServices);
}
public static AppServices get() {
@@ -515,6 +518,10 @@ public class AppServices {
return get().sorobanServices;
}
public static InteractionServices getInteractionServices() {
return get().interactionServices;
}
public static PayNymService getPayNymService() {
if(payNymService == null) {
HostAndPort torProxy = getTorProxy();
@@ -561,7 +568,7 @@ public class AppServices {
private static Image getWindowIcon() {
if(windowIcon == null) {
windowIcon = new Image(MainApp.class.getResourceAsStream("/image/sparrow-icon.png"));
windowIcon = new Image(SparrowWallet.class.getResourceAsStream("/image/sparrow-icon.png"));
}
return windowIcon;
@@ -571,7 +578,7 @@ public class AppServices {
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
}
public MainApp getApplication() {
public Application getApplication() {
return application;
}
@@ -746,40 +753,7 @@ public class AppServices {
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
if(graphic != null) {
alert.setGraphic(graphic);
}
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
if(matcher.find()) {
String link = matcher.group(1);
HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content);
hyperlinkLabel.setMaxWidth(Double.MAX_VALUE);
hyperlinkLabel.setMaxHeight(Double.MAX_VALUE);
hyperlinkLabel.getStyleClass().add("content");
Label label = new Label();
hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50));
hyperlinkLabel.setOnAction(event -> {
alert.close();
get().getApplication().getHostServices().showDocument(link);
});
alert.getDialogPane().setContent(hyperlinkLabel);
}
String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum();
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
moveToActiveWindowScreen(alert);
return alert.showAndWait();
return getInteractionServices().showAlert(title, content, alertType, graphic, buttons);
}
public static void setStageIcon(Window window) {
@@ -1130,7 +1104,7 @@ public class AppServices {
Wallet wallet = walletTabData.getWallet();
Storage storage = walletTabData.getStorage();
if(!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB)) {
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB))) {
usbWallet = true;
if(deviceEnumerateService == null) {
@@ -1157,12 +1131,18 @@ public class AppServices {
@Subscribe
public void requestConnect(RequestConnectEvent event) {
onlineProperty.set(true);
if(Config.get().hasServer()) {
onlineProperty.set(true);
}
}
@Subscribe
public void requestDisconnect(RequestDisconnectEvent event) {
onlineProperty.set(false);
//Ensure services don't try to reconnect
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
}
@Subscribe
@@ -1209,7 +1189,7 @@ public class AppServices {
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && isConnected()) {
onlineProperty.set(false);
log.warn("Failed to fetch wallet history from " + Config.get().getServerAddress() + ", reconnecting to another server...");
log.warn("Failed to fetch wallet history from " + Config.get().getServerDisplayName() + ", reconnecting to another server...");
Config.get().changePublicServer();
onlineProperty.set(true);
}
@@ -17,6 +17,12 @@ public class Args {
@Parameter(names = { "--level", "-l" }, description = "Set log level")
public Level level;
@Parameter(names = { "--terminal", "-t" }, description = "Terminal mode", arity = 0)
public boolean terminal;
@Parameter(names = { "--version", "-v" }, description = "Show version", arity = 0)
public boolean version;
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
public boolean help;
@@ -0,0 +1,65 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.control.KeystorePassphraseDialog;
import com.sparrowwallet.sparrow.control.TextUtils;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.text.Font;
import org.controlsfx.control.HyperlinkLabel;
import java.util.Arrays;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
if(graphic != null) {
alert.setGraphic(graphic);
}
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
if(matcher.find()) {
String link = matcher.group(1);
HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content);
hyperlinkLabel.setMaxWidth(Double.MAX_VALUE);
hyperlinkLabel.setMaxHeight(Double.MAX_VALUE);
hyperlinkLabel.getStyleClass().add("content");
Label label = new Label();
hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50));
hyperlinkLabel.setOnAction(event -> {
alert.close();
AppServices.get().getApplication().getHostServices().showDocument(link);
});
alert.getDialogPane().setContent(hyperlinkLabel);
}
String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum();
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@Override
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
return passphraseDialog.showAndWait();
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.wallet.Keystore;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import java.util.Optional;
public interface InteractionServices {
Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons);
Optional<String> requestPassphrase(String walletName, Keystore keystore);
}
@@ -0,0 +1,34 @@
package com.sparrowwallet.sparrow;
public enum Interface {
DESKTOP, TERMINAL, SERVER;
private static Interface currentInterface;
public static Interface get() {
if(currentInterface == null) {
boolean headless = java.awt.GraphicsEnvironment.isHeadless();
boolean monocle = "Monocle".equalsIgnoreCase(System.getProperty("glass.platform"));
if(headless || monocle) {
currentInterface = TERMINAL;
if(headless && !monocle) {
throw new UnsupportedOperationException("Headless environment detected but Monocle platform not found");
}
} else {
currentInterface = DESKTOP;
}
}
return currentInterface;
}
public static void set(Interface interf) {
if(currentInterface != null && interf != currentInterface) {
throw new IllegalStateException("Interface already set to " + currentInterface);
}
currentInterface = interf;
}
}
@@ -1,7 +1,5 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -12,40 +10,27 @@ 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;
import org.controlsfx.glyphfont.GlyphFontRegistry;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import java.io.File;
import java.util.*;
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.6.6";
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";
public class SparrowDesktop extends Application {
private Stage mainStage;
private static SparrowInstance sparrowInstance;
@Override
public void init() throws Exception {
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
if(e instanceof IndexOutOfBoundsException && Arrays.stream(e.getStackTrace()).anyMatch(element -> element.getClassName().equals("javafx.scene.chart.BarChart"))) {
LoggerFactory.getLogger(MainApp.class).debug("Exception in thread \"" + t.getName() + "\"", e);;
LoggerFactory.getLogger(SparrowWallet.class).debug("Exception in thread \"" + t.getName() + "\"", e);;
} else {
LoggerFactory.getLogger(MainApp.class).error("Exception in thread \"" + t.getName() + "\"", e);
LoggerFactory.getLogger(SparrowWallet.class).error("Exception in thread \"" + t.getName() + "\"", e);
}
});
super.init();
@@ -77,7 +62,7 @@ public class MainApp extends Application {
} else if(Network.get() == Network.MAINNET) {
Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
Config.get().setPublicElectrumServer(servers.get(new Random().nextInt(servers.size())).getUrl());
Config.get().setPublicElectrumServer(servers.get(new Random().nextInt(servers.size())).getServer());
}
}
}
@@ -129,102 +114,9 @@ public class MainApp extends Application {
Config.get().setAppWidth(mainStage.getWidth());
Config.get().setAppHeight(mainStage.getHeight());
mainStage.close();
if(sparrowInstance != null) {
sparrowInstance.freeLock();
}
}
public static void main(String[] argv) {
Args args = new Args();
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase(Locale.ROOT)).acceptUnknownOptions(true).build();
jCommander.parse(argv);
if(args.help) {
jCommander.usage();
System.exit(0);
}
if(args.level != null) {
Drongo.setRootLogLevel(args.level);
}
if(args.dir != null) {
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
}
if(args.network != null) {
Network.set(args.network);
} else {
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
if(envNetwork != null) {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase(Locale.ROOT)));
} catch(Exception e) {
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
if(testnetFlag.exists()) {
Network.set(Network.TESTNET);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
}
if(Network.get() != Network.MAINNET) {
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);
}
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...");
SparrowWallet.Instance instance = SparrowWallet.getSparrowInstance();
if(instance != null) {
instance.freeLock();
}
}
}
@@ -0,0 +1,155 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.instance.InstanceException;
import com.sparrowwallet.sparrow.instance.InstanceList;
import com.sparrowwallet.sparrow.terminal.SparrowTerminal;
import com.sun.javafx.application.PlatformImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import java.io.File;
import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.7.0";
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 static Instance instance;
public static void main(String[] argv) {
Args args = new Args();
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase(Locale.ROOT)).acceptUnknownOptions(true).build();
jCommander.parse(argv);
if(args.help) {
jCommander.usage();
System.exit(0);
}
if(args.version) {
System.out.println("Sparrow Wallet " + APP_VERSION);
System.exit(0);
}
if(args.level != null) {
Drongo.setRootLogLevel(args.level);
}
if(args.dir != null) {
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
}
if(args.network != null) {
Network.set(args.network);
} else {
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
if(envNetwork != null) {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase(Locale.ROOT)));
} catch(Exception e) {
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
if(testnetFlag.exists()) {
Network.set(Network.TESTNET);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
}
if(Network.get() != Network.MAINNET) {
getLogger().info("Using " + Network.get() + " configuration");
}
List<String> fileUriArguments = jCommander.getUnknownOptions();
try {
instance = new Instance(fileUriArguments);
instance.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();
if(args.terminal) {
Interface.set(Interface.TERMINAL);
}
try {
if(Interface.get() == Interface.TERMINAL) {
PlatformImpl.setTaskbarApplication(false);
Drongo.removeRootLogAppender("STDOUT");
com.sun.javafx.application.LauncherImpl.launchApplication(SparrowTerminal.class, SparrowWalletPreloader.class, argv);
} else {
com.sun.javafx.application.LauncherImpl.launchApplication(SparrowDesktop.class, SparrowWalletPreloader.class, argv);
}
} catch(UnsupportedOperationException e) {
Drongo.removeRootLogAppender("STDOUT");
getLogger().error("Unable to launch application", e);
System.out.println("No display detected. Use Sparrow Server on a headless (no display) system.");
try {
if(instance != null) {
instance.freeLock();
}
} catch(InstanceException instanceException) {
getLogger().error("Unable to free instance lock", e);
}
}
}
public static Instance getSparrowInstance() {
return instance;
}
private static Logger getLogger() {
return LoggerFactory.getLogger(SparrowWallet.class);
}
public static class Instance extends InstanceList {
private final List<String> fileUriArguments;
public Instance(List<String> fileUriArguments) {
super(SparrowWallet.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...");
}
}
}
@@ -3,7 +3,7 @@ package com.sparrowwallet.sparrow;
import javafx.application.Preloader;
import javafx.stage.Stage;
public class MainAppPreloader extends Preloader {
public class SparrowWalletPreloader extends Preloader {
@Override
public void start(Stage stage) {
com.sun.glass.ui.Application.GetApplication().setName("Sparrow");
@@ -0,0 +1,86 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.protocol.Transaction;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
public enum UnitFormat {
DOT {
private final DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(getLocale()));
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
public DecimalFormat getCurrencyFormat() {
return currencyFormat;
}
public Locale getLocale() {
return Locale.ENGLISH;
}
},
COMMA {
private final DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(getLocale()));
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
public DecimalFormat getCurrencyFormat() {
return currencyFormat;
}
public Locale getLocale() {
return Locale.GERMAN;
}
};
public abstract Locale getLocale();
public abstract DecimalFormat getBtcFormat();
public abstract DecimalFormat getTableBtcFormat();
public abstract DecimalFormat getCurrencyFormat();
public String formatBtcValue(Long amount) {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String formatSatsValue(Long amount) {
return String.format(getLocale(), "%,d", amount);
}
public String formatCurrencyValue(double amount) {
return getCurrencyFormat().format(amount);
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
return DecimalFormatSymbols.getInstance(getLocale());
}
public String getGroupingSeparator() {
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
}
public String getDecimalSeparator() {
return Character.toString(getDecimalFormatSymbols().getDecimalSeparator());
}
}
@@ -20,7 +20,7 @@ import java.util.*;
public class AddressTreeTable extends CoinTreeTable {
public void initialize(NodeEntry rootEntry) {
getStyleClass().add("address-treetable");
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
String address = rootEntry.getAddress().toString();
updateAll(rootEntry);
@@ -114,7 +114,7 @@ public class AddressTreeTable extends CoinTreeTable {
}
public void updateAll(NodeEntry rootEntry) {
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
setRoot(rootItem);
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.collect.Lists;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
@@ -35,8 +36,7 @@ public class BalanceChart extends LineChart<Number, Number> {
getData().add(balanceSeries);
update(walletTransactionsEntry);
BitcoinUnit unit = Config.get().getBitcoinUnit();
setBitcoinUnit(walletTransactionsEntry.getWallet(), unit);
setUnitFormat(walletTransactionsEntry.getWallet(), Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void update(WalletTransactionsEntry walletTransactionsEntry) {
@@ -116,12 +116,16 @@ public class BalanceChart extends LineChart<Number, Number> {
}
}
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
public void setUnitFormat(Wallet wallet, UnitFormat format, BitcoinUnit unit) {
if(format == null) {
format = UnitFormat.DOT;
}
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = wallet.getAutoUnit();
}
NumberAxis yaxis = (NumberAxis)getYAxis();
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, unit));
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
}
}
@@ -1,28 +1,31 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import javafx.scene.chart.NumberAxis;
import javafx.util.StringConverter;
import java.text.ParseException;
final class CoinAxisFormatter extends StringConverter<Number> {
private final UnitFormat unitFormat;
private final BitcoinUnit bitcoinUnit;
public CoinAxisFormatter(NumberAxis axis, BitcoinUnit unit) {
public CoinAxisFormatter(NumberAxis axis, UnitFormat format, BitcoinUnit unit) {
this.unitFormat = format;
this.bitcoinUnit = unit;
}
@Override
public String toString(Number object) {
Double value = bitcoinUnit.getValue(object.longValue());
return CoinTextFormatter.COIN_FORMAT.format(value);
return new CoinTextFormatter(unitFormat).getCoinFormat().format(value);
}
@Override
public Number fromString(String string) {
try {
Number number = CoinTextFormatter.COIN_FORMAT.parse(string);
Number number = new CoinTextFormatter(unitFormat).getCoinFormat().parse(string);
return bitcoinUnit.getSatsValue(number.doubleValue());
} catch (ParseException e) {
throw new RuntimeException(e);
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
@@ -14,12 +15,8 @@ import javafx.util.Duration;
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() {
@@ -45,10 +42,11 @@ class CoinCell extends TreeTableCell<Entry, Number> {
EntryCell.applyRowStyles(this, entry);
CoinTreeTable coinTreeTable = (CoinTreeTable)getTreeTableView();
UnitFormat format = coinTreeTable.getUnitFormat();
BitcoinUnit unit = coinTreeTable.getBitcoinUnit();
String satsValue = String.format(Locale.ENGLISH, "%,d", amount.longValue());
DecimalFormat decimalFormat = (amount.longValue() == 0L ? CoinLabel.getBTCFormat() : TABLE_BTC_FORMAT);
String satsValue = format.formatSatsValue(amount.longValue());
DecimalFormat decimalFormat = (amount.longValue() == 0L ? format.getBtcFormat() : format.getTableBtcFormat());
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
@@ -61,8 +59,7 @@ class CoinCell extends TreeTableCell<Entry, Number> {
setTooltip(tooltip);
String tooltipValue = tooltip.getText();
if(entry instanceof TransactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleLongProperty;
@@ -12,13 +12,7 @@ import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
public class CoinLabel extends Label {
public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
private final CoinContextMenu contextMenu;
@@ -58,8 +52,9 @@ public class CoinLabel extends Label {
setTooltip(tooltip);
setContextMenu(contextMenu);
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
String btcValue = BTC_FORMAT.format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
String satsValue = format.formatSatsValue(value) + " sats";
String btcValue = format.formatBtcValue(value) + " BTC";
BitcoinUnit unit = bitcoinUnit;
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
@@ -89,16 +84,12 @@ public class CoinLabel extends Label {
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(BTC_FORMAT.format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(getValue()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
}
}
public static DecimalFormat getBTCFormat() {
BTC_FORMAT.setMaximumFractionDigits(8);
return BTC_FORMAT;
}
}
@@ -1,24 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.UnitFormat;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextInputControl;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.ParseException;
import java.util.Locale;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
private static final Pattern COIN_VALIDATION = Pattern.compile("[\\d,]*(\\.\\d{0,8})?");
public static final DecimalFormat COIN_FORMAT = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
}
public CoinTextFormatter() {
super(new CoinFilter());
public UnitFormat getUnitFormat() {
return ((CoinFilter)getFilter()).unitFormat;
}
public DecimalFormat getCoinFormat() {
return ((CoinFilter)getFilter()).coinFormat;
}
private static class CoinFilter implements UnaryOperator<Change> {
private final UnitFormat unitFormat;
private final DecimalFormat coinFormat;
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(unitFormat.getLocale()));
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@Override
public Change apply(Change change) {
String oldText = change.getControlText();
@@ -30,17 +45,17 @@ public class CoinTextFormatter extends TextFormatter<String> {
String noFractionCommaText = newText;
int commasRemoved = 0;
int dotIndex = newText.indexOf(".");
int dotIndex = newText.indexOf(unitFormat.getDecimalSeparator());
if(dotIndex > -1) {
noFractionCommaText = newText.substring(0, dotIndex) + newText.substring(dotIndex).replaceAll(",", "");
noFractionCommaText = newText.substring(0, dotIndex) + newText.substring(dotIndex).replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
commasRemoved = newText.length() - noFractionCommaText.length();
}
if(!COIN_VALIDATION.matcher(noFractionCommaText).matches()) {
if(!coinValidation.matcher(noFractionCommaText).matches()) {
return null;
}
if(",".equals(change.getText())) {
if(unitFormat.getGroupingSeparator().equals(change.getText())) {
return null;
}
@@ -48,20 +63,20 @@ public class CoinTextFormatter extends TextFormatter<String> {
return change;
}
if(change.isDeleted() && ",".equals(deleted) && change.getRangeStart() > 0) {
if(change.isDeleted() && unitFormat.getGroupingSeparator().equals(deleted) && change.getRangeStart() > 0) {
noFractionCommaText = noFractionCommaText.substring(0, change.getRangeStart() - 1) + noFractionCommaText.substring(change.getRangeEnd() - 1);
}
try {
Number value = COIN_FORMAT.parse(noFractionCommaText);
String correct = COIN_FORMAT.format(value.doubleValue());
Number value = coinFormat.parse(noFractionCommaText);
String correct = coinFormat.format(value.doubleValue());
String compare = newText;
if(compare.contains(".") && compare.endsWith("0")) {
if(compare.contains(unitFormat.getDecimalSeparator()) && compare.endsWith("0")) {
compare = compare.replaceAll("0*$", "");
}
if(compare.endsWith(".")) {
if(compare.endsWith(unitFormat.getDecimalSeparator())) {
compare = compare.substring(0, compare.length() - 1);
}
@@ -79,11 +94,11 @@ public class CoinTextFormatter extends TextFormatter<String> {
if(correct.length() != newText.length()) {
String postCorrect = correct.substring(Math.min(change.getCaretPosition(), correct.length()));
int commasAfter = postCorrect.length() - postCorrect.replace(",", "").length();
int caretShift = change.isDeleted() && ".".equals(deleted) ? commasAfter : 0;
int commasAfter = postCorrect.length() - postCorrect.replace(unitFormat.getGroupingSeparator(), "").length();
int caretShift = change.isDeleted() && unitFormat.getDecimalSeparator().equals(deleted) ? commasAfter : 0;
int caret = change.getCaretPosition() + (correct.length() - newText.length() - caretShift) + commasRemoved;
if(caret >= 0) {
if(caret >= 0 && caret <= change.getControlNewText().length()) {
change.setCaretPosition(caret);
change.setAnchor(caret);
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
@@ -26,25 +27,36 @@ import java.util.Optional;
public class CoinTreeTable extends TreeTableView<Entry> {
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
public BitcoinUnit getBitcoinUnit() {
return bitcoinUnit;
}
public void setBitcoinUnit(BitcoinUnit bitcoinUnit) {
this.bitcoinUnit = bitcoinUnit;
public UnitFormat getUnitFormat() {
return unitFormat;
}
public void setBitcoinUnit(Wallet wallet) {
setBitcoinUnit(wallet, Config.get().getBitcoinUnit());
public void setUnitFormat(Wallet wallet) {
setUnitFormat(wallet, Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
public void setUnitFormat(Wallet wallet, UnitFormat format) {
setUnitFormat(wallet, format, Config.get().getBitcoinUnit());
}
public void setUnitFormat(Wallet wallet, UnitFormat format, BitcoinUnit unit) {
if(format == null) {
format = UnitFormat.DOT;
}
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = wallet.getAutoUnit();
}
boolean changed = (bitcoinUnit != unit);
boolean changed = (unitFormat != format);
changed |= (bitcoinUnit != unit);
this.unitFormat = format;
this.bitcoinUnit = unit;
if(changed && !getChildren().isEmpty()) {
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleLongProperty;
@@ -11,8 +11,6 @@ import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.util.Locale;
public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
@@ -24,7 +22,7 @@ public class CopyableCoinLabel extends CopyableLabel {
public CopyableCoinLabel(String text) {
super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getBitcoinUnit()));
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
tooltip = new Tooltip();
contextMenu = new CoinContextMenu();
}
@@ -42,19 +40,23 @@ public class CopyableCoinLabel extends CopyableLabel {
}
public void refresh() {
refresh(Config.get().getBitcoinUnit());
refresh(Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void refresh(BitcoinUnit bitcoinUnit) {
setValueAsText(getValue(), bitcoinUnit);
public void refresh(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
setValueAsText(getValue(), unitFormat, bitcoinUnit);
}
private void setValueAsText(Long value, BitcoinUnit bitcoinUnit) {
private void setValueAsText(Long value, UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
setTooltip(tooltip);
setContextMenu(contextMenu);
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
String btcValue = CoinLabel.getBTCFormat().format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
if(unitFormat == null) {
unitFormat = UnitFormat.DOT;
}
String satsValue = unitFormat.formatSatsValue(value) + " sats";
String btcValue = unitFormat.formatBtcValue(value) + " BTC";
BitcoinUnit unit = bitcoinUnit;
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
@@ -84,7 +86,8 @@ public class CopyableCoinLabel extends CopyableLabel {
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(CoinLabel.getBTCFormat().format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(getValue()));
Clipboard.getSystemClipboard().setContent(content);
});
@@ -1,28 +1,13 @@
package com.sparrowwallet.sparrow.control;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.Image;
import com.lowagie.text.pdf.PdfWriter;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.AppServices;
import javafx.embed.swing.SwingFXUtils;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.control.Button;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.io.File;
import java.io.FileOutputStream;
public class DescriptorQRDisplayDialog extends QRDisplayDialog {
private static final Logger log = LoggerFactory.getLogger(DescriptorQRDisplayDialog.class);
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur) {
super(ur);
@@ -31,43 +16,11 @@ public class DescriptorQRDisplayDialog extends QRDisplayDialog {
dialogPane.getButtonTypes().add(pdfButtonType);
Button pdfButton = (Button)dialogPane.lookupButton(pdfButtonType);
pdfButton.setGraphicTextGap(5);
pdfButton.setGraphic(getGlyph(FontAwesome5.Glyph.FILE_PDF));
pdfButton.addEventFilter(ActionEvent.ACTION, event -> {
savePdf(walletName, outputDescriptor, ur);
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur);
event.consume();
});
}
private void savePdf(String walletName, String outputDescriptor, UR ur) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save PDF");
fileChooser.setInitialFileName(walletName + ".pdf");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(Document document = new Document()) {
document.setMargins(36, 36, 48, 36);
PdfWriter.getInstance(document, new FileOutputStream(file));
document.open();
Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, Color.BLACK);
Chunk title = new Chunk("Output descriptor for " + walletName, titleFont);
document.add(title);
UREncoder urEncoder = new UREncoder(ur, 2000, 10, 0);
String fragment = urEncoder.nextPart();
if(urEncoder.isSinglePart()) {
Image image = Image.getInstance(SwingFXUtils.fromFXImage(getQrCode(fragment), null), Color.WHITE);
document.add(image);
}
Font descriptorFont = FontFactory.getFont(FontFactory.COURIER, 14, Color.BLACK);
Paragraph descriptor = new Paragraph(outputDescriptor, descriptorFont);
document.add(descriptor);
} catch(Exception e) {
log.error("Error creating descriptor PDF", e);
AppServices.showErrorDialog("Error creating PDF", e.getMessage());
}
}
}
}
@@ -9,7 +9,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform;
import javafx.geometry.Pos;
@@ -96,11 +96,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
increaseFeeButton.setOnAction(event -> {
increaseFee(transactionEntry);
increaseFee(transactionEntry, false);
});
actionBox.getChildren().add(increaseFeeButton);
}
@@ -188,7 +189,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
}
private static void increaseFee(TransactionEntry transactionEntry) {
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
@@ -200,6 +201,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
if(utxos.isEmpty()) {
log.error("No UTXOs to replace");
AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction - no replaceable UTXOs were found.");
return;
}
List<TransactionOutput> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
@@ -222,7 +229,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
//Remove any UTXOs created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty()) {
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
//If there is insufficient change output, include another random UTXO so the fee can be increased
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
utxos.add(utxo);
@@ -279,6 +286,15 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
return;
}
if(cancelTransaction) {
Payment existing = payments.get(0);
Address address = transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress();
Payment payment = new Payment(address, existing.getLabel(), existing.getAmount(), true);
payments.clear();
payments.add(payment);
opReturns.clear();
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true)));
}
@@ -410,6 +426,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
return increaseFeeGlyph;
}
private static Glyph getCancelTransactionRBFGlyph() {
Glyph cancelTxGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BAN);
cancelTxGlyph.setFontSize(12);
return cancelTxGlyph;
}
private static Glyph getIncreaseFeeCPFPGlyph() {
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
cpfpGlyph.setFontSize(12);
@@ -463,17 +485,28 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
hide();
increaseFee(transactionEntry);
increaseFee(transactionEntry, false);
});
getItems().add(increaseFee);
}
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> {
hide();
increaseFee(transactionEntry, true);
});
getItems().add(cancelTx);
}
if(containsWalletOutputs(transactionEntry)) {
MenuItem createCpfp = new MenuItem("Increase Effective Fee (CPFP)");
createCpfp.setGraphic(getIncreaseFeeCPFPGlyph());
@@ -558,6 +591,42 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(signVerifyMessage);
}
if(nodeEntry != null && !nodeEntry.getNode().getUnspentTransactionOutputs().isEmpty()) {
List<BlockTransactionHashIndex> utxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().collect(Collectors.toList());
MenuItem spendUtxos = new MenuItem("Spend UTXOs");
spendUtxos.setGraphic(getSendGlyph());
spendUtxos.setOnAction(AE -> {
hide();
EventManager.get().post(new SendActionEvent(nodeEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(nodeEntry.getWallet(), utxos)));
});
getItems().add(spendUtxos);
List<BlockTransactionHashIndex> unfrozenUtxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().filter(utxo -> utxo.getStatus() != Status.FROZEN).collect(Collectors.toList());
if(!unfrozenUtxos.isEmpty()) {
MenuItem freezeUtxos = new MenuItem("Freeze UTXOs");
freezeUtxos.setGraphic(getFreezeGlyph());
freezeUtxos.setOnAction(AE -> {
hide();
unfrozenUtxos.forEach(utxo -> utxo.setStatus(Status.FROZEN));
EventManager.get().post(new WalletUtxoStatusChangedEvent(nodeEntry.getWallet(), unfrozenUtxos));
});
getItems().add(freezeUtxos);
}
List<BlockTransactionHashIndex> frozenUtxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().filter(utxo -> utxo.getStatus() == Status.FROZEN).collect(Collectors.toList());
if(!frozenUtxos.isEmpty()) {
MenuItem unfreezeUtxos = new MenuItem("Unfreeze UTXOs");
unfreezeUtxos.setGraphic(getUnfreezeGlyph());
unfreezeUtxos.setOnAction(AE -> {
hide();
frozenUtxos.forEach(utxo -> utxo.setStatus(null));
EventManager.get().post(new WalletUtxoStatusChangedEvent(nodeEntry.getWallet(), frozenUtxos));
});
getItems().add(unfreezeUtxos);
}
}
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(AE -> {
hide();
@@ -1,7 +1,9 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.*;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
@@ -10,14 +12,9 @@ import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Currency;
import java.util.Locale;
public class FiatLabel extends CopyableLabel {
private static final DecimalFormat CURRENCY_FORMAT = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final DoubleProperty btcRateProperty = new SimpleDoubleProperty(0.0);
private final ObjectProperty<Currency> currencyProperty = new SimpleObjectProperty<>(null);
@@ -30,9 +27,9 @@ public class FiatLabel extends CopyableLabel {
public FiatLabel(String text) {
super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue));
btcRateProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue()));
currencyProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue()));
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat()));
btcRateProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue(), Config.get().getUnitFormat()));
currencyProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue(), Config.get().getUnitFormat()));
tooltip = new Tooltip();
contextMenu = new FiatContextMenu();
}
@@ -83,14 +80,22 @@ public class FiatLabel extends CopyableLabel {
setCurrency(currency);
}
private void setValueAsText(long balance) {
public void refresh() {
refresh(Config.get().getUnitFormat());
}
public void refresh(UnitFormat unitFormat) {
setValueAsText(getValue(), unitFormat);
}
private void setValueAsText(long balance, UnitFormat unitFormat) {
if(getCurrency() != null && getBtcRate() > 0.0) {
BigDecimal satsBalance = BigDecimal.valueOf(balance);
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(getBtcRate()));
String label = getCurrency().getSymbol() + " " + CURRENCY_FORMAT.format(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + getCurrency().getSymbol() + " " + CURRENCY_FORMAT.format(getBtcRate()));
String label = getCurrency().getSymbol() + " " + unitFormat.formatCurrencyValue(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + getCurrency().getSymbol() + " " + unitFormat.formatCurrencyValue(getBtcRate()));
setText(label);
setTooltip(tooltip);
@@ -82,7 +82,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(wallet);
String fileName = wallet.getFullName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
if(exporter instanceof Sparrow) {
if(exporter.exportsAllWallets()) {
fileName = wallet.getMasterName();
}
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
@@ -121,6 +121,10 @@ public class PayNymAvatar extends StackPane {
Proxy proxy = AppServices.getProxy();
String url = PayNymService.getHostUrl(proxy != null) + "/" + paymentCodeStr + "/avatar";
if(log.isDebugEnabled()) {
log.debug("Requesting PayNym avatar from " + url);
}
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false);
paymentCodeCache.put(cacheId, image);
@@ -244,11 +244,11 @@ public class QRDisplayDialog extends Dialog<UR> {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
protected static Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
@@ -74,7 +74,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
results = new CoinTreeTable();
results.setShowRoot(false);
results.setPrefWidth(showWallet ? 950 : 850);
results.setBitcoinUnit(walletForms.iterator().next().getWallet());
results.setUnitFormat(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results"));
@@ -3,10 +3,10 @@ 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.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Theme;
@@ -21,6 +21,7 @@ import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -28,15 +29,17 @@ import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Line;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
@@ -45,11 +48,14 @@ import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import javax.imageio.ImageIO;
import java.awt.image.*;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.control.CoinLabel.BTC_FORMAT;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
private static final int REDUCED_MAX_UTXOS = MAX_UTXOS - 2;
@@ -69,13 +75,14 @@ public class TransactionDiagram extends GridPane {
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
private boolean expanded;
private TransactionDiagram expandedDiagram;
private ContextMenu contextMenu;
private final EventHandler<MouseEvent> expandedDiagramHandler = new EventHandler<>() {
@Override
public void handle(MouseEvent event) {
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle(walletTx.getPayments().iterator().next().getLabel());
stage.setTitle(getDiagramTitle());
stage.initOwner(TransactionDiagram.this.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
@@ -98,7 +105,8 @@ public class TransactionDiagram extends GridPane {
expandedDiagram = new TransactionDiagram();
expandedDiagram.setId("transactionDiagram");
expandedDiagram.setExpanded(true);
updateExpandedDiagram();
expandedDiagram.setFinal(isFinal());
updateDerivedDiagram(expandedDiagram);
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER_RIGHT);
@@ -125,6 +133,13 @@ public class TransactionDiagram extends GridPane {
}
};
private final EventHandler<ContextMenuEvent> contextMenuHandler = new EventHandler<>() {
@Override
public void handle(ContextMenuEvent event) {
contextMenu.show(getChildren().iterator().next(), event.getScreenX(), event.getScreenY());
}
};
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
setMaxHeight(getDiagramHeight());
@@ -136,7 +151,7 @@ public class TransactionDiagram extends GridPane {
update();
setOnMouseClicked(expandedDiagramHandler);
if(expandedDiagram != null) {
updateExpandedDiagram();
updateDerivedDiagram(expandedDiagram);
}
}
}
@@ -165,20 +180,22 @@ public class TransactionDiagram extends GridPane {
getChildren().clear();
}
private void updateExpandedDiagram() {
expandedDiagram.setFinal(isFinal());
expandedDiagram.setOptimizationStrategy(getOptimizationStrategy());
expandedDiagram.walletTx = walletTx;
private void updateDerivedDiagram(TransactionDiagram diagram) {
diagram.setOptimizationStrategy(getOptimizationStrategy());
diagram.walletTx = walletTx;
List<Map<BlockTransactionHashIndex, WalletNode>> utxoSets = expandedDiagram.getDisplayedUtxoSets();
int maxSetSize = utxoSets.stream().mapToInt(Map::size).max().orElse(0);
int maxRows = Math.max(maxSetSize * utxoSets.size(), walletTx.getPayments().size() + 2);
double diagramHeight = Math.max(DIAGRAM_HEIGHT, Math.min(EXPANDED_DIAGRAM_HEIGHT, maxRows * ROW_HEIGHT));
expandedDiagram.setMinHeight(diagramHeight);
expandedDiagram.setMaxHeight(diagramHeight);
expandedDiagram.update();
if(diagram.isExpanded()) {
List<Map<BlockTransactionHashIndex, WalletNode>> utxoSets = diagram.getDisplayedUtxoSets();
int maxSetSize = utxoSets.stream().mapToInt(Map::size).max().orElse(0);
int maxRows = Math.max(maxSetSize * utxoSets.size(), walletTx.getPayments().size() + 2);
double diagramHeight = Math.max(DIAGRAM_HEIGHT, Math.min(EXPANDED_DIAGRAM_HEIGHT, maxRows * ROW_HEIGHT));
diagram.setMinHeight(diagramHeight);
diagram.setMaxHeight(diagramHeight);
}
if(expandedDiagram.getScene() != null && expandedDiagram.getScene().getWindow() instanceof Stage stage) {
diagram.update();
if(diagram.getScene() != null && diagram.getScene().getWindow() instanceof Stage stage) {
stage.sizeToScene();
}
}
@@ -208,6 +225,17 @@ public class TransactionDiagram extends GridPane {
getChildren().clear();
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
if(contextMenu == null) {
contextMenu = new ContextMenu();
MenuItem menuItem = new MenuItem("Save as Image...");
menuItem.setOnAction(event -> {
contextMenu.hide();
saveAsImage();
});
contextMenu.getItems().add(menuItem);
setOnContextMenuRequested(contextMenuHandler);
}
}
private List<Map<BlockTransactionHashIndex, WalletNode>> getDisplayedUtxoSets() {
@@ -434,7 +462,7 @@ public class TransactionDiagram extends GridPane {
StringJoiner joiner = new StringJoiner("\n");
joiner.add("Spending " + getSatsValue(inputValue) + " sats from" + (isExpanded() ? ":" : " (click to expand):"));
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(additionalInput.getHashAsString() + ":" + additionalInput.getIndex());
joiner.add(getInputDescription(additionalInput));
}
tooltip.setText(joiner.toString());
} else if(input instanceof InvisibleBlockTransactionHashIndex) {
@@ -797,12 +825,51 @@ public class TransactionDiagram extends GridPane {
tooltip.setShowDuration(Duration.INDEFINITE);
tooltip.getStyleClass().add("transaction-tooltip");
txLabel.setTooltip(tooltip);
txPane.getChildren().add(txLabel);
txPane.getChildren().add(createSpacer());
return txPane;
}
private void saveAsImage() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Image");
fileChooser.setInitialFileName(getDiagramTitle() + ".png");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
TransactionDiagram transactionDiagram = new TransactionDiagram();
transactionDiagram.setId("transactionDiagram");
transactionDiagram.setFinal(true);
transactionDiagram.setExpanded(isExpanded());
transactionDiagram.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
transactionDiagram.setStyle("-fx-text-background-color: " + (Config.get().getTheme() == Theme.DARK ? "#ffffff" : "#000000"));
updateDerivedDiagram(transactionDiagram);
Scene scene = new Scene(transactionDiagram);
scene.setFill(Color.TRANSPARENT);
scene.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
scene.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scene.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
Image image = scene.snapshot(null);
BufferedImage bufferedImage = SwingFXUtils.fromFXImage(image, null);
try {
ImageIO.write(bufferedImage, "png", file);
} catch(IOException e) {
AppServices.showErrorDialog("Error saving image", e.getMessage());
}
}
}
private String getDiagramTitle() {
if(!isFinal() && walletTx.getPayments().size() > 0 && walletTx.getPayments().get(0).getLabel() != null) {
return walletTx.getPayments().get(0).getLabel();
} else {
return "[" + walletTx.getTransaction().getTxId().toString().substring(0, 6) + "]";
}
}
public double getDiagramHeight() {
if(isExpanded()) {
return getMaxHeight();
@@ -1244,6 +1311,14 @@ public class TransactionDiagram extends GridPane {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyAddress);
MenuItem showAddress = new MenuItem("Show Address as QR...");
showAddress.setOnAction(event -> {
hide();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
qrDisplayDialog.showAndWait();
});
getItems().add(showAddress);
}
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
@@ -1257,7 +1332,8 @@ public class TransactionDiagram extends GridPane {
copyBtcValue.setOnAction(event -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(BTC_FORMAT.format((double)value / Transaction.SATOSHIS_PER_BITCOIN));
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(value));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
@@ -8,12 +7,10 @@ import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import java.util.List;
public class TransactionsTreeTable extends CoinTreeTable {
public void initialize(WalletTransactionsEntry rootEntry) {
getStyleClass().add("transactions-treetable");
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
updateAll(rootEntry);
setShowRoot(false);
@@ -58,7 +55,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
}
public void updateAll(WalletTransactionsEntry rootEntry) {
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
setRoot(rootItem);
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
@@ -36,8 +37,7 @@ public class UtxosChart extends BarChart<String, Number> {
getData().add(utxoSeries);
update(walletUtxosEntry);
BitcoinUnit unit = Config.get().getBitcoinUnit();
setBitcoinUnit(walletUtxosEntry.getWallet(), unit);
setUnitFormat(walletUtxosEntry.getWallet(), Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void update(WalletUtxosEntry walletUtxosEntry) {
@@ -117,12 +117,16 @@ public class UtxosChart extends BarChart<String, Number> {
this.selectedEntries = entries;
}
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
public void setUnitFormat(Wallet wallet, UnitFormat format, BitcoinUnit unit) {
if(format == null) {
format = UnitFormat.DOT;
}
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = wallet.getAutoUnit();
}
NumberAxis yaxis = (NumberAxis)getYAxis();
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, unit));
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
}
}
@@ -1,20 +1,17 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.TableColumnBase;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import java.util.Comparator;
import java.util.List;
public class UtxosTreeTable extends CoinTreeTable {
public void initialize(WalletUtxosEntry rootEntry) {
getStyleClass().add("utxos-treetable");
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
updateAll(rootEntry);
setShowRoot(false);
@@ -93,7 +90,7 @@ public class UtxosTreeTable extends CoinTreeTable {
}
public void updateAll(WalletUtxosEntry rootEntry) {
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
setRoot(rootItem);
@@ -42,17 +42,19 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new Descriptor(), new SpecterDesktop(), new Sparrow());
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
Accordion exportAccordion = new Accordion();
for (WalletExport exporter : exporters) {
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
exportAccordion.getPanes().add(exportPane);
for(WalletExport exporter : exporters) {
if(!exporter.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
exportAccordion.getPanes().add(exportPane);
}
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
@@ -49,16 +49,20 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
}
}
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
}
}
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
@@ -1,11 +1,19 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
public class BitcoinUnitChangedEvent {
public class BitcoinUnitChangedEvent extends UnitFormatChangedEvent {
private final BitcoinUnit bitcoinUnit;
public BitcoinUnitChangedEvent(BitcoinUnit bitcoinUnit) {
super(Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat());
this.bitcoinUnit = bitcoinUnit;
}
public BitcoinUnitChangedEvent(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
super(unitFormat);
this.bitcoinUnit = bitcoinUnit;
}
@@ -1,10 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
@@ -13,7 +12,6 @@ import com.sparrowwallet.sparrow.wallet.TransactionHashIndexEntry;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
public class NewWalletTransactionsEvent {
@@ -50,16 +48,21 @@ public class NewWalletTransactionsEvent {
}
public String getValueAsText(long value) {
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
format = UnitFormat.DOT;
}
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
}
if(unit == BitcoinUnit.BTC) {
return CoinLabel.getBTCFormat().format((double) value / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
return format.formatBtcValue(value) + " BTC";
}
return String.format(Locale.ENGLISH, "%,d", value) + " sats";
return format.formatSatsValue(value) + " sats";
}
public List<BlockTransaction> getUnspentConfirmingWhirlpoolMixTransactions() {
@@ -0,0 +1,21 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
public class UnitFormatChangedEvent {
private final UnitFormat unitFormat;
public UnitFormatChangedEvent(UnitFormat unitFormat) {
this.unitFormat = unitFormat;
}
public UnitFormat getUnitFormat() {
return unitFormat;
}
public BitcoinUnit getBitcoinUnit() {
return Config.get().getBitcoinUnit();
}
}
@@ -38,6 +38,7 @@ public class FontAwesome5 extends GlyphFont {
FEATHER_ALT('\uf56b'),
FILE_CSV('\uf6dd'),
FILE_IMPORT('\uf56f'),
FILE_PDF('\uf1c1'),
HAND_HOLDING('\uf4bd'),
HAND_HOLDING_MEDICAL('\ue05c'),
HAND_HOLDING_WATER('\uf4c1'),
@@ -67,6 +68,7 @@ public class FontAwesome5 extends GlyphFont {
SNOWFLAKE('\uf2dc'),
SORT_NUMERIC_DOWN('\uf162'),
SUN('\uf185'),
TAG('\uf02b'),
THEATER_MASKS('\uf630'),
TIMES_CIRCLE('\uf057'),
TOGGLE_OFF('\uf204'),
@@ -67,4 +67,9 @@ public class CoboVaultMultisig extends ColdcardMultisig {
public boolean isWalletExportScannable() {
return true;
}
@Override
public boolean isDeprecated() {
return true;
}
}
@@ -102,6 +102,11 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
return true;
}
@Override
public boolean isDeprecated() {
return true;
}
private static class CoboVaultSinglesigKeystore {
public String ExtPubKey;
public String MasterFingerprint;
@@ -187,7 +187,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
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.getFullName()).append("\n");
writer.append("Name: ").append(wallet.getFullName().length() >= 20 ? (wallet.getDisplayName().length() >= 20 ? wallet.getDisplayName().substring(0, 20) : wallet.getDisplayName()) : wallet.getFullName()).append("\n");
writer.append("Policy: ").append(Integer.toString(wallet.getDefaultPolicy().getNumSignaturesRequired())).append(" of ").append(Integer.toString(wallet.getKeystores().size())).append("\n");
if(!multipleDerivations) {
writer.append("Derivation: ").append(wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath()).append("\n");
@@ -214,7 +214,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getWalletExportDescription() {
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from SD feature.";
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from File feature.";
}
@Override
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.net.*;
@@ -27,6 +28,7 @@ public class Config {
private Mode mode;
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
private FeeRatesSource feeRatesSource;
private FeeRatesSelection feeRatesSelection;
private OptimizationStrategy sendOptimizationStrategy;
@@ -44,6 +46,7 @@ public class Config {
private boolean showTransactionHex = true;
private boolean showLoadingLog = true;
private boolean showAddressTransactionCount = false;
private boolean showDeprecatedImportExport = false;
private boolean preventSleep = false;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
@@ -53,14 +56,14 @@ public class Config {
private Boolean hdCapture;
private String webcamDevice;
private ServerType serverType;
private String publicElectrumServer;
private String coreServer;
private List<String> recentCoreServers;
private Server publicElectrumServer;
private Server coreServer;
private List<Server> recentCoreServers;
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private String electrumServer;
private List<String> recentElectrumServers;
private Server electrumServer;
private List<Server> recentElectrumServers;
private File electrumServerCert;
private boolean useProxy;
private String proxyServer;
@@ -77,6 +80,8 @@ public class Config {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(File.class, new FileSerializer());
gsonBuilder.registerTypeAdapter(File.class, new FileDeserializer());
gsonBuilder.registerTypeAdapter(Server.class, new ServerSerializer());
gsonBuilder.registerTypeAdapter(Server.class, new ServerDeserializer());
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
@@ -131,6 +136,15 @@ public class Config {
flush();
}
public UnitFormat getUnitFormat() {
return unitFormat;
}
public void setUnitFormat(UnitFormat unitFormat) {
this.unitFormat = unitFormat;
flush();
}
public FeeRatesSource getFeeRatesSource() {
return feeRatesSource;
}
@@ -288,6 +302,15 @@ public class Config {
flush();
}
public boolean isShowDeprecatedImportExport() {
return showDeprecatedImportExport;
}
public void setShowDeprecatedImportExport(boolean showDeprecatedImportExport) {
this.showDeprecatedImportExport = showDeprecatedImportExport;
flush();
}
public boolean isPreventSleep() {
return preventSleep;
}
@@ -362,14 +385,18 @@ public class Config {
flush();
}
public boolean hasServerAddress() {
return getServerAddress() != null && !getServerAddress().isEmpty();
public boolean hasServer() {
return getServer() != null;
}
public String getServerAddress() {
public Server getServer() {
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : (getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? getPublicElectrumServer() : getElectrumServer());
}
public String getServerDisplayName() {
return getServer() == null ? "server" : getServer().getDisplayName();
}
public boolean requiresInternalTor() {
if(isUseProxy()) {
return false;
@@ -379,57 +406,71 @@ public class Config {
}
public boolean requiresTor() {
if(!hasServerAddress()) {
if(!hasServer()) {
return false;
}
Protocol protocol = Protocol.getProtocol(getServerAddress());
if(protocol == null) {
return false;
}
return protocol.isOnionAddress(protocol.getServerHostAndPort(getServerAddress()));
return getServer().isOnionAddress();
}
public String getPublicElectrumServer() {
public Server getPublicElectrumServer() {
return publicElectrumServer;
}
public void setPublicElectrumServer(String publicElectrumServer) {
public void setPublicElectrumServer(Server publicElectrumServer) {
this.publicElectrumServer = publicElectrumServer;
flush();
}
public void changePublicServer() {
List<String> otherServers = PublicElectrumServer.getServers().stream().map(PublicElectrumServer::getUrl).filter(url -> !url.equals(getPublicElectrumServer())).collect(Collectors.toList());
List<Server> otherServers = PublicElectrumServer.getServers().stream().map(PublicElectrumServer::getServer).filter(server -> !server.equals(getPublicElectrumServer())).collect(Collectors.toList());
if(!otherServers.isEmpty()) {
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
}
}
public String getCoreServer() {
public Server getCoreServer() {
return coreServer;
}
public void setCoreServer(String coreServer) {
public void setCoreServer(Server coreServer) {
this.coreServer = coreServer;
flush();
}
public List<String> getRecentCoreServers() {
return recentCoreServers;
public List<Server> getRecentCoreServers() {
return recentCoreServers == null ? new ArrayList<>() : recentCoreServers;
}
public void addRecentCoreServer(String coreServer) {
public boolean addRecentCoreServer(Server coreServer) {
if(recentCoreServers == null) {
recentCoreServers = new ArrayList<>();
}
if(!recentCoreServers.contains(coreServer)) {
recentCoreServers.stream().filter(url -> Objects.equals(Protocol.getHost(url), Protocol.getHost(coreServer)))
.findFirst().ifPresent(existingUrl -> recentCoreServers.remove(existingUrl));
int index = getRecentCoreServers().indexOf(coreServer);
if(index < 0) {
recentCoreServers.removeIf(server -> server.getHost().equals(coreServer.getHost()) && server.getAlias() == null);
recentCoreServers.add(coreServer);
flush();
return true;
}
return false;
}
public void removeRecentCoreServer(Server server) {
int index = getRecentCoreServers().indexOf(server);
if(index >= 0) {
recentCoreServers.remove(index);
flush();
}
}
public void setCoreServerAlias(Server server) {
int index = getRecentCoreServers().indexOf(server);
if(index >= 0) {
recentCoreServers.set(index, server);
flush();
}
}
@@ -460,37 +501,59 @@ public class Config {
flush();
}
public String getElectrumServer() {
public Server getElectrumServer() {
return electrumServer;
}
public void setElectrumServer(String electrumServer) {
public void setElectrumServer(Server electrumServer) {
this.electrumServer = electrumServer;
flush();
}
public List<String> getRecentElectrumServers() {
return recentElectrumServers;
public List<Server> getRecentElectrumServers() {
return recentElectrumServers == null ? new ArrayList<>() : recentElectrumServers;
}
public void addRecentServer() {
public boolean addRecentServer() {
if(serverType == ServerType.BITCOIN_CORE && coreServer != null) {
addRecentCoreServer(coreServer);
return addRecentCoreServer(coreServer);
} else if(serverType == ServerType.ELECTRUM_SERVER && electrumServer != null) {
addRecentElectrumServer(electrumServer);
return addRecentElectrumServer(electrumServer);
}
return false;
}
public void addRecentElectrumServer(String electrumServer) {
public boolean addRecentElectrumServer(Server electrumServer) {
if(recentElectrumServers == null) {
recentElectrumServers = new ArrayList<>();
}
if(!recentElectrumServers.contains(electrumServer)) {
recentElectrumServers.stream().filter(url -> Objects.equals(Protocol.getHost(url), Protocol.getHost(electrumServer)))
.findFirst().ifPresent(existingUrl -> recentElectrumServers.remove(existingUrl));
int index = getRecentElectrumServers().indexOf(electrumServer);
if(index < 0) {
recentElectrumServers.removeIf(server -> server.getHost().equals(electrumServer.getHost()) && server.getAlias() == null);
recentElectrumServers.add(electrumServer);
flush();
return true;
}
return false;
}
public void removeRecentElectrumServer(Server server) {
int index = getRecentElectrumServers().indexOf(server);
if(index >= 0) {
recentElectrumServers.remove(index);
flush();
}
}
public void setElectrumServerAlias(Server server) {
int index = getRecentElectrumServers().indexOf(server);
if(index >= 0) {
recentElectrumServers.set(index, server);
flush();
}
}
@@ -595,4 +658,18 @@ public class Config {
return new File(json.getAsJsonPrimitive().getAsString());
}
}
private static class ServerSerializer implements JsonSerializer<Server> {
@Override
public JsonElement serialize(Server src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class ServerDeserializer implements JsonDeserializer<Server> {
@Override
public Server deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Server.fromString(json.getAsJsonPrimitive().getAsString());
}
}
}
@@ -66,7 +66,18 @@ public class Descriptor implements WalletImport, WalletExport {
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
String outputDescriptor = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
inputStream.transferTo(baos);
InputStream firstClone = new ByteArrayInputStream(baos.toByteArray());
InputStream secondClone = new ByteArrayInputStream(baos.toByteArray());
try {
return PdfUtils.getOutputDescriptor(firstClone).toWallet();
} catch(Exception e) {
//ignore
}
String outputDescriptor = new BufferedReader(new InputStreamReader(secondClone, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor.trim());
return descriptor.toWallet();
} catch(Exception e) {
@@ -193,6 +193,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
keystore.setLabel(keystore.getLabel().substring(0, Keystore.MAX_LABEL_LENGTH));
}
wallet.makeLabelsUnique(keystore);
wallet.getKeystores().add(keystore);
ExtendedKey.Header xpubHeader = ExtendedKey.Header.fromExtendedKey(ek.xpub);
@@ -0,0 +1,136 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
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.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
public class ElectrumPersonalServer implements WalletExport {
private static final Logger log = LoggerFactory.getLogger(ElectrumPersonalServer.class);
@Override
public String getName() {
return "Electrum Personal Server";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.EPS;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
if(wallet.getScriptType() == ScriptType.P2TR) {
throw new ExportException(getName() + " does not support Taproot wallets.");
}
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.write("# Electrum Personal Server configuration file fragments\n");
writer.write("# Copy the lines below into the relevant sections in your EPS config.ini file\n\n");
writer.write("# Copy into [master-public-keys] section\n");
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
writeWalletXpub(masterWallet, writer);
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isNested()) {
writeWalletXpub(childWallet, writer);
}
}
writeBip47Addresses(masterWallet, writer);
writer.flush();
} catch(Exception e) {
log.error("Could not export EPS wallet", e);
throw new ExportException("Could not export EPS wallet", e);
}
}
private static void writeWalletXpub(Wallet wallet, BufferedWriter writer) throws IOException {
writer.write(wallet.getFullName().replace(' ', '_') + " = ");
ExtendedKey.Header xpubHeader = ExtendedKey.Header.fromScriptType(wallet.getScriptType(), false);
if(wallet.getPolicyType() == PolicyType.MULTI) {
writer.write(wallet.getDefaultPolicy().getNumSignaturesRequired() + " ");
}
for(Iterator<Keystore> iter = wallet.getKeystores().iterator(); iter.hasNext(); ) {
Keystore keystore = iter.next();
writer.write(keystore.getExtendedPublicKey().toString(xpubHeader));
if(iter.hasNext()) {
writer.write(" ");
}
}
writer.newLine();
}
private static void writeBip47Addresses(Wallet wallet, BufferedWriter writer) throws IOException {
if(wallet.hasPaymentCode()) {
writer.write("\n# Copy into [watch-only-addresses] section\n");
WalletNode notificationNode = wallet.getNotificationWallet().getNode(KeyPurpose.NOTIFICATION);
writer.write(wallet.getFullName().replace(' ', '_') + "-notification_addr = " + notificationNode.getAddress().toString() + "\n");
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
writer.write(childWallet.getFullName().replace(' ', '_') + " = ");
for(Iterator<KeyPurpose> purposeIterator = KeyPurpose.DEFAULT_PURPOSES.iterator(); purposeIterator.hasNext(); ) {
KeyPurpose keyPurpose = purposeIterator.next();
for(Iterator<WalletNode> iter = childWallet.getNode(keyPurpose).getChildren().iterator(); iter.hasNext(); ) {
WalletNode receiveNode = iter.next();
writer.write(receiveNode.getAddress().toString());
if(iter.hasNext()) {
writer.write(" ");
}
}
if(purposeIterator.hasNext()) {
writer.write(" ");
}
}
writer.newLine();
}
}
writer.write("\n# Important: If this wallet receives any BIP47 payments, redo this export");
}
}
@Override
public String getWalletExportDescription() {
return "Export this wallet as a configuration file fragment to copy into your Electrum Personal Server (EPS) config.ini file.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "ini";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public boolean exportsAllWallets() {
return true;
}
}
@@ -2,6 +2,6 @@ package com.sparrowwallet.sparrow.io;
import java.io.File;
public interface FileImport extends Import {
public interface FileImport extends ImportExport {
boolean isEncrypted(File file);
}
@@ -300,6 +300,7 @@ public class Hwi {
if(hwiExecutable == null || !hwiExecutable.exists()) {
try {
Platform platform = Platform.getCurrent();
String osArch = System.getProperty("os.arch");
Set<PosixFilePermission> ownerExecutableWritable = PosixFilePermissions.fromString("rwxr--r--");
//A PyInstaller --onefile expands into a new directory on every run triggering OSX Gatekeeper checks.
@@ -307,7 +308,6 @@ 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) {
String osArch = System.getProperty("os.arch");
InputStream inputStream;
if(osArch.equals("aarch64")) {
inputStream = Hwi.class.getResourceAsStream("/native/osx/aarch64/" + HWI_VERSION_DIR + "-mac-aarch64-signed.zip");
@@ -359,6 +359,9 @@ public class Hwi {
Files.createDirectories(getHwiHomeDir().toPath());
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(getHwiHomeDir().toPath(), HWI_VERSION_DIR, null);
} else if(osArch.equals("aarch64")) {
inputStream = Hwi.class.getResourceAsStream("/native/linux/aarch64/hwi");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
@@ -1,8 +0,0 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public interface Import {
String getName();
WalletModel getWalletModel();
}
@@ -2,7 +2,10 @@ package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public interface Export {
public interface ImportExport {
String getName();
WalletModel getWalletModel();
default boolean isDeprecated() {
return false;
}
}
@@ -0,0 +1,45 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.File;
import java.io.InputStream;
public class Jade implements KeystoreFileImport {
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public String getName() {
return "Jade";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.JADE;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
throw new ImportException("Failed to detect a valid " + scriptType.getDescription() + " keystore.");
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your Jade by selecting Xpub Export from the Settings menu once you have loaded your seed.";
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
}
@@ -1,5 +1,5 @@
package com.sparrowwallet.sparrow.io;
public interface KeystoreImport extends Import {
public interface KeystoreImport extends ImportExport {
String getKeystoreImportDescription();
}
@@ -0,0 +1,110 @@
package com.sparrowwallet.sparrow.io;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.Image;
import com.lowagie.text.pdf.PdfReader;
import com.lowagie.text.pdf.PdfWriter;
import com.lowagie.text.pdf.parser.PdfTextExtractor;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.AppServices;
import javafx.embed.swing.SwingFXUtils;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.io.*;
import java.util.Scanner;
public class PdfUtils {
private static final Logger log = LoggerFactory.getLogger(PdfUtils.class);
private static final int QR_WIDTH = 480;
private static final int QR_HEIGHT = 480;
public static void saveOutputDescriptor(String walletName, String outputDescriptor, UR ur) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save PDF");
fileChooser.setInitialFileName(walletName + ".pdf");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(Document document = new Document()) {
document.setMargins(36, 36, 48, 36);
PdfWriter.getInstance(document, new FileOutputStream(file));
document.open();
Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, Color.BLACK);
Chunk title = new Chunk("Output descriptor for " + walletName, titleFont);
document.add(title);
UREncoder urEncoder = new UREncoder(ur, 2000, 10, 0);
String fragment = urEncoder.nextPart();
if(urEncoder.isSinglePart()) {
Image image = Image.getInstance(SwingFXUtils.fromFXImage(getQrCode(fragment), null), Color.WHITE);
document.add(image);
}
Font descriptorFont = FontFactory.getFont(FontFactory.COURIER, 14, Color.BLACK);
Paragraph descriptor = new Paragraph(outputDescriptor, descriptorFont);
document.add(descriptor);
} catch(Exception e) {
log.error("Error creating descriptor PDF", e);
AppServices.showErrorDialog("Error creating PDF", e.getMessage());
}
}
}
public static OutputDescriptor getOutputDescriptor(InputStream inputStream) throws IOException {
try {
PdfReader pdfReader = new PdfReader(inputStream);
String allText = "";
for(int page = 1; page <= pdfReader.getNumberOfPages(); page++) {
PdfTextExtractor textExtractor = new PdfTextExtractor(pdfReader);
allText += textExtractor.getTextFromPage(page) + "\n";
}
String descriptor = null;
Scanner scanner = new Scanner(allText);
while(scanner.hasNextLine()) {
String line = scanner.nextLine().trim();
if(descriptor != null) {
descriptor += line;
} else if(ScriptType.fromDescriptor(line) != null) {
descriptor = line;
}
}
if(descriptor != null) {
return OutputDescriptor.getOutputDescriptor(descriptor);
}
} catch(Exception e) {
throw new IllegalArgumentException("Not a valid PDF or output descriptor");
}
throw new IllegalArgumentException("Output descriptor could not be found");
}
private static javafx.scene.image.Image getQrCode(String fragment) throws IOException, WriterException {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
return new javafx.scene.image.Image(bais);
}
}
@@ -0,0 +1,99 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.net.Protocol;
import java.util.Arrays;
public class Server {
private final String url;
private final String alias;
public Server(String url) {
this(url, null);
}
public Server(String url, String alias) {
if(url == null) {
throw new IllegalArgumentException("Url cannot be null");
}
if(url.isEmpty()) {
throw new IllegalArgumentException("Url cannot be empty");
}
if(Protocol.getProtocol(url) == null) {
throw new IllegalArgumentException("Unknown protocol for url " + url + ", must be one of " + Arrays.toString(Protocol.values()));
}
if(Protocol.getHost(url) == null) {
throw new IllegalArgumentException("Cannot determine host for url " + url);
}
if(alias != null && alias.isEmpty()) {
throw new IllegalArgumentException("Server alias cannot be an empty string");
}
this.url = url;
this.alias = alias;
}
public String getUrl() {
return url;
}
public Protocol getProtocol() {
return Protocol.getProtocol(url);
}
public HostAndPort getHostAndPort() {
return getProtocol().getServerHostAndPort(url);
}
public String getHost() {
return getHostAndPort().getHost();
}
public boolean isOnionAddress() {
return Protocol.isOnionAddress(getHostAndPort());
}
public String getAlias() {
return alias;
}
public String getDisplayName() {
return alias == null ? url : alias;
}
public String toString() {
return url + (alias == null ? "" : "|" + alias);
}
public static Server fromString(String server) {
String[] parts = server.split("\\|");
if(parts.length >= 2) {
return new Server(parts[0], parts[1]);
}
return new Server(parts[0], null);
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(o == null || getClass() != o.getClass()) {
return false;
}
Server server = (Server)o;
return url.equals(server.url);
}
@Override
public int hashCode() {
return url.hashCode();
}
}
@@ -144,4 +144,9 @@ public class Sparrow implements WalletImport, WalletExport {
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean exportsAllWallets() {
return true;
}
}
@@ -1,12 +1,15 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.MainApp;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
@@ -136,6 +139,98 @@ public class Storage {
closePersistenceService.start();
}
public void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException {
checkWalletNetwork(wallet);
if(wallet.containsMasterPrivateKeys()) {
//Derive xpub and master fingerprint from seed, potentially with passphrase
Wallet copy = wallet.copy();
for(int i = 0; i < copy.getKeystores().size(); i++) {
Keystore copyKeystore = copy.getKeystores().get(i);
if(copyKeystore.hasSeed() && copyKeystore.getSeed().getPassphrase() == null) {
if(copyKeystore.getSeed().needsPassphrase()) {
if(!wallet.isMasterWallet() && wallet.getMasterWallet().getKeystores().size() == copy.getKeystores().size() && wallet.getMasterWallet().getKeystores().get(i).hasSeed()) {
copyKeystore.getSeed().setPassphrase(wallet.getMasterWallet().getKeystores().get(i).getSeed().getPassphrase());
} else {
Optional<String> optionalPassphrase = AppServices.getInteractionServices().requestPassphrase(wallet.getFullDisplayName(), copyKeystore);
if(optionalPassphrase.isPresent()) {
copyKeystore.getSeed().setPassphrase(optionalPassphrase.get());
} else {
return;
}
}
} else {
copyKeystore.getSeed().setPassphrase("");
}
}
}
if(wallet.isEncrypted()) {
if(key == null) {
throw new IllegalStateException("Wallet was not encrypted, but seed is");
}
copy.decrypt(key);
}
if(wallet.isWhirlpoolMasterWallet()) {
String walletId = getWalletId(wallet);
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
whirlpool.setScode(wallet.getMasterMixConfig().getScode());
whirlpool.setHDWallet(getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
if(standardAccount != null && standardAccount.getMinimumGapLimit() != null && wallet.gapLimit() == null) {
wallet.setGapLimit(standardAccount.getMinimumGapLimit());
}
for(int i = 0; i < wallet.getKeystores().size(); i++) {
Keystore keystore = wallet.getKeystores().get(i);
if(keystore.hasSeed()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getSeed().clear();
} else if(keystore.hasMasterPrivateExtendedKey()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getMasterPrivateKey().clear();
}
}
}
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
try {
Keystore masterKeystore = wallet.getKeystores().get(0);
Keystore keystore = childWallet.getKeystores().get(0);
keystore.setBip47ExtendedPrivateKey(masterKeystore.getBip47ExtendedPrivateKey());
List<ChildNumber> derivation = keystore.getKeyDerivation().getDerivation();
keystore.setKeyDerivation(new KeyDerivation(masterKeystore.getKeyDerivation().getMasterFingerprint(), derivation));
DeterministicKey pubKey = keystore.getBip47ExtendedPrivateKey().getKey().dropPrivateBytes().dropParent();
keystore.setExtendedPublicKey(new ExtendedKey(pubKey, keystore.getBip47ExtendedPrivateKey().getParentFingerprint(), derivation.get(derivation.size() - 1)));
} catch(Exception e) {
log.error("Cannot prepare BIP47 keystore", e);
}
}
}
}
private void checkWalletNetwork(Wallet wallet) {
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
}
}
public void backupWallet() throws IOException {
if(walletFile.toPath().startsWith(getWalletsDir().toPath())) {
backupWallet(null);
@@ -452,8 +547,8 @@ public class Storage {
}
public static File getSparrowHome() {
if(System.getProperty(MainApp.APP_HOME_PROPERTY) != null) {
return new File(System.getProperty(MainApp.APP_HOME_PROPERTY));
if(System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) {
return new File(System.getProperty(SparrowWallet.APP_HOME_PROPERTY));
}
if(Platform.getCurrent() == Platform.WINDOWS) {
@@ -4,10 +4,13 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.OutputStream;
public interface WalletExport extends Export {
public interface WalletExport extends ImportExport {
void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException;
String getWalletExportDescription();
String getExportFileExtension(Wallet wallet);
boolean isWalletExportScannable();
boolean walletExportRequiresDecryption();
default boolean exportsAllWallets() {
return false;
}
}
@@ -4,7 +4,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.InputStream;
public interface WalletImport extends Import, FileImport {
public interface WalletImport extends FileImport {
String getWalletImportDescription();
Wallet importWallet(InputStream inputStream, String password) throws ImportException;
boolean isWalletImportScannable();
@@ -18,15 +18,17 @@ public class HwAirgappedController extends KeystoreImportDetailController {
public void initializeView() {
List<KeystoreFileImport> importers = Collections.emptyList();
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
}
for(KeystoreFileImport importer : importers) {
FileKeystoreImportPane importPane = new FileKeystoreImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
importAccordion.getPanes().add(importPane);
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileKeystoreImportPane importPane = new FileKeystoreImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
importAccordion.getPanes().add(importPane);
}
}
}
@@ -18,6 +18,10 @@ public class SwController extends KeystoreImportDetailController {
List<KeystoreImport> importers = List.of(new Bip39(), new Electrum(), new Bip32());
for(KeystoreImport importer : importers) {
if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) {
continue;
}
TitledDescriptionPane importPane = null;
if(importer instanceof KeystoreFileImport) {
@@ -123,8 +123,8 @@ public class Auth47 {
}
private void send(String json) throws IOException, Auth47Exception {
if(log.isDebugEnabled()) {
log.debug("Sending " + json + " to " + callback);
if(log.isInfoEnabled()) {
log.info("Sending auth47 response " + json + " to " + callback);
}
Proxy proxy = AppServices.getProxy();
@@ -146,6 +146,10 @@ public enum BroadcastSource {
try {
URL url = getURL(proxy);
if(log.isInfoEnabled()) {
log.info("Broadcasting transaction to " + url);
}
HttpURLConnection connection = proxy == null ? (HttpURLConnection)url.openConnection() : (HttpURLConnection)url.openConnection(proxy);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "text/plain");
@@ -51,6 +51,8 @@ public class Bwt {
NativeUtils.loadLibraryFromJar("/native/osx/x64/libbwt_jni.dylib");
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
NativeUtils.loadLibraryFromJar("/native/windows/x64/bwt_jni.dll");
} else if(osArch.equals("aarch64")) {
NativeUtils.loadLibraryFromJar("/native/linux/aarch64/libbwt_jni.so");
} else {
NativeUtils.loadLibraryFromJar("/native/linux/x64/libbwt_jni.so");
}
@@ -143,8 +145,8 @@ public class Bwt {
bwtConfig.electrumSkipMerkle = true;
Config config = Config.get();
bwtConfig.bitcoindUrl = config.getCoreServer();
if(bwtConfig.bitcoindUrl != null) {
if(config.getCoreServer() != null) {
bwtConfig.bitcoindUrl = config.getCoreServer().getUrl();
try {
HostAndPort hostAndPort = Protocol.HTTP.getServerHostAndPort(bwtConfig.bitcoindUrl);
if(hostAndPort.getHost().endsWith(".local")) {
@@ -308,7 +310,7 @@ public class Bwt {
Bwt.this.shutdown();
terminating = false;
} else {
Platform.runLater(() -> EventManager.get().post(new BwtBootStatusEvent("Connecting to Bitcoin Core node at " + Config.get().getCoreServer() + "...")));
Platform.runLater(() -> EventManager.get().post(new BwtBootStatusEvent("Connecting to Bitcoin Core node " + Config.get().getServerDisplayName() + "...")));
}
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.net;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.KeyPurpose;
@@ -15,6 +14,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.paynym.PayNym;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
@@ -51,7 +51,7 @@ public class ElectrumServer {
private static final Map<String, List<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static String previousServerAddress;
private static Server previousServer;
private static Map<String, String> retrievedScriptHashes = Collections.synchronizedMap(new HashMap<>());
@@ -59,14 +59,14 @@ public class ElectrumServer {
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
private static String bwtElectrumServer;
private static Server bwtElectrumServer;
private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed:[^\"]*)\".*");
private static synchronized CloseableTransport getTransport() throws ServerException {
if(transport == null) {
try {
String electrumServer = null;
Server electrumServer = null;
File electrumServerCert = null;
String proxyServer = null;
@@ -78,8 +78,8 @@ public class ElectrumServer {
throw new ServerConfigException("Could not connect to Bitcoin Core RPC");
}
electrumServer = bwtElectrumServer;
if(previousServerAddress != null && previousServerAddress.contains(Bwt.ELECTRUM_HOST)) {
previousServerAddress = bwtElectrumServer;
if(previousServer != null && previousServer.getUrl().contains(Bwt.ELECTRUM_HOST)) {
previousServer = bwtElectrumServer;
}
} else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
electrumServer = Config.get().getElectrumServer();
@@ -95,33 +95,30 @@ public class ElectrumServer {
throw new ServerConfigException("Electrum server certificate file not found");
}
Protocol protocol = Protocol.getProtocol(electrumServer);
if(protocol == null) {
throw new ServerConfigException("Electrum server URL must start with " + Protocol.TCP.toUrlString() + " or " + Protocol.SSL.toUrlString());
}
Protocol protocol = electrumServer.getProtocol();
//If changing server, don't rely on previous transaction history
if(previousServerAddress != null && !electrumServer.equals(previousServerAddress)) {
if(previousServer != null && !electrumServer.equals(previousServer)) {
retrievedScriptHashes.clear();
retrievedTransactions.clear();
}
previousServerAddress = electrumServer;
previousServer = electrumServer;
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
boolean localNetworkAddress = !protocol.isOnionAddress(server) && IpAddressMatcher.isLocalNetworkAddress(server.getHost());
HostAndPort hostAndPort = electrumServer.getHostAndPort();
boolean localNetworkAddress = !protocol.isOnionAddress(hostAndPort) && IpAddressMatcher.isLocalNetworkAddress(hostAndPort.getHost());
if(!localNetworkAddress && Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) {
HostAndPort proxy = HostAndPort.fromString(proxyServer);
if(electrumServerCert != null) {
transport = protocol.getTransport(server, electrumServerCert, proxy);
transport = protocol.getTransport(hostAndPort, electrumServerCert, proxy);
} else {
transport = protocol.getTransport(server, proxy);
transport = protocol.getTransport(hostAndPort, proxy);
}
} else {
if(electrumServerCert != null) {
transport = protocol.getTransport(server, electrumServerCert);
transport = protocol.getTransport(hostAndPort, electrumServerCert);
} else {
transport = protocol.getTransport(server);
transport = protocol.getTransport(hostAndPort);
}
}
} catch (Exception e) {
@@ -364,7 +361,7 @@ public class ElectrumServer {
}
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
int highestIndex = nodeTransactionMap.keySet().stream().map(WalletNode::getIndex).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
int highestIndex = nodeTransactionMap.keySet().stream().filter(node -> node.getDerivation().size() > 1).map(WalletNode::getIndex).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
return highestIndex + wallet.getGapLimit() + 1;
}
@@ -1251,7 +1248,7 @@ public class ElectrumServer {
@Subscribe
public void bwtElectrumReadyStatus(BwtElectrumReadyStatusEvent event) {
if(this.isRunning()) {
ElectrumServer.bwtElectrumServer = Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr()));
ElectrumServer.bwtElectrumServer = new Server(Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr())));
}
}
@@ -1637,6 +1634,10 @@ public class ElectrumServer {
}
private List<StandardAccount> getStandardAccounts(Wallet wallet) {
if(!wallet.getKeystores().stream().allMatch(Keystore::hasMasterPrivateKey)) {
return Collections.emptyList();
}
List<StandardAccount> accounts = new ArrayList<>();
for(StandardAccount account : StandardAccount.values()) {
if(account != StandardAccount.ACCOUNT_0 && (!StandardAccount.WHIRLPOOL_ACCOUNTS.contains(account) || wallet.getScriptType() == ScriptType.P2WPKH)) {
@@ -52,6 +52,10 @@ public enum ExchangeSource {
String url = "https://api.coinbase.com/v2/exchange-rates?currency=BTC";
Proxy proxy = AppServices.getProxy();
if(log.isInfoEnabled()) {
log.info("Requesting exchange rates from " + url);
}
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
return gson.fromJson(reader, CoinbaseRates.class);
@@ -87,6 +91,10 @@ public enum ExchangeSource {
String url = "https://api.coingecko.com/api/v3/exchange_rates";
Proxy proxy = AppServices.getProxy();
if(log.isInfoEnabled()) {
log.info("Requesting exchange rates from " + url);
}
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
return gson.fromJson(reader, CoinGeckoRates.class);
@@ -68,6 +68,10 @@ public enum FeeRatesSource {
private static Map<Integer, Double> getThreeTierFeeRates(Map<Integer, Double> defaultblockTargetFeeRates, String url) {
Proxy proxy = AppServices.getProxy();
if(log.isInfoEnabled()) {
log.info("Requesting fee rates from " + url);
}
Map<Integer, Double> blockTargetFeeRates = new LinkedHashMap<>();
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
@@ -97,7 +97,7 @@ public final class IpAddressMatcher {
return InetAddress.getByName(address);
}
catch (UnknownHostException e) {
throw new IllegalArgumentException("Failed to parse address" + address, e);
throw new IllegalArgumentException("Failed to parse address: " + address, e);
}
}
@@ -103,6 +103,10 @@ public class LnurlAuth {
public void sendResponse(Wallet wallet) throws LnurlAuthException, IOException {
URL callback = getReturnURL(wallet);
if(log.isInfoEnabled()) {
log.info("Sending LNURL-auth response to " + callback);
}
Proxy proxy = AppServices.getProxy();
if(proxy == null && callback.getHost().toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX)) {
throw new LnurlAuthException("A Tor proxy must be configured to authenticate this resource.");
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.net;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.io.Server;
import java.io.File;
import java.io.IOException;
@@ -117,8 +117,20 @@ public enum Protocol {
return toUrlString() + hostAndPort.toString();
}
public static boolean isOnionHost(String host) {
return host != null && host.toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX);
}
public static boolean isOnionAddress(Server server) {
if(server != null) {
return isOnionAddress(server.getHostAndPort());
}
return false;
}
public static boolean isOnionAddress(HostAndPort server) {
return server.getHost().toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX);
return isOnionHost(server.getHost());
}
public static boolean isOnionAddress(String address) {
@@ -33,12 +33,12 @@ public class ProxySocketFactory extends SocketFactory {
@Override
public Socket createSocket(String address, int port) throws IOException {
return createSocket(new InetSocketAddress(address, port), null);
return createSocket(createAddress(address, port), null);
}
@Override
public Socket createSocket(String address, int port, InetAddress localAddress, int localPort) throws IOException {
return createSocket(new InetSocketAddress(address, port), new InetSocketAddress(localAddress, localPort));
return createSocket(createAddress(address, port), new InetSocketAddress(localAddress, localPort));
}
@Override
@@ -51,6 +51,14 @@ public class ProxySocketFactory extends SocketFactory {
return createSocket(new InetSocketAddress(address, port), new InetSocketAddress(localAddress, localPort));
}
private InetSocketAddress createAddress(String address, int port) {
if(Protocol.isOnionHost(address)) {
return InetSocketAddress.createUnresolved(address, port);
} else {
return new InetSocketAddress(address, port);
}
}
private Socket createSocket(InetSocketAddress address, InetSocketAddress bindAddress) throws IOException {
Socket socket = new Socket(proxy);
if(bindAddress != null) {
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.io.Server;
import java.util.Arrays;
import java.util.List;
@@ -15,23 +16,21 @@ public enum PublicElectrumServer {
TESTNET_ARANGUREN_ORG("testnet.aranguren.org", "ssl://testnet.aranguren.org:51002", Network.TESTNET);
PublicElectrumServer(String name, String url, Network network) {
this.name = name;
this.url = url;
this.server = new Server(url, name);
this.network = network;
}
public static final List<Network> SUPPORTED_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
private final String name;
private final String url;
private final Server server;
private final Network network;
public String getName() {
return name;
public Server getServer() {
return server;
}
public String getUrl() {
return url;
return server.getUrl();
}
public Network getNetwork() {
@@ -46,10 +45,10 @@ public enum PublicElectrumServer {
return SUPPORTED_NETWORKS.contains(Network.get());
}
public static PublicElectrumServer fromUrl(String url) {
for(PublicElectrumServer server : values()) {
if(server.url.equals(url)) {
return server;
public static PublicElectrumServer fromServer(Server server) {
for(PublicElectrumServer publicServer : values()) {
if(publicServer.getServer().equals(server)) {
return publicServer;
}
}
@@ -58,6 +57,6 @@ public enum PublicElectrumServer {
@Override
public String toString() {
return name;
return server.getAlias();
}
}
@@ -111,7 +111,8 @@ public class TcpOverTlsTransport extends TcpTransport {
protected boolean shouldSaveCertificate() {
//Avoid saving the certificates for blockstream.info public servers - they change too often and encourage approval complacency
if(PublicElectrumServer.BLOCKSTREAM_INFO.getName().equals(server.getHost()) || PublicElectrumServer.ELECTRUM_BLOCKSTREAM_INFO.getName().equals(server.getHost())) {
if(PublicElectrumServer.BLOCKSTREAM_INFO.getServer().getHost().equals(server.getHost())
|| PublicElectrumServer.ELECTRUM_BLOCKSTREAM_INFO.getServer().getHost().equals(server.getHost())) {
return false;
}
@@ -96,6 +96,14 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
}
private void writeRequest(String request) throws IOException {
if(log.isTraceEnabled()) {
log.trace("Sending to electrum server at " + server + ": " + request);
}
if(socket == null) {
throw new IllegalStateException("Socket connection has not been established.");
}
PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())));
out.println(request);
out.flush();
@@ -203,7 +211,7 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
String response = readLine(in);
if(response == null) {
throw new IOException("Could not connect to server at " + Config.get().getServerAddress());
throw new IOException("Could not connect to server" + (Config.get().hasServer() ? " at " + Config.get().getServer().getUrl() : ""));
}
return response;
@@ -6,7 +6,7 @@ import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.MainApp;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.VersionUpdatedEvent;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
@@ -47,6 +47,11 @@ public class VersionCheckService extends ScheduledService<VersionUpdatedEvent> {
private VersionCheck getVersionCheck() throws IOException {
URL url = new URL(VERSION_CHECK_URL);
Proxy proxy = AppServices.getProxy();
if(log.isInfoEnabled()) {
log.info("Requesting application version check from " + url);
}
HttpsURLConnection conn = (HttpsURLConnection)(proxy == null ? url.openConnection() : url.openConnection(proxy));
try(InputStreamReader reader = new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)) {
@@ -86,10 +91,10 @@ public class VersionCheckService extends ScheduledService<VersionUpdatedEvent> {
private boolean isNewer(VersionCheck versionCheck) {
try {
Version versionCheckVersion = new Version(versionCheck.version);
Version currentVersion = new Version(MainApp.APP_VERSION);
Version currentVersion = new Version(SparrowWallet.APP_VERSION);
return versionCheckVersion.compareTo(currentVersion) > 0;
} catch(IllegalArgumentException e) {
log.error("Invalid versions to compare: " + versionCheck.version + " to " + MainApp.APP_VERSION, e);
log.error("Invalid versions to compare: " + versionCheck.version + " to " + SparrowWallet.APP_VERSION, e);
}
return false;
@@ -15,11 +15,7 @@ import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.net.ServerException;
import com.sparrowwallet.sparrow.wallet.SendController;
import com.sparrowwallet.sparrow.net.Protocol;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
@@ -84,6 +80,10 @@ public class Payjoin {
Proxy proxy = AppServices.getProxy();
if(proxy == null && Protocol.isOnionHost(finalUri.getHost())) {
throw new PayjoinReceiverException("Configure a Tor proxy to get a payjoin transaction from " + finalUri.getHost() + ".");
}
HttpURLConnection connection = proxy == null ? (HttpURLConnection)finalUri.toURL().openConnection() : (HttpURLConnection)finalUri.toURL().openConnection(proxy);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "text/plain");
@@ -117,6 +117,9 @@ public class Payjoin {
} catch(URISyntaxException e) {
log.error("Invalid payjoin receiver URI", e);
throw new PayjoinReceiverException("Invalid payjoin receiver URI", e);
} catch(FileNotFoundException e) {
log.error("Could not find resource at payjoin URL " + uri, e);
throw new PayjoinReceiverException("Could not find resource at payjoin URL " + uri, e);
} catch(IOException e) {
log.error("Payjoin receiver error", e);
throw new PayjoinReceiverException("Payjoin receiver error", e);
@@ -229,7 +229,7 @@ public class PayNymController {
followingList.setItems(FXCollections.observableList(new ArrayList<>()));
findPayNym.setVisible(true);
AppServices.getPayNymService().getPayNym(nymIdentifier).subscribe(searchedPayNym -> {
AppServices.getPayNymService().getPayNym(nymIdentifier, true).subscribe(searchedPayNym -> {
findPayNym.setVisible(false);
List<PayNym> searchList = new ArrayList<>();
searchList.add(searchedPayNym);
@@ -51,8 +51,13 @@ public class PayNymService {
HashMap<String, Object> body = new HashMap<>();
body.put("code", paymentCode.toString());
String url = getHostUrl() + "/api/v1/create";
if(log.isInfoEnabled()) {
log.info("Creating PayNym using " + url);
}
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(getHostUrl() + "/api/v1/create", Map.class, headers, body)
return httpClient.postJson(url, Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -69,8 +74,13 @@ public class PayNymService {
HashMap<String, Object> body = new HashMap<>();
body.put("code", paymentCode.toString());
String url = getHostUrl() + "/api/v1/token";
if(log.isInfoEnabled()) {
log.info("Updating PayNym token using " + url);
}
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(getHostUrl() + "/api/v1/token", Map.class, headers, body)
return httpClient.postJson(url, Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -114,8 +124,13 @@ public class PayNymService {
HashMap<String, Object> body = new HashMap<>();
body.put("signature", signature);
String url = getHostUrl() + "/api/v1/claim";
if(log.isInfoEnabled()) {
log.info("Claiming PayNym using " + url);
}
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(getHostUrl() + "/api/v1/claim", Map.class, headers, body)
return httpClient.postJson(url, Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -139,8 +154,13 @@ public class PayNymService {
body.put("code", strPaymentCode);
body.put("signature", signature);
String url = getHostUrl() + "/api/v1/nym/add";
if(log.isInfoEnabled()) {
log.info("Adding payment code to PayNym using " + url);
}
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(getHostUrl() + "/api/v1/nym/add", Map.class, headers, body)
return httpClient.postJson(url, Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -159,32 +179,50 @@ public class PayNymService {
body.put("signature", signature);
body.put("target", paymentCode.toString());
String url = getHostUrl() + "/api/v1/follow";
if(log.isInfoEnabled()) {
log.info("Following payment code using " + url);
}
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(getHostUrl() + "/api/v1/follow", Map.class, headers, body)
return httpClient.postJson(url, Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> fetchPayNym(String nymIdentifier) {
public Observable<Map<String, Object>> fetchPayNym(String nymIdentifier, boolean compact) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("nym", nymIdentifier);
String url = getHostUrl() + "/api/v1/nym" + (compact ? "?compact=true" : "");
if(log.isInfoEnabled()) {
log.info("Fetching PayNym using " + url);
}
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(getHostUrl() + "/api/v1/nym", Map.class, headers, body)
return httpClient.postJson(url, Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<PayNym> getPayNym(String nymIdentifier) {
return fetchPayNym(nymIdentifier).map(nymMap -> {
return getPayNym(nymIdentifier, false);
}
public Observable<PayNym> getPayNym(String nymIdentifier, boolean compact) {
return fetchPayNym(nymIdentifier, compact).map(nymMap -> {
List<Map<String, Object>> codes = (List<Map<String, Object>>)nymMap.get("codes");
PaymentCode code = new PaymentCode((String)codes.stream().filter(codeMap -> codeMap.get("segwit") == Boolean.FALSE).map(codeMap -> codeMap.get("code")).findFirst().orElse(codes.get(0).get("code")));
if(compact) {
return new PayNym(code, (String)nymMap.get("nymID"), (String)nymMap.get("nymName"), (Boolean)nymMap.get("segwit"), Collections.emptyList(), Collections.emptyList());
}
List<Map<String, Object>> followingMaps = (List<Map<String, Object>>)nymMap.get("following");
List<PayNym> following = followingMaps.stream().map(followingMap -> {
return PayNym.fromString((String)followingMap.get("code"), (String)followingMap.get("nymId"), (String)followingMap.get("nymName"), (Boolean)followingMap.get("segwit"), Collections.emptyList(), Collections.emptyList());
@@ -0,0 +1,214 @@
package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.event.Event;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.util.converter.DefaultStringConverter;
import org.controlsfx.glyphfont.Glyph;
import java.lang.reflect.Field;
import java.util.List;
import java.util.stream.Collectors;
public class ServerAliasDialog extends Dialog<Server> {
private final ServerType serverType;
private final TableView<ServerEntry> serverTable;
public ServerAliasDialog(ServerType serverType) {
this.serverType = serverType;
final DialogPane dialogPane = new ServerAliasDialogPane();
setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Server Aliases");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.setHeaderText("Configure aliases for recently connected servers.\nNew servers are added to this list on successful connections.");
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
serverTable = new TableView<>();
serverTable.setPlaceholder(new Label("No servers added yet"));
TableColumn<ServerEntry, String> urlColumn = new TableColumn<>("URL");
urlColumn.setMinWidth(300);
urlColumn.setCellValueFactory((TableColumn.CellDataFeatures<ServerEntry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getUrl());
});
TableColumn<ServerEntry, String> aliasColumn = new TableColumn<>("Alias");
aliasColumn.setCellValueFactory((TableColumn.CellDataFeatures<ServerEntry, String> param) -> {
return param.getValue().labelProperty();
});
aliasColumn.setCellFactory(value -> new AliasCell());
aliasColumn.setGraphic(getTagGlyph());
serverTable.getColumns().add(urlColumn);
serverTable.getColumns().add(aliasColumn);
serverTable.setEditable(true);
serverTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
List<Server> servers = serverType == ServerType.BITCOIN_CORE ? Config.get().getRecentCoreServers() : Config.get().getRecentElectrumServers();
List<ServerEntry> serverEntries = servers.stream().map(server -> new ServerEntry(serverType, server)).collect(Collectors.toList());
serverTable.setItems(FXCollections.observableArrayList(serverEntries));
StackPane stackPane = new StackPane();
stackPane.getChildren().add(serverTable);
dialogPane.setContent(stackPane);
ButtonType selectButtonType = new ButtonType("Select", ButtonBar.ButtonData.APPLY);
ButtonType deleteButtonType = new ButtonType("Delete", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().addAll(deleteButtonType, ButtonType.CLOSE, selectButtonType);
Button selectButton = (Button)dialogPane.lookupButton(selectButtonType);
Button deleteButton = (Button)dialogPane.lookupButton(deleteButtonType);
selectButton.setDefaultButton(true);
selectButton.setDisable(true);
deleteButton.setDisable(true);
serverTable.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
selectButton.setDisable(newValue == null);
deleteButton.setDisable(newValue == null);
});
setResultConverter(buttonType -> buttonType == selectButtonType ? serverTable.getSelectionModel().getSelectedItem().getServer() : null);
dialogPane.setPrefWidth(680);
dialogPane.setPrefHeight(500);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
private static Glyph getTagGlyph() {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TAG);
glyph.setFontSize(12);
return glyph;
}
private class ServerAliasDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button deleteButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(deleteButton, buttonData);
deleteButton.setOnAction(event -> {
ServerEntry serverEntry = serverTable.getSelectionModel().getSelectedItem();
if(serverEntry != null) {
serverTable.getItems().remove(serverEntry);
if(serverType == ServerType.BITCOIN_CORE) {
Config.get().removeRecentCoreServer(serverEntry.getServer());
} else {
Config.get().removeRecentElectrumServer(serverEntry.getServer());
}
}
});
button = deleteButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
}
private static class ServerEntry {
private final Server server;
private final StringProperty labelProperty = new SimpleStringProperty();
public ServerEntry(ServerType serverType, Server server) {
this.server = server;
labelProperty.set(server.getAlias());
labelProperty.addListener((observable, oldValue, newValue) -> {
String alias = newValue;
if(alias != null && alias.isEmpty()) {
alias = null;
}
Server newServer = new Server(server.getUrl(), alias);
if(serverType == ServerType.BITCOIN_CORE) {
Config.get().setCoreServerAlias(newServer);
} else {
Config.get().setElectrumServerAlias(newServer);
}
});
}
public String getUrl() {
return server.getHostAndPort().toString();
}
public StringProperty labelProperty() {
return labelProperty;
}
public Server getServer() {
return new Server(server.getUrl(), labelProperty.get().isEmpty() ? null : labelProperty.get());
}
}
private static class AliasCell extends TextFieldTableCell<ServerEntry, String> {
public AliasCell() {
super(new DefaultStringConverter());
}
@Override
public void commitEdit(String label) {
if(label != null) {
label = label.trim();
}
// This block is necessary to support commit on losing focus, because
// the baked-in mechanism sets our editing state to false before we can
// intercept the loss of focus. The default commitEdit(...) method
// simply bails if we are not editing...
if (!isEditing() && !label.equals(getItem())) {
TableView<ServerEntry> table = getTableView();
if(table != null) {
TableColumn<ServerEntry, String> column = getTableColumn();
TableColumn.CellEditEvent<ServerEntry, String> event = new TableColumn.CellEditEvent<>(
table, new TablePosition<>(table, getIndex(), column),
TableColumn.editCommitEvent(), label
);
Event.fireEvent(column, event);
}
}
super.commitEdit(label);
}
@Override
public void startEdit() {
super.startEdit();
try {
Field f = getClass().getSuperclass().getDeclaredField("textField");
f.setAccessible(true);
TextField textField = (TextField)f.get(this);
textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
if (!isNowFocused) {
commitEdit(getConverter().fromString(textField.getText()));
setText(getConverter().fromString(textField.getText()));
}
});
} catch (Exception e) {
//ignore
}
}
}
}
@@ -12,11 +12,13 @@ import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.text.Font;
@@ -51,6 +53,8 @@ import java.util.Random;
public class ServerPreferencesController extends PreferencesDetailController {
private static final Logger log = LoggerFactory.getLogger(ServerPreferencesController.class);
private static final Server MANAGE_ALIASES_SERVER = new Server("tcp://localhost", "Manage Aliases...");
@FXML
private ToggleGroup serverTypeToggleGroup;
@@ -79,7 +83,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
private Form coreForm;
@FXML
private ComboBox<String> recentCoreServers;
private ComboBox<Server> recentCoreServers;
@FXML
private ComboBoxTextField coreHost;
@@ -121,7 +125,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
private Form electrumForm;
@FXML
private ComboBox<String> recentElectrumServers;
private ComboBox<Server> recentElectrumServers;
@FXML
private ComboBoxTextField electrumHost;
@@ -267,26 +271,52 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
});
recentCoreServers.setConverter(new UrlHostConverter());
recentCoreServers.setItems(FXCollections.observableList(Config.get().getRecentCoreServers() == null ? new ArrayList<>() : Config.get().getRecentCoreServers()));
recentCoreServers.setCellFactory(value -> new ServerCell());
recentCoreServers.setItems(getObservableServerList(Config.get().getRecentCoreServers()));
recentCoreServers.prefWidthProperty().bind(coreHost.widthProperty());
recentCoreServers.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null && Protocol.getProtocol(newValue) != null) {
HostAndPort hostAndPort = Protocol.getProtocol(newValue).getServerHostAndPort(newValue);
coreHost.setText(hostAndPort.getHost());
corePort.setText(Integer.toString(hostAndPort.getPort()));
if(newValue != null) {
if(newValue == MANAGE_ALIASES_SERVER) {
ServerAliasDialog serverAliasDialog = new ServerAliasDialog(ServerType.BITCOIN_CORE);
Optional<Server> optServer = serverAliasDialog.showAndWait();
recentCoreServers.setItems(getObservableServerList(Config.get().getRecentCoreServers()));
Server selectedServer = optServer.orElseGet(() -> Config.get().getCoreServer());
Platform.runLater(() -> recentCoreServers.setValue(selectedServer));
} else if(newValue.getHostAndPort() != null) {
HostAndPort hostAndPort = newValue.getHostAndPort();
corePort.setText(hostAndPort.hasPort() ? Integer.toString(hostAndPort.getPort()) : "");
if(newValue.getAlias() != null) {
coreHost.setText(newValue.getAlias());
} else {
coreHost.setText(hostAndPort.getHost());
}
coreHost.positionCaret(coreHost.getText().length());
}
}
});
recentElectrumServers.setConverter(new UrlHostConverter());
recentElectrumServers.setItems(FXCollections.observableList(Config.get().getRecentElectrumServers() == null ? new ArrayList<>() : Config.get().getRecentElectrumServers()));
recentElectrumServers.setCellFactory(value -> new ServerCell());
recentElectrumServers.setItems(getObservableServerList(Config.get().getRecentElectrumServers()));
recentElectrumServers.prefWidthProperty().bind(electrumHost.widthProperty());
recentElectrumServers.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null && Protocol.getProtocol(newValue) != null) {
HostAndPort hostAndPort = Protocol.getProtocol(newValue).getServerHostAndPort(newValue);
electrumHost.setText(hostAndPort.getHost());
electrumPort.setText(Integer.toString(hostAndPort.getPort()));
electrumUseSsl.setSelected(Protocol.getProtocol(newValue) == Protocol.SSL);
if(newValue != null) {
if(newValue == MANAGE_ALIASES_SERVER) {
ServerAliasDialog serverAliasDialog = new ServerAliasDialog(ServerType.ELECTRUM_SERVER);
Optional<Server> optServer = serverAliasDialog.showAndWait();
recentElectrumServers.setItems(getObservableServerList(Config.get().getRecentElectrumServers()));
Server selectedServer = optServer.orElseGet(() -> Config.get().getElectrumServer());
Platform.runLater(() -> recentElectrumServers.setValue(selectedServer));
} else if(newValue.getHostAndPort() != null) {
HostAndPort hostAndPort = newValue.getHostAndPort();
electrumPort.setText(hostAndPort.hasPort() ? Integer.toString(hostAndPort.getPort()) : "");
electrumUseSsl.setSelected(newValue.getProtocol() == Protocol.SSL);
if(newValue.getAlias() != null) {
electrumHost.setText(newValue.getAlias());
} else {
electrumHost.setText(hostAndPort.getHost());
}
electrumHost.positionCaret(electrumHost.getText().length());
}
}
});
@@ -339,7 +369,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
setTestResultsFont();
testConnection.setOnAction(event -> {
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
testResults.setText("Connecting to " + config.getServerAddress() + "...");
testResults.setText("Connecting " + (config.hasServer() ? "to " + config.getServer().getUrl() : "") + "...");
if(Config.get().requiresInternalTor() && Tor.getDefault() == null) {
startTor();
@@ -358,7 +388,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
testConnection.setVisible(true);
});
PublicElectrumServer configPublicElectrumServer = PublicElectrumServer.fromUrl(config.getPublicElectrumServer());
PublicElectrumServer configPublicElectrumServer = PublicElectrumServer.fromServer(config.getPublicElectrumServer());
if(configPublicElectrumServer == null && PublicElectrumServer.supportedNetwork()) {
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
if(!servers.isEmpty()) {
@@ -368,16 +398,16 @@ public class ServerPreferencesController extends PreferencesDetailController {
publicElectrumServer.setValue(configPublicElectrumServer);
}
String coreServer = config.getCoreServer();
Server coreServer = config.getCoreServer();
if(coreServer != null) {
Protocol protocol = Protocol.getProtocol(coreServer);
if(protocol != null) {
HostAndPort server = protocol.getServerHostAndPort(coreServer);
coreHost.setText(server.getHost());
if(server.hasPort()) {
corePort.setText(Integer.toString(server.getPort()));
}
HostAndPort hostAndPort = coreServer.getHostAndPort();
Server server = config.getRecentCoreServers().stream().filter(coreServer::equals).findFirst().orElse(null);
if(server != null) {
coreHost.setLeft(getGlyph(FontAwesome5.Glyph.TAG, null));
}
coreHost.setText(server == null || server.getAlias() == null ? hostAndPort.getHost() : server.getAlias());
if(hostAndPort.hasPort()) {
corePort.setText(Integer.toString(hostAndPort.getPort()));
}
} else {
coreHost.setText("127.0.0.1");
@@ -396,21 +426,22 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
}
String electrumServer = config.getElectrumServer();
Server electrumServer = config.getElectrumServer();
if(electrumServer != null) {
Protocol protocol = Protocol.getProtocol(electrumServer);
Protocol protocol = electrumServer.getProtocol();
boolean ssl = protocol.equals(Protocol.SSL);
electrumUseSsl.setSelected(ssl);
electrumCertificate.setDisable(!ssl);
electrumCertificateSelect.setDisable(!ssl);
if(protocol != null) {
boolean ssl = protocol.equals(Protocol.SSL);
electrumUseSsl.setSelected(ssl);
electrumCertificate.setDisable(!ssl);
electrumCertificateSelect.setDisable(!ssl);
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
electrumHost.setText(server.getHost());
if(server.hasPort()) {
electrumPort.setText(Integer.toString(server.getPort()));
}
HostAndPort hostAndPort = electrumServer.getHostAndPort();
Server server = config.getRecentElectrumServers().stream().filter(electrumServer::equals).findFirst().orElse(null);
if(server != null) {
electrumHost.setLeft(getGlyph(FontAwesome5.Glyph.TAG, null));
}
electrumHost.setText(server == null || server.getAlias() == null ? hostAndPort.getHost() : server.getAlias());
if(hostAndPort.hasPort()) {
electrumPort.setText(Integer.toString(hostAndPort.getPort()));
}
}
@@ -449,7 +480,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
torService.setOnSucceeded(workerStateEvent -> {
Tor.setDefault(torService.getValue());
torService.cancel();
testResults.appendText("\nTor running, connecting to " + Config.get().getServerAddress() + "...");
testResults.appendText("\nTor running, connecting to " + Config.get().getServer().getUrl() + "...");
startElectrumConnection();
});
torService.setOnFailed(workerStateEvent -> {
@@ -495,6 +526,13 @@ public class ServerPreferencesController extends PreferencesDetailController {
Config.get().setMode(Mode.ONLINE);
connectionService.cancel();
useProxyOriginal = null;
if(Config.get().addRecentServer()) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
recentCoreServers.setItems(getObservableServerList(Config.get().getRecentCoreServers()));
} else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
recentElectrumServers.setItems(getObservableServerList(Config.get().getRecentElectrumServers()));
}
}
});
connectionService.setOnFailed(workerStateEvent -> {
EventManager.get().unregister(connectionService);
@@ -668,24 +706,32 @@ public class ServerPreferencesController extends PreferencesDetailController {
@NotNull
private ChangeListener<PublicElectrumServer> getPublicElectrumServerListener(Config config) {
return (observable, oldValue, newValue) -> {
config.setPublicElectrumServer(newValue.getUrl());
config.setPublicElectrumServer(newValue.getServer());
};
}
@NotNull
private ChangeListener<String> getBitcoinCoreListener(Config config) {
return (observable, oldValue, newValue) -> {
Server existingServer = config.getRecentCoreServers().stream().filter(server -> coreHost.getText().equals(server.getAlias())).findFirst().orElse(null);
coreHost.setLeft(existingServer == null ? null : getGlyph(FontAwesome5.Glyph.TAG, null));
setCoreServerInConfig(config);
};
}
private void setCoreServerInConfig(Config config) {
Server existingServer = config.getRecentCoreServers().stream().filter(server -> coreHost.getText().equals(server.getAlias())).findFirst().orElse(null);
if(existingServer != null) {
config.setCoreServer(existingServer);
return;
}
String hostAsString = getHost(coreHost.getText());
Integer portAsInteger = getPort(corePort.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
config.setCoreServer(Protocol.HTTP.toUrlString(hostAsString, portAsInteger));
config.setCoreServer(new Server(Protocol.HTTP.toUrlString(hostAsString, portAsInteger)));
} else if(hostAsString != null) {
config.setCoreServer(Protocol.HTTP.toUrlString(hostAsString));
config.setCoreServer(new Server(Protocol.HTTP.toUrlString(hostAsString)));
}
}
@@ -699,17 +745,25 @@ public class ServerPreferencesController extends PreferencesDetailController {
@NotNull
private ChangeListener<String> getElectrumServerListener(Config config) {
return (observable, oldValue, newValue) -> {
Server existingServer = config.getRecentElectrumServers().stream().filter(server -> electrumHost.getText().equals(server.getAlias())).findFirst().orElse(null);
electrumHost.setLeft(existingServer == null ? null : getGlyph(FontAwesome5.Glyph.TAG, null));
setElectrumServerInConfig(config);
};
}
private void setElectrumServerInConfig(Config config) {
Server existingServer = config.getRecentElectrumServers().stream().filter(server -> electrumHost.getText().equals(server.getAlias())).findFirst().orElse(null);
if(existingServer != null) {
config.setElectrumServer(existingServer);
return;
}
String hostAsString = getHost(electrumHost.getText());
Integer portAsInteger = getPort(electrumPort.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
config.setElectrumServer(getProtocol().toUrlString(hostAsString, portAsInteger));
config.setElectrumServer(new Server(getProtocol().toUrlString(hostAsString, portAsInteger)));
} else if(hostAsString != null) {
config.setElectrumServer(getProtocol().toUrlString(hostAsString));
config.setElectrumServer(new Server(getProtocol().toUrlString(hostAsString)));
}
}
@@ -777,7 +831,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName, String styleClass) {
private static Glyph getGlyph(FontAwesome5.Glyph glyphName, String styleClass) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(12);
if(styleClass != null) {
@@ -813,6 +867,12 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
}
private ObservableList<Server> getObservableServerList(List<Server> servers) {
ObservableList<Server> serverObservableList = FXCollections.observableList(new ArrayList<>(servers));
serverObservableList.add(MANAGE_ALIASES_SERVER);
return serverObservableList;
}
@Subscribe
public void bwtStatus(BwtStatusEvent event) {
if(!(event instanceof BwtSyncStatusEvent)) {
@@ -844,15 +904,28 @@ public class ServerPreferencesController extends PreferencesDetailController {
});
}
private static class UrlHostConverter extends StringConverter<String> {
private static class ServerCell extends ListCell<Server> {
@Override
public String toString(String serverUrl) {
return serverUrl == null || Protocol.getProtocol(serverUrl) == null ? "" : Protocol.getProtocol(serverUrl).getServerHostAndPort(serverUrl).getHost();
}
protected void updateItem(Server server, boolean empty) {
super.updateItem(server, empty);
if(server == null || empty) {
setText("");
setGraphic(null);
} else {
String serverAlias = server.getAlias();
@Override
public String fromString(String string) {
return null;
if(server == MANAGE_ALIASES_SERVER) {
setText(serverAlias);
setStyle("-fx-font-style: italic");
setGraphic(null);
} else if(serverAlias != null) {
setText(serverAlias);
setGraphic(getGlyph(FontAwesome5.Glyph.TAG, null));
} else {
setText(server.getHost());
setGraphic(null);
}
}
}
}
}
@@ -247,6 +247,7 @@ public class CounterpartyController extends SorobanController {
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(responseMessage -> {
requestUserAttention();
if(accepted) {
startCounterpartyCollaboration(counterpartyCahootsWallet, paymentCodeInitiator, cahootsType);
followPaymentCode(paymentCodeInitiator);
@@ -254,10 +255,12 @@ public class CounterpartyController extends SorobanController {
}, error -> {
log.error("Error sending meeting response", error);
mixingPartner.setVisible(false);
requestUserAttention();
});
}, error -> {
log.error("Failed to receive meeting request", error);
mixingPartner.setVisible(false);
requestUserAttention();
});
} catch(Exception e) {
log.error("Error sending meeting response", e);
@@ -105,6 +105,9 @@ public class InitiatorController extends SorobanController {
@FXML
private Label step2Desc;
@FXML
private Hyperlink meetingFail;
@FXML
private ProgressBar sorobanProgressBar;
@@ -188,6 +191,17 @@ public class InitiatorController extends SorobanController {
broadcastSuccessful.managedProperty().bind(broadcastSuccessful.visibleProperty());
broadcastSuccessful.setVisible(false);
meetingFail.managedProperty().bind(meetingFail.visibleProperty());
meetingFail.setVisited(true);
meetingFail.setVisible(false);
meetingFail.setOnAction(event -> {
meetingFail.setVisible(false);
step2Desc.setText("Retrying...");
sorobanProgressLabel.setVisible(true);
startInitiatorMeetingRequest(AppServices.getSorobanServices().getSoroban(walletId), wallet);
step2Timer.start();
});
step2.setVisible(false);
step3.setVisible(false);
step4.setVisible(false);
@@ -409,6 +423,7 @@ public class InitiatorController extends SorobanController {
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanResponse -> {
requestUserAttention();
if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mix partner accepted!");
@@ -419,17 +434,17 @@ public class InitiatorController extends SorobanController {
}
}, error -> {
log.error("Error receiving meeting response", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length());
msg = msg.replace("#Cahoots", "mix transaction");
step2Desc.setText(msg);
step2Desc.setText(getErrorMessage(error));
sorobanProgressLabel.setVisible(false);
meetingFail.setVisible(true);
requestUserAttention();
});
}, error -> {
log.error("Error sending meeting request", error);
step2Desc.setText(error.getMessage());
step2Desc.setText(getErrorMessage(error));
sorobanProgressLabel.setVisible(false);
meetingFail.setVisible(true);
requestUserAttention();
});
} catch(Exception e) {
log.error("Error sending meeting request", e);
@@ -512,9 +527,7 @@ public class InitiatorController extends SorobanController {
},
error -> {
log.error("Error creating mix transaction", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length()));
step2Desc.setText(getErrorMessage(error));
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
@@ -667,6 +680,15 @@ public class InitiatorController extends SorobanController {
}
}
private static String getErrorMessage(Throwable error) {
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length());
msg = msg.replace("#Cahoots", "mix transaction");
msg = msg.endsWith(".") ? msg : msg + ".";
return msg;
}
public boolean isTransactionAccepted() {
return transactionAccepted.get() == Boolean.TRUE;
}
@@ -47,6 +47,7 @@ public class InitiatorDialog extends Dialog<Transaction> {
dialogPane.setPrefHeight(530);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/initiator.css").toExternalForm());
@@ -15,8 +15,8 @@ import com.sparrowwallet.sparrow.net.ElectrumServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.Taskbar;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class SorobanController {
@@ -120,4 +120,10 @@ public class SorobanController {
return feeAmt;
}
protected void requestUserAttention() {
if(Taskbar.isTaskbarSupported() && Taskbar.getTaskbar().isSupported(Taskbar.Feature.USER_ATTENTION)) {
Taskbar.getTaskbar().requestUserAttention(true, false);
}
}
}
@@ -0,0 +1,38 @@
package com.sparrowwallet.sparrow.terminal;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.graphics.ThemeDefinition;
import com.googlecode.lanterna.gui2.ComponentRenderer;
import com.googlecode.lanterna.gui2.ProgressBar;
import com.googlecode.lanterna.gui2.TextGUIGraphics;
public class BackgroundProgressBarRenderer implements ComponentRenderer<ProgressBar> {
@Override
public TerminalSize getPreferredSize(ProgressBar component) {
int preferredWidth = component.getPreferredWidth();
if(preferredWidth > 0) {
return new TerminalSize(preferredWidth, 1);
} else {
return new TerminalSize(10, 1);
}
}
@Override
public void drawComponent(TextGUIGraphics graphics, ProgressBar component) {
TerminalSize size = graphics.getSize();
if(size.getRows() == 0 || size.getColumns() == 0) {
return;
}
ThemeDefinition themeDefinition = component.getThemeDefinition();
int columnOfProgress = (int)(component.getProgress() * size.getColumns());
for(int row = 0; row < size.getRows(); row++) {
graphics.applyThemeStyle(themeDefinition.getActive());
for(int column = 0; column < size.getColumns(); column++) {
if(column < columnOfProgress) {
graphics.setCharacter(column, row, themeDefinition.getCharacter("FILLER", ' '));
}
}
}
}
}
@@ -0,0 +1,163 @@
package com.sparrowwallet.sparrow.terminal;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.gui2.ActionListBox;
import com.googlecode.lanterna.gui2.dialogs.ActionListDialogBuilder;
import com.googlecode.lanterna.gui2.dialogs.FileDialogBuilder;
import com.googlecode.lanterna.gui2.dialogs.TextInputDialogBuilder;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.terminal.preferences.GeneralDialog;
import com.sparrowwallet.sparrow.terminal.preferences.ServerStatusDialog;
import com.sparrowwallet.sparrow.terminal.preferences.ServerTypeDialog;
import com.sparrowwallet.sparrow.terminal.wallet.Bip39Dialog;
import com.sparrowwallet.sparrow.terminal.wallet.LoadWallet;
import com.sparrowwallet.sparrow.terminal.wallet.WatchOnlyDialog;
import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.Map;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class MasterActionListBox extends ActionListBox {
private static final Logger log = LoggerFactory.getLogger(MasterActionListBox.class);
public static final int MAX_RECENT_WALLETS = 6;
public MasterActionListBox(SparrowTerminal sparrowTerminal) {
super(new TerminalSize(14, 3));
addItem("Wallets", () -> {
ActionListDialogBuilder builder = new ActionListDialogBuilder();
builder.setTitle("Wallets");
if(Config.get().getRecentWalletFiles() != null) {
for(int i = 0; i < Config.get().getRecentWalletFiles().size() && i < MAX_RECENT_WALLETS; i++) {
File recentWalletFile = Config.get().getRecentWalletFiles().get(i);
if(!recentWalletFile.exists()) {
continue;
}
Storage storage = new Storage(recentWalletFile);
Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream()
.filter(entry -> entry.getValue().getWalletFile().equals(recentWalletFile)).map(Map.Entry::getKey)
.map(wallet -> wallet.isMasterWallet() ? wallet : wallet.getMasterWallet()).findFirst();
if(optWallet.isPresent()) {
Wallet wallet = optWallet.get();
Storage existingStorage = AppServices.get().getOpenWallets().get(wallet);
builder.addAction(storage.getWalletName(null) + "*", () -> openLoadedWallet(existingStorage, optWallet.get()));
} else {
builder.addAction(storage.getWalletName(null), new LoadWallet(storage));
}
}
}
builder.addAction("Open Wallet...", () -> {
SparrowTerminal.get().getGuiThread().invokeLater(MasterActionListBox::openWallet);
});
builder.addAction("Create Wallet...", () -> {
SparrowTerminal.get().getGuiThread().invokeLater(MasterActionListBox::createWallet);
});
builder.build().showDialog(SparrowTerminal.get().getGui());
});
addItem("Preferences", () -> {
new ActionListDialogBuilder()
.setTitle("Preferences")
.addAction("General", () -> {
GeneralDialog generalDialog = new GeneralDialog();
generalDialog.showDialog(sparrowTerminal.getGui());
})
.addAction("Server", () -> {
if(Config.get().hasServer()) {
ServerStatusDialog serverStatusDialog = new ServerStatusDialog();
serverStatusDialog.showDialog(sparrowTerminal.getGui());
} else {
ServerTypeDialog serverTypeDialog = new ServerTypeDialog();
serverTypeDialog.showDialog(sparrowTerminal.getGui());
}
})
.build()
.showDialog(sparrowTerminal.getGui());
});
addItem("Quit", () -> sparrowTerminal.getGui().getMainWindow().close());
}
private static void openLoadedWallet(Storage storage, Wallet wallet) {
if(SparrowTerminal.get().isLocked(storage)) {
String walletId = storage.getWalletId(wallet);
TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Wallet Password");
builder.setDescription("Enter the wallet password:");
builder.setPasswordInput(true);
String password = builder.build().showDialog(SparrowTerminal.get().getGui());
if(password != null) {
Platform.runLater(() -> {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, new SecureString(password), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
keyDerivationService.getValue().clear();
SparrowTerminal.get().unlockWallet(storage);
SparrowTerminal.get().getGuiThread().invokeLater(() -> LoadWallet.getOpeningDialog(storage, wallet).showDialog(SparrowTerminal.get().getGui()));
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
showErrorDialog("Invalid Password", "The wallet password was invalid.");
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
});
}
} else {
SparrowTerminal.get().getGuiThread().invokeLater(() -> LoadWallet.getOpeningDialog(storage, wallet).showDialog(SparrowTerminal.get().getGui()));
}
}
private static void openWallet() {
FileDialogBuilder openBuilder = new FileDialogBuilder().setTitle("Open Wallet");
openBuilder.setShowHiddenDirectories(true);
openBuilder.setSelectedFile(Storage.getWalletsDir());
File file = openBuilder.build().showDialog(SparrowTerminal.get().getGui());
if(file != null) {
LoadWallet loadWallet = new LoadWallet(new Storage(file));
SparrowTerminal.get().getGuiThread().invokeLater(loadWallet);
}
}
private static void createWallet() {
TextInputDialogBuilder newWalletNameBuilder = new TextInputDialogBuilder();
newWalletNameBuilder.setTitle("Create Wallet");
newWalletNameBuilder.setDescription("Enter a name for the wallet");
newWalletNameBuilder.setValidator(content -> content.isEmpty() ? "Please enter a name" : (Storage.walletExists(content) ? "Wallet already exists" : null));
String walletName = newWalletNameBuilder.build().showDialog(SparrowTerminal.get().getGui());
ActionListDialogBuilder newBuilder = new ActionListDialogBuilder();
newBuilder.setTitle("Create Wallet");
newBuilder.setDescription("Choose the type of wallet");
newBuilder.addAction("Software (BIP39)", () -> {
Bip39Dialog bip39Dialog = new Bip39Dialog(walletName);
bip39Dialog.showDialog(SparrowTerminal.get().getGui());
});
newBuilder.addAction("Watch Only", () -> {
WatchOnlyDialog watchOnlyDialog = new WatchOnlyDialog(walletName);
watchOnlyDialog.showDialog(SparrowTerminal.get().getGui());
});
newBuilder.build().showDialog(SparrowTerminal.get().getGui());
}
}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.terminal;
import com.googlecode.lanterna.gui2.BasicWindow;
import com.googlecode.lanterna.gui2.BorderLayout;
import com.googlecode.lanterna.gui2.Panel;
import java.util.List;
public class MasterActionWindow extends BasicWindow {
public MasterActionWindow(SparrowTerminal sparrowTerminal) {
setHints(List.of(Hint.CENTERED));
Panel panel = new Panel(new BorderLayout());
MasterActionListBox masterActionListBox = new MasterActionListBox(sparrowTerminal);
panel.addComponent(masterActionListBox, BorderLayout.Location.CENTER);
setComponent(panel);
}
}
@@ -0,0 +1,30 @@
package com.sparrowwallet.sparrow.terminal;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.gui2.EmptySpace;
import com.googlecode.lanterna.gui2.Label;
import com.googlecode.lanterna.gui2.LinearLayout;
import com.googlecode.lanterna.gui2.Panel;
import com.googlecode.lanterna.gui2.dialogs.DialogWindow;
import java.util.List;
public final class ModalDialog extends DialogWindow {
public ModalDialog(String walletName, String description) {
super(walletName);
setHints(List.of(Hint.CENTERED));
setFixedSize(new TerminalSize(30, 5));
Panel mainPanel = new Panel();
mainPanel.setLayoutManager(new LinearLayout());
mainPanel.addComponent(new EmptySpace(), LinearLayout.createLayoutData(LinearLayout.Alignment.Beginning, LinearLayout.GrowPolicy.CanGrow));
Label label = new Label(description);
mainPanel.addComponent(label, LinearLayout.createLayoutData(LinearLayout.Alignment.Center));
mainPanel.addComponent(new EmptySpace(), LinearLayout.createLayoutData(LinearLayout.Alignment.Beginning, LinearLayout.GrowPolicy.CanGrow));
setComponent(mainPanel);
}
}
@@ -0,0 +1,163 @@
package com.sparrowwallet.sparrow.terminal;
import com.googlecode.lanterna.TextColor;
import com.googlecode.lanterna.gui2.DefaultWindowManager;
import com.googlecode.lanterna.gui2.EmptySpace;
import com.googlecode.lanterna.gui2.TextGUIThread;
import com.googlecode.lanterna.screen.Screen;
import com.googlecode.lanterna.screen.TerminalScreen;
import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
import com.googlecode.lanterna.terminal.Terminal;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.event.WalletOpenedEvent;
import com.sparrowwallet.sparrow.event.WalletOpeningEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.terminal.wallet.WalletData;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.stage.Stage;
import javafx.stage.Window;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.terminal.MasterActionListBox.MAX_RECENT_WALLETS;
public class SparrowTerminal extends Application {
private static final Logger log = LoggerFactory.getLogger(SparrowTerminal.class);
private static SparrowTerminal sparrowTerminal;
private Terminal terminal;
private Screen screen;
private SparrowTextGui gui;
private final Map<String, WalletData> walletData = new HashMap<>();
private final Set<File> lockedWallets = new HashSet<>();
private static final javafx.stage.Window DEFAULT_WINDOW = new Window() { };
@Override
public void init() throws Exception {
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
log.error("Exception in thread \"" + t.getName() + "\"", e);
});
AppServices.initialize(this, new TerminalInteractionServices());
this.terminal = new DefaultTerminalFactory().createTerminal();
this.screen = new TerminalScreen(terminal);
this.gui = new SparrowTextGui(this, screen, new DefaultWindowManager(), new EmptySpace(TextColor.ANSI.BLUE));
EventManager.get().register(gui);
sparrowTerminal = this;
try {
getScreen().startScreen();
getGui().getMainWindow().waitUntilClosed();
} finally {
exit();
}
}
@Override
public void start(Stage primaryStage) throws Exception {
}
@Override
public void stop() throws Exception {
}
public Screen getScreen() {
return screen;
}
public SparrowTextGui getGui() {
return gui;
}
public TextGUIThread getGuiThread() {
return gui.getGUIThread();
}
public Map<String, WalletData> getWalletData() {
return walletData;
}
public void exit() {
try {
screen.stopScreen();
Platform.runLater(() -> {
AppServices.get().stop();
Platform.exit();
});
SparrowWallet.Instance instance = SparrowWallet.getSparrowInstance();
if(instance != null) {
instance.freeLock();
}
List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
if(recentWalletFiles != null && !recentWalletFiles.isEmpty()) {
Set<File> openedWalletFiles = new LinkedHashSet<>(recentWalletFiles);
openedWalletFiles.removeIf(file -> walletData.values().stream().noneMatch(data -> data.getWalletForm().getWalletFile().equals(file)));
openedWalletFiles.addAll(Config.get().getRecentWalletFiles().subList(0, Math.min(3, recentWalletFiles.size())));
Config.get().setRecentWalletFiles(new ArrayList<>(openedWalletFiles));
}
} catch(Exception e) {
log.error("Could not stop terminal screen", e);
}
}
public static SparrowTerminal get() {
return sparrowTerminal;
}
public static void addWallet(Storage storage, Wallet wallet) {
if(wallet.isNested()) {
WalletData walletData = SparrowTerminal.get().getWalletData().get(storage.getWalletId(wallet.getMasterWallet()));
WalletForm walletForm = new WalletForm(storage, wallet);
EventManager.get().register(walletForm);
walletData.getWalletForm().getNestedWalletForms().add(walletForm);
} else {
EventManager.get().post(new WalletOpeningEvent(storage, wallet));
WalletForm walletForm = new WalletForm(storage, wallet);
EventManager.get().register(walletForm);
SparrowTerminal.get().getWalletData().put(walletForm.getWalletId(), new WalletData(walletForm));
List<WalletTabData> walletTabDataList = SparrowTerminal.get().getWalletData().values().stream()
.map(data -> new WalletTabData(TabData.TabType.WALLET, data.getWalletForm())).collect(Collectors.toList());
EventManager.get().post(new OpenWalletsEvent(DEFAULT_WINDOW, walletTabDataList));
Set<File> walletFiles = new LinkedHashSet<>();
walletFiles.add(storage.getWalletFile());
if(Config.get().getRecentWalletFiles() != null) {
walletFiles.addAll(Config.get().getRecentWalletFiles().stream().limit(MAX_RECENT_WALLETS - 1).collect(Collectors.toList()));
}
Config.get().setRecentWalletFiles(Config.get().isLoadRecentWallets() ? new ArrayList<>(walletFiles) : Collections.emptyList());
}
EventManager.get().post(new WalletOpenedEvent(storage, wallet));
}
public boolean isLocked(Storage storage) {
return lockedWallets.contains(storage.getWalletFile());
}
public void lockWallet(Storage storage) {
lockedWallets.add(storage.getWalletFile());
}
public void unlockWallet(Storage storage) {
lockedWallets.remove(storage.getWalletFile());
}
}
@@ -0,0 +1,178 @@
package com.sparrowwallet.sparrow.terminal;
import com.google.common.eventbus.Subscribe;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.TextColor;
import com.googlecode.lanterna.gui2.*;
import com.googlecode.lanterna.screen.Screen;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.animation.*;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.util.Duration;
import java.util.Objects;
public class SparrowTextGui extends MultiWindowTextGUI {
private final BasicWindow mainWindow;
private final Label connectedLabel;
private final Label statusLabel;
private final ProgressBar statusProgress;
private PauseTransition wait;
private Timeline statusTimeline;
private final DoubleProperty progressProperty = new SimpleDoubleProperty();
public SparrowTextGui(SparrowTerminal sparrowTerminal, Screen screen, WindowManager windowManager, Component background) {
super(screen, windowManager, background);
this.mainWindow = new MasterActionWindow(sparrowTerminal);
addWindow(mainWindow);
Panel panel = new Panel(new BorderLayout());
Panel titleBar = new Panel(new GridLayout(2));
new Label("Sparrow Terminal").addTo(titleBar);
this.connectedLabel = new Label("Disconnected");
titleBar.addComponent(connectedLabel, GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, true, false));
panel.addComponent(titleBar, BorderLayout.Location.TOP);
panel.addComponent(new EmptySpace(TextColor.ANSI.BLUE));
Panel statusBar = new Panel(new GridLayout(2));
this.statusLabel = new Label("").addTo(statusBar);
this.statusProgress = new ProgressBar(0, 100, 10);
statusBar.addComponent(statusProgress, GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, true, false));
statusProgress.setRenderer(new BackgroundProgressBarRenderer());
statusProgress.setLabelFormat(null);
progressProperty.addListener((observable, oldValue, newValue) -> statusProgress.setValue((int) (newValue.doubleValue() * 100)));
panel.addComponent(statusBar, BorderLayout.Location.BOTTOM);
getBackgroundPane().setComponent(panel);
getMainWindow().addWindowListener(new WindowListenerAdapter() {
@Override
public void onResized(Window window, TerminalSize oldSize, TerminalSize newSize) {
if(!Objects.equals(oldSize, newSize)) {
titleBar.invalidate();
statusBar.invalidate();
}
}
});
AppServices.get().start();
}
public BasicWindow getMainWindow() {
return mainWindow;
}
private void setConnectedLabel(boolean connected) {
getGUIThread().invokeLater(() -> {
connectedLabel.setText(connected ? "Connected" : "Disconnected");
});
}
@Subscribe
public void connectionStart(ConnectionStartEvent event) {
statusUpdated(new StatusEvent(event.getStatus(), 120));
}
@Subscribe
public void connectionFailed(ConnectionFailedEvent event) {
setConnectedLabel(false);
statusUpdated(new StatusEvent("Connection failed: " + event.getMessage()));
}
@Subscribe
public void connection(ConnectionEvent event) {
setConnectedLabel(true);
statusUpdated(new StatusEvent("Connected to " + Config.get().getServerDisplayName() + " at height " + event.getBlockHeight()));
}
@Subscribe
public void disconnection(DisconnectionEvent event) {
if(!AppServices.isConnecting() && !AppServices.isConnected()) {
setConnectedLabel(false);
statusUpdated(new StatusEvent("Disconnected"));
}
}
@Subscribe
public void statusUpdated(StatusEvent event) {
getGUIThread().invokeLater(() -> statusLabel.setText(event.getStatus()));
if(wait != null && wait.getStatus() == Animation.Status.RUNNING) {
wait.stop();
}
wait = new PauseTransition(Duration.seconds(event.getShowDuration()));
wait.setOnFinished((e) -> {
if(statusLabel.getText().equals(event.getStatus())) {
getGUIThread().invokeLater(() -> statusLabel.setText(""));
}
});
wait.play();
}
@Subscribe
public void timedEvent(TimedEvent event) {
if(event.getTimeMills() == 0) {
getGUIThread().invokeLater(() -> {
statusLabel.setText("");
statusProgress.setValue(0);
});
} else if(event.getTimeMills() < 0) {
getGUIThread().invokeLater(() -> {
statusLabel.setText(event.getStatus());
statusProgress.setValue(0);
});
} else {
getGUIThread().invokeLater(() -> {
statusLabel.setText(event.getStatus());
});
statusTimeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(progressProperty, 0)),
new KeyFrame(Duration.millis(event.getTimeMills()), e -> {
getGUIThread().invokeLater(() -> {
statusLabel.setText("");
statusProgress.setValue(0);
});
}, new KeyValue(progressProperty, 1))
);
statusTimeline.setCycleCount(1);
statusTimeline.play();
}
}
@Subscribe
public void walletHistoryStarted(WalletHistoryStartedEvent event) {
statusUpdated(new StatusEvent("Loading wallet history..."));
}
@Subscribe
public void walletHistoryFinished(WalletHistoryFinishedEvent event) {
if(statusLabel.getText().equals("Loading wallet history...")) {
getGUIThread().invokeLater(() -> statusLabel.setText(""));
}
}
@Subscribe
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
walletHistoryFinished(new WalletHistoryFinishedEvent(event.getWallet()));
statusUpdated(new StatusEvent("Error retrieving wallet history" + (Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? ", trying another server..." : "")));
}
@Subscribe
public void childWalletsAdded(ChildWalletsAddedEvent event) {
if(!event.getChildWallets().isEmpty()) {
for(Wallet childWallet : event.getChildWallets()) {
SparrowTerminal.addWallet(event.getStorage(), childWallet);
}
}
}
}
@@ -0,0 +1,112 @@
package com.sparrowwallet.sparrow.terminal;
import com.googlecode.lanterna.gui2.dialogs.MessageDialogBuilder;
import com.googlecode.lanterna.gui2.dialogs.MessageDialogButton;
import com.googlecode.lanterna.gui2.dialogs.TextInputDialogBuilder;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.InteractionServices;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import java.text.BreakIterator;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
public class TerminalInteractionServices implements InteractionServices {
private final Object alertShowing = new Object();
private final Object passphraseShowing = new Object();
@Override
@SuppressWarnings("unchecked")
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
if(Platform.isFxApplicationThread()) {
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
Optional<ButtonType> optButtonType = showMessageDialog(title, content, buttons);
Platform.runLater(() -> Platform.exitNestedEventLoop(alertShowing, optButtonType));
});
return (Optional<ButtonType>)Platform.enterNestedEventLoop(alertShowing);
} else {
return showMessageDialog(title, content, buttons);
}
}
private Optional<ButtonType> showMessageDialog(String title, String content, ButtonType[] buttons) {
String formattedContent = formatLines(content, 50);
MessageDialogBuilder builder = new MessageDialogBuilder().setTitle(title).setText("\n" + formattedContent);
for(ButtonType buttonType : buttons) {
builder.addButton(getButton(buttonType));
}
MessageDialogButton button = builder.build().showDialog(SparrowTerminal.get().getGui());
return Arrays.stream(buttons).filter(buttonType -> button.equals(getButton(buttonType))).findFirst();
}
private String formatLines(String input, int maxLength) {
StringBuilder builder = new StringBuilder();
BreakIterator boundary = BreakIterator.getLineInstance(Locale.ROOT);
boundary.setText(input);
int start = boundary.first();
int end = boundary.next();
int lineLength = 0;
while(end != BreakIterator.DONE) {
String word = input.substring(start,end);
lineLength = lineLength + word.length();
if (lineLength >= maxLength) {
builder.append("\n");
lineLength = word.length();
}
builder.append(word);
start = end;
end = boundary.next();
}
return builder.toString();
}
private MessageDialogButton getButton(ButtonType buttonType) {
if(ButtonType.OK.equals(buttonType)) {
return MessageDialogButton.OK;
}
if(ButtonType.CANCEL.equals(buttonType)) {
return MessageDialogButton.Cancel;
}
if(ButtonType.YES.equals(buttonType)) {
return MessageDialogButton.Yes;
}
if(ButtonType.NO.equals(buttonType)) {
return MessageDialogButton.No;
}
if(ButtonType.CLOSE.equals(buttonType)) {
return MessageDialogButton.Close;
}
throw new IllegalArgumentException("Cannot find button for " + buttonType);
}
@Override
@SuppressWarnings("unchecked")
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
if(Platform.isFxApplicationThread()) {
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
Optional<String> optPassphrase = showPassphraseDialog(walletName, keystore);
Platform.runLater(() -> Platform.exitNestedEventLoop(passphraseShowing, optPassphrase));
});
return (Optional<String>)Platform.enterNestedEventLoop(passphraseShowing);
} else {
return showPassphraseDialog(walletName, keystore);
}
}
private Optional<String> showPassphraseDialog(String walletName, Keystore keystore) {
TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Passphrase for " + walletName);
builder.setDescription("Enter the BIP39 passphrase for keystore:\n" + keystore.getLabel());
builder.setPasswordInput(true);
String passphrase = builder.build().showDialog(SparrowTerminal.get().getGui());
return passphrase == null ? Optional.empty() : Optional.of(passphrase);
}
}
@@ -0,0 +1,139 @@
package com.sparrowwallet.sparrow.terminal.preferences;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.gui2.*;
import com.googlecode.lanterna.gui2.dialogs.DirectoryDialogBuilder;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.net.CoreAuthType;
import com.sparrowwallet.sparrow.net.Protocol;
import com.sparrowwallet.sparrow.terminal.SparrowTerminal;
import java.io.File;
import java.util.List;
public class BitcoinCoreDialog extends ServerUrlDialog {
private final ComboBox<String> authentication;
private final Label dataFolderLabel;
private final TextBox dataFolder;
private final Button selectDataFolder;
private final Label userPassLabel;
private final TextBox user;
private final TextBox pass;
public BitcoinCoreDialog() {
super("Bitcoin Core");
setHints(List.of(Hint.CENTERED));
Panel mainPanel = new Panel(new GridLayout(3).setHorizontalSpacing(2).setVerticalSpacing(0));
if(Config.get().getCoreServer() == null) {
Config.get().setCoreServer(new Server("http://127.0.0.1:" + Network.get().getDefaultPort()));
}
addUrlComponents(mainPanel, Config.get().getRecentCoreServers(), Config.get().getCoreServer());
addLine(mainPanel);
mainPanel.addComponent(new Label("Authentication"));
authentication = new ComboBox<>("Default", "User/Pass");
authentication.setSelectedIndex(Config.get().getCoreAuthType() == CoreAuthType.USERPASS ? 0 : 1);
mainPanel.addComponent(authentication);
mainPanel.addComponent(new EmptySpace(TerminalSize.ONE));
dataFolderLabel = new Label("Data Folder");
mainPanel.addComponent(dataFolderLabel);
dataFolder = new TextBox(new TerminalSize(30, 1), Config.get().getCoreDataDir() != null ? Config.get().getCoreDataDir().getAbsolutePath() : "");
mainPanel.addComponent(dataFolder);
selectDataFolder = new Button("Select...");
mainPanel.addComponent(selectDataFolder);
userPassLabel = new Label("User/Pass");
mainPanel.addComponent(userPassLabel);
user = new TextBox(new TerminalSize(16, 1));
mainPanel.addComponent(user);
pass = new TextBox(new TerminalSize(16, 1));
pass.setMask('*');
mainPanel.addComponent(pass);
if(Config.get().getCoreAuth() != null) {
String[] userPass = Config.get().getCoreAuth().split(":");
if(userPass.length > 0) {
user.setText(userPass[0]);
}
if(userPass.length > 1) {
pass.setText(userPass[1]);
}
}
authentication.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> {
dataFolderLabel.setVisible(selectedIndex == 0);
dataFolder.setVisible(selectedIndex == 0);
selectDataFolder.setVisible(selectedIndex == 0);
userPassLabel.setVisible(selectedIndex == 1);
user.setVisible(selectedIndex == 1);
pass.setVisible(selectedIndex == 1);
Config.get().setCoreAuthType(selectedIndex == 0 ? CoreAuthType.COOKIE : CoreAuthType.USERPASS);
});
authentication.setSelectedIndex(Config.get().getCoreAuthType() == CoreAuthType.USERPASS ? 1 : 0);
dataFolder.setTextChangeListener((newText, changedByUserInteraction) -> {
File dataDir = new File(newText);
if(dataDir.exists()) {
Config.get().setCoreDataDir(dataDir);
}
});
selectDataFolder.addListener(button -> {
DirectoryDialogBuilder builder = new DirectoryDialogBuilder().setTitle("Select Bitcoin Core Data Folder").setActionLabel("Select");
builder.setShowHiddenDirectories(true);
builder.setSelectedDirectory(Config.get().getCoreDataDir() == null ? new File(System.getProperty("user.home")) : Config.get().getCoreDataDir());
File file = builder.build().showDialog(SparrowTerminal.get().getGui());
if(file != null) {
dataFolder.setText(file.getAbsolutePath());
}
});
user.setTextChangeListener((newText, changedByUserInteraction) -> {
setCoreAuth();
});
pass.setTextChangeListener((newText, changedByUserInteraction) -> {
setCoreAuth();
});
addLine(mainPanel);
addProxyComponents(mainPanel);
Panel buttonPanel = new Panel();
buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1));
buttonPanel.addComponent(new Button("Test", this::onTest));
buttonPanel.addComponent(new Button("Done", this::onDone));
addLine(mainPanel);
mainPanel.addComponent(new EmptySpace(TerminalSize.ONE));
mainPanel.addComponent(new EmptySpace(TerminalSize.ONE));
buttonPanel.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER,false,false)).addTo(mainPanel);
setComponent(mainPanel);
}
protected void setServerConfig() {
Server currentServer = getCurrentServer();
if(currentServer != null) {
Config.get().setCoreServer(currentServer);
}
}
protected void setServerAlias(Server server) {
Config.get().setCoreServerAlias(server);
}
protected Protocol getProtocol() {
return Protocol.HTTP;
}
protected void setProtocol(Protocol protocol) {
//empty
}
private void setCoreAuth() {
Config.get().setCoreAuth(user.getText() + ":" + pass.getText());
}
}

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