diff --git a/build.gradle b/build.gradle index 8cafd170..7c8ab148 100644 --- a/build.gradle +++ b/build.gradle @@ -3,11 +3,11 @@ import java.awt.GraphicsEnvironment plugins { id 'application' id 'extra-java-module-info' - id 'org.openjfx.javafxplugin' version '0.1.0' + id 'org-openjfx-javafxplugin' id 'org.beryx.jlink' version '3.0.1' } -def sparrowVersion = '1.8.3' +def sparrowVersion = '1.8.5' def os = org.gradle.internal.os.OperatingSystem.current() def osName = os.getFamilyName() if(os.macOsX) { @@ -161,7 +161,7 @@ processResources { test { useJUnitPlatform() - jvmArgs '--add-opens=java.base/java.io=ALL-UNNAMED' + jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--add-opens=java.base/java.io=com.google.gson"] } application { @@ -247,6 +247,8 @@ jlink { "--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation", "--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core", "--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor", + "--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.pg", + "--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.provider", "--add-reads=kotlin.stdlib=kotlinx.coroutines.core"] if(os.windows) { @@ -650,18 +652,6 @@ extraJavaModuleInfo { module('jcommander-1.81.jar', 'com.beust.jcommander', '1.81') { exports('com.beust.jcommander') } - module('pgpainless-core-1.6.6.jar', 'org.pgpainless.core', '1.6.6') { - exports('org.pgpainless') - exports('org.pgpainless.key') - exports('org.pgpainless.key.parsing') - exports('org.pgpainless.decryption_verification') - exports('org.pgpainless.exception') - exports('org.pgpainless.signature') - exports('org.pgpainless.util') - requires('org.bouncycastle.provider') - requires('org.bouncycastle.pg') - requires('org.slf4j') - } module('jzlib-1.1.3.jar', 'com.jcraft.jzlib', '1.1.3') { exports('com.jcraft.jzlib') } diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index a85db759..35f31961 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -3,6 +3,8 @@ plugins { } dependencies { + implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.3' + implementation 'org.javamodularity:moduleplugin:1.8.14' implementation 'org.ow2.asm:asm:9.6' } @@ -20,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" + } } } diff --git a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java index 1a05d530..c892bdb7 100644 --- a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java +++ b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java @@ -143,7 +143,7 @@ abstract public class ExtraModuleInfoTransform implements TransformAction dependentModules; + + JavaFXModule(JavaFXModule...dependentModules) { + this.dependentModules = List.of(dependentModules); + } + + public static Optional 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 getJavaFXModules(List 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 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 getDependentModules() { + return dependentModules; + } + + public List getMavenDependencies() { + List dependencies = new ArrayList<>(dependentModules); + dependencies.add(0, this); + return dependencies; + } +} diff --git a/buildSrc/src/main/java/org/openjfx/gradle/JavaFXOptions.java b/buildSrc/src/main/java/org/openjfx/gradle/JavaFXOptions.java new file mode 100644 index 00000000..70cdf941 --- /dev/null +++ b/buildSrc/src/main/java/org/openjfx/gradle/JavaFXOptions.java @@ -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 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 getModules() { + return modules; + } + + public void setModules(List 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 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())); + } + } +} diff --git a/buildSrc/src/main/java/org/openjfx/gradle/JavaFXPlatform.java b/buildSrc/src/main/java/org/openjfx/gradle/JavaFXPlatform.java new file mode 100644 index 00000000..58347f1c --- /dev/null +++ b/buildSrc/src/main/java/org/openjfx/gradle/JavaFXPlatform.java @@ -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 ) + ); + + } +} diff --git a/buildSrc/src/main/java/org/openjfx/gradle/JavaFXPlugin.java b/buildSrc/src/main/java/org/openjfx/gradle/JavaFXPlugin.java new file mode 100644 index 00000000..2b5e59dd --- /dev/null +++ b/buildSrc/src/main/java/org/openjfx/gradle/JavaFXPlugin.java @@ -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 { + + @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); + } +} diff --git a/buildSrc/src/main/java/org/openjfx/gradle/tasks/ExecTask.java b/buildSrc/src/main/java/org/openjfx/gradle/tasks/ExecTask.java new file mode 100644 index 00000000..6b31f844 --- /dev/null +++ b/buildSrc/src/main/java/org/openjfx/gradle/tasks/ExecTask.java @@ -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(); + jvmArgs.add("--add-modules"); + jvmArgs.add(String.join(",", definedJavaFXModuleNames)); + + List 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())); + } +} diff --git a/docs/reproducible.md b/docs/reproducible.md index ecbf69fb..10ced6d7 100644 --- a/docs/reproducible.md +++ b/docs/reproducible.md @@ -82,7 +82,7 @@ sudo apt install -y rpm fakeroot binutils First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify: ```shell -GIT_TAG="1.8.2" +GIT_TAG="1.8.4" ``` The project can then be initially cloned as follows: diff --git a/src/main/deploy/asc.properties b/src/main/deploy/asc.properties index 0ddb0a03..c495ddee 100644 --- a/src/main/deploy/asc.properties +++ b/src/main/deploy/asc.properties @@ -1,3 +1,3 @@ mime-type=application/pgp-signature extension=asc -description=ASCII Armored Signature \ No newline at end of file +description=ASCII Armored File \ No newline at end of file diff --git a/src/main/deploy/package/osx/Info.plist b/src/main/deploy/package/osx/Info.plist index 83cdb555..b34b9e83 100644 --- a/src/main/deploy/package/osx/Info.plist +++ b/src/main/deploy/package/osx/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8.3 + 1.8.5 CFBundleSignature ???? @@ -105,7 +105,7 @@ UTTypeDescription - ASCII Armored Signature + ASCII Armored File UTTypeIconFile sparrow.icns diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index ef0116b2..e5b66109 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -21,6 +21,8 @@ import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.*; +import com.sparrowwallet.sparrow.io.bbqr.BBQR; +import com.sparrowwallet.sparrow.io.bbqr.BBQRType; import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.net.ServerType; import com.sparrowwallet.sparrow.preferences.PreferenceGroup; @@ -289,9 +291,7 @@ public class AppController implements Initializable { Dragboard db = event.getDragboard(); boolean success = false; if(db.hasFiles()) { - for(File file : db.getFiles()) { - openFile(file); - } + openFiles(db.getFiles()); success = true; } event.setDropCompleted(success); @@ -333,6 +333,8 @@ public class AppController implements Initializable { EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList())); }); + tabs.setPickOnBounds(false); + registerShortcuts(); BitcoinUnit unit = Config.get().getBitcoinUnit(); @@ -379,6 +381,9 @@ public class AppController implements Initializable { preventSleepProperty.set(Config.get().isPreventSleep()); preventSleep.selectedProperty().bindBidirectional(preventSleepProperty); + MenuItem homeItem = new MenuItem("Home Folder..."); + homeItem.setOnAction(this::restartInHome); + restart.getItems().add(homeItem); List networks = new ArrayList<>(List.of(Network.MAINNET, Network.TESTNET, Network.SIGNET)); networks.remove(Network.get()); for(Network network : networks) { @@ -636,7 +641,7 @@ public class AppController implements Initializable { } catch(TransactionParseException e) { showErrorDialog("Invalid transaction", e.getMessage()); } catch(Exception e) { - showErrorDialog("Invalid file", "Cannot recognise the format of this file."); + showErrorDialog("Invalid file", "Cannot recognise the format of the " + file.getName() + " file."); } } } @@ -752,8 +757,10 @@ public class AppController implements Initializable { Transaction transaction = transactionTabData.getTransaction(); try { - UR ur = UR.fromBytes(transaction.bitcoinSerialize()); - QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur); + byte[] txBytes = transaction.bitcoinSerialize(); + UR ur = UR.fromBytes(txBytes); + BBQR bbqr = new BBQR(BBQRType.TXN, txBytes); + QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false); qrDisplayDialog.initOwner(rootStack.getScene().getWindow()); qrDisplayDialog.showAndWait(); } catch(Exception e) { @@ -851,8 +858,10 @@ public class AppController implements Initializable { if(tabData.getType() == TabData.TabType.TRANSACTION) { TransactionTabData transactionTabData = (TransactionTabData)tabData; - CryptoPSBT cryptoPSBT = new CryptoPSBT(transactionTabData.getPsbt().serialize()); - QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR()); + byte[] psbtBytes = transactionTabData.getPsbt().serialize(); + CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes); + BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes); + QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, false); qrDisplayDialog.initOwner(rootStack.getScene().getWindow()); qrDisplayDialog.show(); } @@ -967,19 +976,46 @@ public class AppController implements Initializable { AppServices.get().setPreventSleep(item.isSelected()); } + public void restartInHome(ActionEvent event) { + Args args = getRestartArgs(); + File initialDir = null; + if(args.dir != null) { + initialDir = new File(args.dir); + } + + Stage window = new Stage(); + DirectoryChooser directoryChooser = new DirectoryChooser(); + directoryChooser.setTitle("Choose Sparrow Home Folder"); + directoryChooser.setInitialDirectory(initialDir == null || !initialDir.exists() ? Storage.getSparrowHome() : initialDir); + File newHome = directoryChooser.showDialog(window); + + if(newHome != null) { + args.dir = newHome.getAbsolutePath(); + restart(event, args); + } + } + public void restart(ActionEvent event, Network network) { if(System.getProperty(JPACKAGE_APP_PATH) == null) { throw new IllegalStateException("Property " + JPACKAGE_APP_PATH + " is not present"); } + Args args = getRestartArgs(); + args.network = network; + restart(event, args); + } + + private static Args getRestartArgs() { Args args = new Args(); ProcessHandle.current().info().arguments().ifPresent(argv -> { JCommander jCommander = JCommander.newBuilder().addObject(args).acceptUnknownOptions(true).build(); jCommander.parse(argv); }); - args.network = network; + return args; + } + private void restart(ActionEvent event, Args args) { try { List cmd = new ArrayList<>(); cmd.add(System.getProperty(JPACKAGE_APP_PATH)); @@ -992,13 +1028,19 @@ public class AppController implements Initializable { } } - public void openFile(File file) { - if(isWalletFile(file)) { - openWalletFile(file, true); - } else if(isVerifyDownloadFile(file)) { - verifyDownload(new ActionEvent(file, rootStack)); - } else { - openTransactionFile(file); + public void openFiles(List files) { + boolean verifyOpened = false; + for(File file : files) { + if(isWalletFile(file)) { + openWalletFile(file, true); + } else if(isVerifyDownloadFile(file)) { + if(!verifyOpened) { + verifyDownload(new ActionEvent(file, rootStack)); + verifyOpened = true; + } + } else { + openTransactionFile(file); + } } } @@ -2743,7 +2785,7 @@ public class AppController implements Initializable { public void disconnection(DisconnectionEvent event) { serverToggle.setDisable(false); if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith(CONNECTION_FAILED_PREFIX) && !statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) { - statusUpdated(new StatusEvent("Disconnected")); + statusUpdated(new StatusEvent("Disconnected (click toggle on the right to connect)", 240)); } if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) { statusBar.setProgress(0); diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 2a787269..84132b92 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -117,6 +117,8 @@ public class AppServices { private ElectrumServer.ConnectionService connectionService; + private ElectrumServer.FeeRatesService feeRatesService; + private Hwi.ScheduledEnumerateService deviceEnumerateService; private VersionCheckService versionCheckService; @@ -188,6 +190,7 @@ public class AppServices { public void start() { Config config = Config.get(); connectionService = createConnectionService(); + feeRatesService = createFeeRatesService(); ratesService = createRatesService(config.getExchangeSource(), config.getFiatCurrency()); versionCheckService = createVersionCheckService(); torService = createTorService(); @@ -201,6 +204,8 @@ public class AppServices { } else { restartServices(); } + } else { + EventManager.get().post(new DisconnectionEvent()); } addURIHandlers(); @@ -284,8 +289,15 @@ public class AppServices { onlineProperty.setValue(true); onlineProperty.addListener(onlineServicesListener); - if(connectionService.getValue() != null) { - EventManager.get().post(connectionService.getValue()); + FeeRatesUpdatedEvent event = connectionService.getValue(); + if(event != null) { + EventManager.get().post(event); + } + + FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource(); + feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource); + if(event instanceof ConnectionEvent && Network.get().equals(Network.MAINNET) && feeRatesSource.isExternal()) { + EventManager.get().post(new FeeRatesSourceChangedEvent(feeRatesSource)); } }); connectionService.setOnFailed(failEvent -> { @@ -356,6 +368,15 @@ public class AppServices { return connectionService; } + private ElectrumServer.FeeRatesService createFeeRatesService() { + ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService(); + feeRatesService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(feeRatesService.getValue()); + }); + + return feeRatesService; + } + private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) { ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService( exchangeSource == null ? DEFAULT_EXCHANGE_SOURCE : exchangeSource, @@ -1102,17 +1123,18 @@ public class AppServices { @Subscribe public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) { - addMempoolRateSizes(event.getMempoolRateSizes()); + if(event.getMempoolRateSizes() != null) { + addMempoolRateSizes(event.getMempoolRateSizes()); + } } @Subscribe public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) { - ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService(); - feeRatesService.setOnSucceeded(workerStateEvent -> { - EventManager.get().post(feeRatesService.getValue()); - }); //Perform once-off fee rates retrieval to immediately change displayed rates - feeRatesService.start(); + if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) { + feeRatesService = createFeeRatesService(); + feeRatesService.start(); + } } @Subscribe diff --git a/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java b/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java index 6faad6be..7aa792d5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java +++ b/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java @@ -16,9 +16,9 @@ import java.io.File; import java.util.*; public class SparrowWallet { - public static final String APP_ID = "com.sparrowwallet.sparrow"; + public static final String APP_ID = "sparrow"; public static final String APP_NAME = "Sparrow"; - public static final String APP_VERSION = "1.8.3"; + public static final String APP_VERSION = "1.8.5"; 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"; @@ -79,7 +79,7 @@ public class SparrowWallet { try { instance = new Instance(fileUriArguments); - instance.acquireLock(); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired + instance.acquireLock(!fileUriArguments.isEmpty()); //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); } @@ -130,13 +130,13 @@ public class SparrowWallet { private final List fileUriArguments; public Instance(List fileUriArguments) { - super(SparrowWallet.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty()); + super(SparrowWallet.APP_ID, true); this.fileUriArguments = fileUriArguments; } @Override protected void receiveMessageList(List messageList) { - if(messageList != null && !messageList.isEmpty()) { + if(messageList != null) { AppServices.parseFileUriArguments(messageList); AppServices.openFileUriArguments(null); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java index 870c66f2..ecf4008e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow.control; import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.pgp.PGPKeySource; import com.sparrowwallet.drongo.pgp.PGPUtils; import com.sparrowwallet.drongo.pgp.PGPVerificationException; import com.sparrowwallet.drongo.pgp.PGPVerificationResult; @@ -70,6 +71,7 @@ public class DownloadVerifierDialog extends Dialog { private final ObjectProperty publicKey = new SimpleObjectProperty<>(); private final ObjectProperty release = new SimpleObjectProperty<>(); + private final BooleanProperty manifestDisabled = new SimpleBooleanProperty(); private final BooleanProperty publicKeyDisabled = new SimpleBooleanProperty(); private final Label signedBy; @@ -99,7 +101,7 @@ public class DownloadVerifierDialog extends Dialog { String version = VersionCheckService.getVersion() != null ? VersionCheckService.getVersion() : "x.x.x"; Field signatureField = setupField(signature, "Signature", SIGNATURE_EXTENSIONS, false, "sparrow-" + version + "-manifest.txt", null); - Field manifestField = setupField(manifest, "Manifest", MANIFEST_EXTENSIONS, false, "sparrow-" + version + "-manifest", null); + Field manifestField = setupField(manifest, "Manifest", MANIFEST_EXTENSIONS, false, "sparrow-" + version + "-manifest", manifestDisabled); Field publicKeyField = setupField(publicKey, "Public Key", PUBLIC_KEY_EXTENSIONS, true, "pgp_keys", publicKeyDisabled); Field releaseFileField = setupField(release, "Release File", getReleaseFileExtensions(), false, getReleaseFileExample(version), null); @@ -153,6 +155,7 @@ public class DownloadVerifierDialog extends Dialog { release.set(null); signedBy.setText(""); signedBy.setGraphic(null); + signedBy.setTooltip(null); releaseHash.setText(""); releaseHash.setGraphic(null); releaseVerified.setText(""); @@ -262,6 +265,7 @@ public class DownloadVerifierDialog extends Dialog { } private void verify() { + manifestDisabled.set(false); publicKeyDisabled.set(false); if(signature.get() == null || manifest.get() == null) { @@ -282,12 +286,14 @@ public class DownloadVerifierDialog extends Dialog { String message = result.userId() + " on " + signatureDateFormat.format(result.signatureTimestamp()) + (result.expired() ? " (key expired)" : ""); signedBy.setText(message); signedBy.setGraphic(result.expired() ? GlyphUtils.getWarningGlyph() : GlyphUtils.getSuccessGlyph()); + signedBy.setTooltip(new Tooltip(result.fingerprint())); - if(!result.expired()) { + if(!result.expired() && result.keySource() != PGPKeySource.USER) { publicKeyDisabled.set(true); } if(manifest.get().equals(release.get())) { + manifestDisabled.set(true); releaseHash.setText("No hash required, signature signs release file directly"); releaseHash.setGraphic(GlyphUtils.getSuccessGlyph()); releaseHash.setTooltip(null); @@ -302,6 +308,7 @@ public class DownloadVerifierDialog extends Dialog { Throwable e = event.getSource().getException(); signedBy.setText(getDisplayMessage(e)); signedBy.setGraphic(GlyphUtils.getFailureGlyph()); + signedBy.setTooltip(null); clearReleaseFields(); }); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 6a9be11b..3a0e526a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -585,12 +585,14 @@ public class EntryCell extends TreeTableCell implements Confirmati getItems().add(createCpfp); } - MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer"); - openBlockExplorer.setOnAction(AE -> { - hide(); - AppServices.openBlockExplorer(blockTransaction.getHashAsString()); - }); - getItems().add(openBlockExplorer); + if(!Config.get().isBlockExplorerDisabled()) { + MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer"); + openBlockExplorer.setOnAction(AE -> { + hide(); + AppServices.openBlockExplorer(blockTransaction.getHashAsString()); + }); + getItems().add(openBlockExplorer); + } MenuItem copyTxid = new MenuItem("Copy Transaction ID"); copyTxid.setOnAction(AE -> { @@ -612,12 +614,16 @@ public class EntryCell extends TreeTableCell implements Confirmati hide(); EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction)); }); + getItems().add(viewTransaction); - MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer"); - openBlockExplorer.setOnAction(AE -> { - hide(); - AppServices.openBlockExplorer(blockTransaction.getHashAsString()); - }); + if(!Config.get().isBlockExplorerDisabled()) { + MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer"); + openBlockExplorer.setOnAction(AE -> { + hide(); + AppServices.openBlockExplorer(blockTransaction.getHashAsString()); + }); + getItems().add(openBlockExplorer); + } MenuItem copyDate = new MenuItem("Copy Date"); copyDate.setOnAction(AE -> { @@ -626,6 +632,7 @@ public class EntryCell extends TreeTableCell implements Confirmati content.putString(date); Clipboard.getSystemClipboard().setContent(content); }); + getItems().add(copyDate); MenuItem copyTxid = new MenuItem("Copy Transaction ID"); copyTxid.setOnAction(AE -> { @@ -634,6 +641,7 @@ public class EntryCell extends TreeTableCell implements Confirmati content.putString(blockTransaction.getHashAsString()); Clipboard.getSystemClipboard().setContent(content); }); + getItems().add(copyTxid); MenuItem copyHeight = new MenuItem("Copy Block Height"); copyHeight.setOnAction(AE -> { @@ -642,8 +650,7 @@ public class EntryCell extends TreeTableCell implements Confirmati content.putString(blockTransaction.getHeight() > 0 ? Integer.toString(blockTransaction.getHeight()) : "Mempool"); Clipboard.getSystemClipboard().setContent(content); }); - - getItems().addAll(viewTransaction, openBlockExplorer, copyDate, copyTxid, copyHeight); + getItems().add(copyHeight); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreExportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreExportPane.java index aa14fd6c..0cd2f562 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreExportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreExportPane.java @@ -4,12 +4,14 @@ import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.Wallet; -import com.sparrowwallet.hummingbird.registry.RegistryType; +import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.KeystoreExportEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.*; +import com.sparrowwallet.sparrow.io.bbqr.BBQR; +import com.sparrowwallet.sparrow.io.bbqr.BBQRType; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; @@ -153,7 +155,9 @@ public class FileKeystoreExportPane extends TitledDescriptionPane { } else { QRDisplayDialog qrDisplayDialog; if(exporter instanceof Bip129) { - qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), baos.toByteArray(), false); + UR ur = UR.fromBytes(baos.toByteArray()); + BBQR bbqr = new BBQR(BBQRType.UNICODE, baos.toByteArray()); + qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false); } else { qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8)); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java index aee9debe..1d0bbaad 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletExportPane.java @@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.registry.CryptoOutput; import com.sparrowwallet.hummingbird.registry.RegistryType; import com.sparrowwallet.sparrow.AppServices; @@ -13,6 +14,8 @@ import com.sparrowwallet.sparrow.event.TimedEvent; import com.sparrowwallet.sparrow.event.WalletExportEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.*; +import com.sparrowwallet.sparrow.io.bbqr.BBQR; +import com.sparrowwallet.sparrow.io.bbqr.BBQRType; import javafx.concurrent.Service; import javafx.concurrent.Task; import javafx.geometry.Pos; @@ -163,8 +166,12 @@ public class FileWalletExportPane extends TitledDescriptionPane { QRDisplayDialog qrDisplayDialog; if(exporter instanceof CoboVaultMultisig) { qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true); - } else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig || exporter instanceof Bip129) { + } else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig) { qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false); + } else if(exporter instanceof Bip129) { + UR ur = UR.fromBytes(outputStream.toByteArray()); + BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray()); + qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false); } else if(exporter instanceof Descriptor) { OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null); CryptoOutput cryptoOutput = getCryptoOutput(exportWallet); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java index f0f59a1a..886703e3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java @@ -472,7 +472,7 @@ public class MessageSignDialog extends Dialog { QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(qrText, true); qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow()); Optional optButtonType = qrDisplayDialog.showAndWait(); - if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) { + if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) { scanQr(); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index c4db84c6..41986d81 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -32,6 +32,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder; import com.sparrowwallet.sparrow.io.bbqr.BBQRException; +import com.sparrowwallet.sparrow.wallet.KeystoreController; import javafx.application.Platform; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; @@ -621,7 +622,8 @@ public class QRScanDialog extends Dialog { List path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp) .map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList()); - return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), KeyDerivation.writePath(path)); + String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()); + return new KeyDerivation(fingerprint, KeyDerivation.writePath(path)); } return null; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/ScriptArea.java b/src/main/java/com/sparrowwallet/sparrow/control/ScriptArea.java index 749d403b..8be135af 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/ScriptArea.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/ScriptArea.java @@ -54,10 +54,10 @@ public class ScriptArea extends CodeArea { ScriptChunk chunk = script.getChunks().get(i); if(chunk.isOpCode()) { append(chunk.toString(), "script-opcode"); - } else if(chunk.isSignature()) { - append("", "script-signature"); } else if(chunk.isPubKey()) { append("", "script-pubkey"); + } else if(chunk.isSignature()) { + append("", "script-signature"); } else if(chunk.isTaprootControlBlock()) { append("", "script-controlblock"); } else if(chunk.isString()) { diff --git a/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java b/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java index 1b6ecffa..c684252f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java +++ b/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java @@ -1,535 +1,270 @@ -/** - * Copyright 2019 Pratanu Mandal - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - package com.sparrowwallet.sparrow.instance; +import com.sparrowwallet.sparrow.io.Storage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.DataInputStream; -import java.io.DataOutputStream; import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.RandomAccessFile; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; +import java.net.ConnectException; import java.net.SocketException; -import java.net.UnknownHostException; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Iterator; +import java.util.Set; -/** - * The Instance class is the primary logical entry point to the library.
- * It allows to create an application lock or free it and send and receive messages between first and subsequent instances.

- * - *
- *	// unique application ID
- *	String APP_ID = "tk.pratanumandal.unique4j-mlsdvo-20191511-#j.6";
- *	
- *	// create Instance instance
- *	Instance unique = new Instance(APP_ID) {
- *	    @Override
- *	    protected void receiveMessage(String message) {
- *	        // print received message (timestamp)
- *	        System.out.println(message);
- *	    }
- *	    
- *	    @Override
- *	    protected String sendMessage() {
- *	        // send timestamp as message
- *	        Timestamp ts = new Timestamp(new Date().getTime());
- *	        return "Another instance launch attempted: " + ts.toString();
- *	    }
- *	};
- *	
- *	// try to obtain lock
- *	try {
- *	    unique.acquireLock();
- *	} catch (InstanceException e) {
- *	    e.printStackTrace();
- *	}
- *	
- *	...
- *	
- *	// try to free the lock before exiting program
- *	try {
- *	    unique.freeLock();
- *	} catch (InstanceException e) {
- *	    e.printStackTrace();
- *	}
- * 
- * - * @author Pratanu Mandal - * @since 1.3 - * - */ public abstract class Instance { private static final Logger log = LoggerFactory.getLogger(Instance.class); - - // starting position of port check - private static final int PORT_START = 7221; - - // system temporary directory path - private static final String TEMP_DIR = System.getProperty("java.io.tmpdir"); - - /** - * Unique string representing the application ID.

- * - * The APP_ID must be as unique as possible. - * Avoid generic names like "my_app_id" or "hello_world".
- * A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters. - */ - public final String APP_ID; - - // auto exit from application or not - private final boolean AUTO_EXIT; - - // lock server port - private int port; - - // lock server socket - private ServerSocket server; - - // lock file RAF object - private RandomAccessFile lockRAF; - - // file lock for the lock file RAF object - private FileLock fileLock; + private static final String LINK_ENV_PROPERTY = "SPARROW_NO_LOCK_FILE_LINK"; - /** - * Parameterized constructor.
- * This constructor configures to automatically exit the application for subsequent instances.

- * - * The APP_ID must be as unique as possible. - * Avoid generic names like "my_app_id" or "hello_world".
- * A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters. - * - * @param APP_ID Unique string representing the application ID - */ - public Instance(final String APP_ID) { - this(APP_ID, true); - } - - /** - * Parameterized constructor.
- * This constructor allows to explicitly specify the exit strategy for subsequent instances.

- * - * The APP_ID must be as unique as possible. - * Avoid generic names like "my_app_id" or "hello_world".
- * A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters. - * - * @since 1.2 - * - * @param APP_ID Unique string representing the application ID - * @param AUTO_EXIT If true, automatically exit the application for subsequent instances - */ - public Instance(final String APP_ID, final boolean AUTO_EXIT) { - this.APP_ID = APP_ID; - this.AUTO_EXIT = AUTO_EXIT; - } - - /** - * Try to obtain lock. If not possible, send data to first instance. - * - * @deprecated Use acquireLock() instead. - * @throws InstanceException throws InstanceException if it is unable to start a server or connect to server - */ - @Deprecated - public void lock() throws InstanceException { - acquireLock(); - } - - /** - * Try to obtain lock. If not possible, send data to first instance. - * - * @since 1.2 - * - * @return true if able to acquire lock, false otherwise - * @throws InstanceException throws InstanceException if it is unable to start a server or connect to server - */ - public boolean acquireLock() throws InstanceException { - // try to obtain port number from lock file - port = lockFile(); - - if (port == -1) { - // failed to fetch port number - // try to start server - startServer(); - } - else { - // port number fetched from lock file - // try to start client - doClient(); - } - - return (server != null); - } - - // start the server - private void startServer() throws InstanceException { - // try to create server - port = PORT_START; - while (true) { - try { - server = new ServerSocket(port, 50, InetAddress.getByName(null)); - break; - } catch (IOException e) { - port++; - } - } - - // try to lock file - lockFile(port); - - // server created successfully; this is the first instance - // keep listening for data from other instances - Thread thread = new Thread() { - @Override - public void run() { - while (!server.isClosed()) { - try { - // establish connection - final Socket socket = server.accept(); - - // handle socket on a different thread to allow parallel connections - Thread thread = new Thread() { - @Override - public void run() { - try { - // open writer - OutputStream os = socket.getOutputStream(); - DataOutputStream dos = new DataOutputStream(os); - - // open reader - InputStream is = socket.getInputStream(); - DataInputStream dis = new DataInputStream(is); - - // read message length from client - int length = dis.readInt(); - - // read message string from client - String message = null; - if (length > -1) { - byte[] messageBytes = new byte[length]; - int bytesRead = dis.read(messageBytes, 0, length); - message = new String(messageBytes, 0, bytesRead, "UTF-8"); - } - - // write response to client - if (APP_ID == null) { - dos.writeInt(-1); - } - else { - byte[] appId = APP_ID.getBytes("UTF-8"); - - dos.writeInt(appId.length); - dos.write(appId); - } - dos.flush(); - - // close writer and reader - dos.close(); - dis.close(); - - // perform user action on message - receiveMessage(message); - - // close socket - socket.close(); - } catch (IOException e) { - handleException(new InstanceException(e)); - } - } - }; - - // start socket thread - thread.start(); - } catch (SocketException e) { - if (!server.isClosed()) { - handleException(new InstanceException(e)); - } - } catch (IOException e) { - handleException(new InstanceException(e)); - } - } - } - }; - - thread.start(); - } - - // do client tasks - private void doClient() throws InstanceException { - // get localhost address - InetAddress address = null; - try { - address = InetAddress.getByName(null); - } catch (UnknownHostException e) { - throw new InstanceException(e); - } - - // try to establish connection to server - Socket socket = null; - try { - socket = new Socket(address, port); - } catch (IOException e) { - // connection failed try to start server - startServer(); - } - - // connection successful try to connect to server - if (socket != null) { - try { - // get message to be sent to first instance - String message = sendMessage(); - - // open writer - OutputStream os = socket.getOutputStream(); - DataOutputStream dos = new DataOutputStream(os); - - // open reader - InputStream is = socket.getInputStream(); - DataInputStream dis = new DataInputStream(is); - - // write message to server - if (message == null) { - dos.writeInt(-1); - } - else { - byte[] messageBytes = message.getBytes("UTF-8"); - - dos.writeInt(messageBytes.length); - dos.write(messageBytes); - } - - dos.flush(); - - // read response length from server - int length = dis.readInt(); - - // read response string from server - String response = null; - if (length > -1) { - byte[] responseBytes = new byte[length]; - int bytesRead = dis.read(responseBytes, 0, length); - response = new String(responseBytes, 0, bytesRead, "UTF-8"); - } - - // close writer and reader - dos.close(); - dis.close(); - - if (response.equals(APP_ID)) { - // validation successful - if (AUTO_EXIT) { - // perform pre-exit tasks - beforeExit(); - // exit this instance - System.exit(0); - } - } - else { - // validation failed, this is the first instance - startServer(); - } - } catch (IOException e) { - throw new InstanceException(e); - } finally { - // close socket - try { - if (socket != null) socket.close(); - } catch (IOException e) { - throw new InstanceException(e); - } - } - } - } - - // try to get port from lock file - private int lockFile() throws InstanceException { - // lock file path - String filePath = TEMP_DIR + File.separator + APP_ID + ".lock"; - File file = new File(filePath); - - // try to get port from lock file - if (file.exists()) { - BufferedReader br = null; - try { - br = new BufferedReader(new InputStreamReader(new FileInputStream(file))); - return Integer.parseInt(br.readLine()); - } catch (IOException e) { - throw new InstanceException(e); - } catch (NumberFormatException e) { - // do nothing - } finally { - try { - if (br != null) br.close(); - } catch (IOException e) { - throw new InstanceException(e); - } - } - } - - return -1; - } - - // try to write port to lock file - private void lockFile(int port) throws InstanceException { - // lock file path - String filePath = TEMP_DIR + File.separator + APP_ID + ".lock"; - File file = new File(filePath); - - // try to write port to lock file - BufferedWriter bw = null; - try { - bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file))); - bw.write(String.valueOf(port)); - } catch (IOException e) { - throw new InstanceException(e); - } finally { - try { - if (bw != null) bw.close(); - } catch (IOException e) { - throw new InstanceException(e); - } - } - - // try to obtain file lock - try { - lockRAF = new RandomAccessFile(file, "rw"); - FileChannel fc = lockRAF.getChannel(); - fileLock = fc.tryLock(0, Long.MAX_VALUE, true); - if (fileLock == null) { - throw new InstanceException("Failed to obtain file lock"); - } - } catch (FileNotFoundException e) { - throw new InstanceException(e); - } catch (IOException e) { - throw new InstanceException(e); - } - } - - /** - * Free the lock if possible. This is only required to be called from the first instance. - * - * @deprecated Use freeLock() instead. - * @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock - */ - @Deprecated - public void free() throws InstanceException { - freeLock(); - } - - /** - * Free the lock if possible. This is only required to be called from the first instance. - * - * @since 1.2 - * - * @return true if able to release lock, false otherwise - * @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock - */ - public boolean freeLock() throws InstanceException { - try { - // close server socket - if (server != null) { - server.close(); - - // lock file path - String filePath = TEMP_DIR + File.separator + APP_ID + ".lock"; - File file = new File(filePath); - - // try to release file lock - if (fileLock != null) { - fileLock.release(); - } - - // try to close lock file RAF object - if (lockRAF != null) { - lockRAF.close(); - } - - // try to delete lock file - if (file.exists()) { - file.delete(); - } - - return true; - } - - return false; - } catch (IOException e) { - throw new InstanceException(e); - } - } - - /** - * Method used in first instance to receive messages from subsequent instances.

- * - * This method is not synchronized. - * - * @param message message received by first instance from subsequent instances - */ - protected abstract void receiveMessage(String message); - - /** - * Method used in subsequent instances to send message to first instance.

- * - * It is not recommended to perform blocking (long running) tasks here. Use beforeExit() method instead.
- * One exception to this rule is if you intend to perform some user interaction before sending the message.

- * - * This method is not synchronized. - * - * @return message sent from subsequent instances - */ - protected abstract String sendMessage(); - - /** - * Method to receive and handle exceptions occurring while first instance is listening for subsequent instances.

- * - * By default prints stack trace of all exceptions. Override this method to handle exceptions explicitly.

- * - * This method is not synchronized. - * - * @param exception exception occurring while first instance is listening for subsequent instances - */ - protected void handleException(Exception exception) { + public final String applicationId; + private final boolean autoExit; + + private Selector selector; + private ServerSocketChannel serverChannel; + + public Instance(final String applicationId) { + this(applicationId, true); + } + + public Instance(final String applicationId, final boolean autoExit) { + this.applicationId = applicationId; + this.autoExit = autoExit; + } + + /** + * Try to obtain lock. If not possible, send data to first instance. + * + * @throws InstanceException throws InstanceException if it is unable to start a server or connect to server + */ + public void acquireLock(boolean findExisting) throws InstanceException { + Path lockFile = getLockFile(findExisting); + + if(!Files.exists(lockFile)) { + startServer(lockFile); + } else { + doClient(lockFile); + } + } + + private void startServer(Path lockFile) throws InstanceException { + try { + selector = Selector.open(); + UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(lockFile); + serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); + serverChannel.bind(socketAddress); + serverChannel.configureBlocking(false); + serverChannel.register(selector, SelectionKey.OP_ACCEPT); + lockFile.toFile().deleteOnExit(); + } catch(Exception e) { + throw new InstanceException("Could not open UNIX socket lock file for instance at " + lockFile.toAbsolutePath(), e); + } + + Thread thread = new Thread(() -> { + while(true) { + try { + selector.select(); + Set selectedKeys = selector.selectedKeys(); + Iterator iter = selectedKeys.iterator(); + while(iter.hasNext()) { + SelectionKey key = iter.next(); + if(key.isAcceptable()) { + SocketChannel client = serverChannel.accept(); + client.configureBlocking(false); + client.register(selector, SelectionKey.OP_READ); + } + if(key.isReadable()) { + try(SocketChannel clientChannel = (SocketChannel)key.channel()) { + String message = readMessage(clientChannel); + clientChannel.write(ByteBuffer.wrap(applicationId.getBytes(StandardCharsets.UTF_8))); + receiveMessage(message); + } + } + iter.remove(); + } + } catch(SocketException e) { + if(serverChannel.isOpen()) { + handleException(new InstanceException(e)); + } + } catch(Exception e) { + handleException(new InstanceException(e)); + } + } + }); + + thread.setDaemon(true); + thread.setName("SparrowInstanceListener"); + thread.start(); + + createSymlink(lockFile); + } + + private void doClient(Path lockFile) throws InstanceException { + try(SocketChannel client = SocketChannel.open(UnixDomainSocketAddress.of(lockFile))) { + String message = sendMessage(); + client.write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8))); + client.shutdownOutput(); + + String response = readMessage(client); + if(response.equals(applicationId) && autoExit) { + beforeExit(); + System.exit(0); + } + } catch(ConnectException e) { + try { + Files.deleteIfExists(lockFile); + startServer(lockFile); + } catch(Exception ex) { + throw new InstanceException("Could not delete lock file from previous instance", e); + } + } catch(Exception e) { + throw new InstanceException("Could not open client connection to existing instance", e); + } + } + + private static String readMessage(SocketChannel clientChannel) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(1024); + StringBuilder messageBuilder = new StringBuilder(); + while(clientChannel.read(buffer) != -1) { + buffer.flip(); + messageBuilder.append(new String(buffer.array(), 0, buffer.limit())); + buffer.clear(); + } + + return messageBuilder.toString(); + } + + private Path getLockFile(boolean findExisting) { + if(findExisting) { + Path pointer = getUserLockFilePointer(); + try { + if(pointer != null && Files.exists(pointer)) { + if(Files.isSymbolicLink(pointer)) { + return Files.readSymbolicLink(pointer); + } else { + Path lockFile = Path.of(Files.readString(pointer, StandardCharsets.UTF_8)); + if(Files.exists(lockFile)) { + return lockFile; + } + } + } + } catch(IOException e) { + log.warn("Could not find lock file at " + pointer.toAbsolutePath()); + } catch(Exception e) { + //ignore + } + } + + return Storage.getSparrowDir().toPath().resolve(applicationId + ".lock"); + } + + private void createSymlink(Path lockFile) { + Path pointer = getUserLockFilePointer(); + try { + if(pointer != null && !Files.exists(pointer, LinkOption.NOFOLLOW_LINKS)) { + Files.createSymbolicLink(pointer, lockFile); + pointer.toFile().deleteOnExit(); + } + } catch(IOException e) { + log.debug("Could not create symlink " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath() + ", writing as normal file", e); + + try { + Files.writeString(pointer, lockFile.toAbsolutePath().toString(), StandardCharsets.UTF_8); + pointer.toFile().deleteOnExit(); + } catch(IOException ex) { + log.warn("Could not create pointer " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath(), ex); + } + } catch(Exception e) { + //ignore + } + } + + private Path getUserLockFilePointer() { + if(Boolean.parseBoolean(System.getenv(LINK_ENV_PROPERTY))) { + return null; + } + + try { + File sparrowHome = Storage.getSparrowHome(true); + if(!sparrowHome.exists()) { + Storage.createOwnerOnlyDirectory(sparrowHome); + } + + return sparrowHome.toPath().resolve(applicationId + ".default"); + } catch(Exception e) { + return null; + } + } + + /** + * Free the lock if possible. This is only required to be called from the first instance. + * + * @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock + */ + public void freeLock() throws InstanceException { + try { + if(serverChannel != null && serverChannel.isOpen()) { + serverChannel.close(); + } + if(getUserLockFilePointer() != null) { + Files.deleteIfExists(getUserLockFilePointer()); + } + Files.deleteIfExists(getLockFile(false)); + } catch(Exception e) { + throw new InstanceException(e); + } + } + + /** + * Method used in first instance to receive messages from subsequent instances.

+ * + * This method is not synchronized. + * + * @param message message received by first instance from subsequent instances + */ + protected abstract void receiveMessage(String message); + + /** + * Method used in subsequent instances to send message to first instance.

+ * + * It is not recommended to perform blocking (long running) tasks here. Use beforeExit() method instead.
+ * One exception to this rule is if you intend to perform some user interaction before sending the message.

+ * + * This method is not synchronized. + * + * @return message sent from subsequent instances + */ + protected abstract String sendMessage(); + + /** + * Method to receive and handle exceptions occurring while first instance is listening for subsequent instances.

+ * + * By default prints stack trace of all exceptions. Override this method to handle exceptions explicitly.

+ * + * This method is not synchronized. + * + * @param exception exception occurring while first instance is listening for subsequent instances + */ + protected void handleException(Exception exception) { log.error("Error listening for instances", exception); - } - - /** - * This method is called before exiting from subsequent instances.

- * - * Override this method to perform blocking tasks before exiting from subsequent instances.
- * This method is not invoked if auto exit is turned off.

- * - * This method is not synchronized. - * - * @since 1.2 - */ - protected void beforeExit() {} - + } + + /** + * This method is called before exiting from subsequent instances.

+ * + * Override this method to perform blocking tasks before exiting from subsequent instances.
+ * This method is not invoked if auto exit is turned off.

+ * + * This method is not synchronized. + */ + protected void beforeExit() {} } diff --git a/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java b/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java index 44cfd8c2..5986d8ea 100644 --- a/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java +++ b/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java @@ -7,88 +7,14 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonParser; -/** - * The InstanceList class is a logical entry point to the library which extends the functionality of the Instance class.
- * It allows to create an application lock or free it and send and receive messages between first and subsequent instances.

- * - * This class is intended for passing a list of strings instead of a single string from the subsequent instance to the first instance.

- * - *
- *	// unique application ID
- *	String APP_ID = "tk.pratanumandal.unique4j-mlsdvo-20191511-#j.6";
- *	
- *	// create Instance instance
- *	Instance unique = new InstanceList(APP_ID) {
- *	    @Override
- *	    protected List<String> sendMessageList() {
- *	        List<String> messageList = new ArrayList<String>();
- *	        
- *	        messageList.add("Message 1");
- *	        messageList.add("Message 2");
- *	        messageList.add("Message 3");
- *	        messageList.add("Message 4");
- *	        
- *	        return messageList;
- *	    }
- *
- *	    @Override
- *	    protected void receiveMessageList(List<String> messageList) {
- *	        for (String message : messageList) {
- *	            System.out.println(message);
- *	        }
- *	    }
- *	};
- *	
- *	// try to obtain lock
- *	try {
- *	    unique.acquireLock();
- *	} catch (InstanceException e) {
- *	    e.printStackTrace();
- *	}
- *	
- *	...
- *	
- *	// try to free the lock before exiting program
- *	try {
- *	    unique.freeLock();
- *	} catch (InstanceException e) {
- *	    e.printStackTrace();
- *	}
- * 
- * - * @author Pratanu Mandal - * @since 1.3 - * - */ public abstract class InstanceList extends Instance { - /** - * Parameterized constructor.
- * This constructor configures to automatically exit the application for subsequent instances.

- * - * The APP_ID must be as unique as possible. - * Avoid generic names like "my_app_id" or "hello_world".
- * A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters. - * - * @param APP_ID Unique string representing the application ID - */ - public InstanceList(String APP_ID) { - super(APP_ID); + public InstanceList(String applicationId) { + super(applicationId); } - /** - * Parameterized constructor.
- * This constructor allows to explicitly specify the exit strategy for subsequent instances.

- * - * The APP_ID must be as unique as possible. - * Avoid generic names like "my_app_id" or "hello_world".
- * A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters. - * - * @param APP_ID Unique string representing the application ID - * @param AUTO_EXIT If true, automatically exit the application for subsequent instances - */ - public InstanceList(String APP_ID, boolean AUTO_EXIT) { - super(APP_ID, AUTO_EXIT); + public InstanceList(String applicationId, boolean autoExit) { + super(applicationId, autoExit); } /** @@ -101,16 +27,15 @@ public abstract class InstanceList extends Instance { */ @Override protected final void receiveMessage(String message) { - if (message == null) { + if(message == null) { receiveMessageList(null); - } - else { + } else { // parse the JSON array string into an array of string arguments JsonArray jsonArgs = JsonParser.parseString(message).getAsJsonArray(); List stringArgs = new ArrayList(jsonArgs.size()); - for (int i = 0; i < jsonArgs.size(); i++) { + for(int i = 0; i < jsonArgs.size(); i++) { JsonElement element = jsonArgs.get(i); stringArgs.add(element.getAsString()); } @@ -137,10 +62,11 @@ public abstract class InstanceList extends Instance { JsonArray jsonArgs = new JsonArray(); List stringArgs = sendMessageList(); + if(stringArgs == null) { + return null; + } - if (stringArgs == null) return null; - - for (String arg : stringArgs) { + for(String arg : stringArgs) { jsonArgs.add(arg); } @@ -168,5 +94,4 @@ public abstract class InstanceList extends Instance { * @return list of messages sent from subsequent instances */ protected abstract List sendMessageList(); - } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java index 89363744..1af64d79 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java @@ -535,7 +535,7 @@ public class Storage { return certsDir; } - static File getSparrowDir() { + public static File getSparrowDir() { File sparrowDir; if(Network.get() != Network.MAINNET) { sparrowDir = new File(getSparrowHome(), Network.get().getName()); @@ -551,7 +551,11 @@ public class Storage { } public static File getSparrowHome() { - if(System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) { + return getSparrowHome(false); + } + + public static File getSparrowHome(boolean useDefault) { + if(!useDefault && System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) { return new File(System.getProperty(SparrowWallet.APP_HOME_PROPERTY)); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 0424ed9b..888bbd77 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -812,12 +812,18 @@ public class ElectrumServer { return transactionMap; } - public Map getFeeEstimates(List targetBlocks) throws ServerException { + public Map getFeeEstimates(List targetBlocks, boolean useCached) throws ServerException { Map targetBlocksFeeRatesSats = getDefaultFeeEstimates(targetBlocks); FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource(); feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource); - if(Network.get().equals(Network.MAINNET)) { + if(!feeRatesSource.isExternal()) { + targetBlocksFeeRatesSats.putAll(feeRatesSource.getBlockTargetFeeRates(targetBlocksFeeRatesSats)); + } else if(useCached) { + if(AppServices.getTargetBlockFeeRates() != null) { + targetBlocksFeeRatesSats.putAll(AppServices.getTargetBlockFeeRates()); + } + } else if(Network.get().equals(Network.MAINNET)) { targetBlocksFeeRatesSats.putAll(feeRatesSource.getBlockTargetFeeRates(targetBlocksFeeRatesSats)); } @@ -1204,7 +1210,7 @@ public class ElectrumServer { String banner = electrumServer.getServerBanner(); - Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, true); Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); feeRatesRetrievedAt = System.currentTimeMillis(); @@ -1220,7 +1226,7 @@ public class ElectrumServer { long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt; if(elapsed > FEE_RATES_PERIOD) { - Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false); Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); feeRatesRetrievedAt = System.currentTimeMillis(); return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes); @@ -1679,9 +1685,8 @@ public class ElectrumServer { return new Task<>() { protected FeeRatesUpdatedEvent call() throws ServerException { ElectrumServer electrumServer = new ElectrumServer(); - Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE); - Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); - return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false); + return new FeeRatesUpdatedEvent(blockTargetFeeRates, null); } }; } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java index e3f112b8..0a2fb5df 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java @@ -9,27 +9,27 @@ import java.util.LinkedHashMap; import java.util.Map; public enum FeeRatesSource { - ELECTRUM_SERVER("Server") { + ELECTRUM_SERVER("Server", false) { @Override public Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates) { return Collections.emptyMap(); } }, - MEMPOOL_SPACE("mempool.space") { + MEMPOOL_SPACE("mempool.space", true) { @Override public Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates) { String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/fees/recommended" : "https://mempool.space/api/v1/fees/recommended"; return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url); } }, - BITCOINFEES_EARN_COM("bitcoinfees.earn.com") { + BITCOINFEES_EARN_COM("bitcoinfees.earn.com", true) { @Override public Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates) { String url = "https://bitcoinfees.earn.com/api/v1/fees/recommended"; return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url); } }, - MINIMUM("Minimum (1 sat/vB)") { + MINIMUM("Minimum (1 sat/vB)", false) { @Override public Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates) { Map blockTargetFeeRates = new LinkedHashMap<>(); @@ -40,7 +40,7 @@ public enum FeeRatesSource { return blockTargetFeeRates; } }, - OXT_ME("oxt.me") { + OXT_ME("oxt.me", true) { @Override public Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates) { String url = AppServices.isUsingProxy() ? "http://oxtwshnfyktikbflierkwcxxksbonl6v73l5so5zky7ur72w52tktkid.onion/stats/global/mempool" : "https://api.oxt.me/stats/global/mempool"; @@ -63,9 +63,11 @@ public enum FeeRatesSource { public static final int BLOCKS_IN_TWO_HOURS = 12; private final String name; + private final boolean external; - FeeRatesSource(String name) { + FeeRatesSource(String name, boolean external) { this.name = name; + this.external = external; } public abstract Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates); @@ -74,6 +76,10 @@ public enum FeeRatesSource { return name; } + public boolean isExternal() { + return external; + } + private static Map getThreeTierFeeRates(FeeRatesSource feeRatesSource, Map defaultblockTargetFeeRates, String url) { if(log.isInfoEnabled()) { log.info("Requesting fee rates from " + url); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java b/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java index ab1548c4..1f4fa27c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/TorUtils.java @@ -1,16 +1,23 @@ package com.sparrowwallet.sparrow.net; import com.google.common.net.HostAndPort; +import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.sparrow.AppServices; import io.matthewnelson.kmp.tor.controller.common.control.usecase.TorControlSignal; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; +import java.io.*; import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.file.Files; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class TorUtils { private static final Logger log = LoggerFactory.getLogger(TorUtils.class); + private static final Pattern TOR_OK = Pattern.compile("^2\\d{2}[ -]OK$"); + private static final Pattern TOR_AUTH_METHODS = Pattern.compile("^2\\d{2}[ -]AUTH METHODS=(\\S+)\\s?(COOKIEFILE=\"?(.+?)\"?)?$"); public static void changeIdentity(HostAndPort proxy) { if(AppServices.isTorRunning()) { @@ -22,16 +29,62 @@ public class TorUtils { } else { HostAndPort control = HostAndPort.fromParts(proxy.getHost(), proxy.getPort() + 1); try(Socket socket = new Socket(control.getHost(), control.getPort())) { - writeNewNym(socket); + socket.setSoTimeout(1500); + if(authenticate(socket)) { + writeNewNym(socket); + } + } catch(TorAuthenticationException e) { + log.warn("Error authenticating to Tor at " + control + ", server returned " + e.getMessage()); + } catch(SocketTimeoutException e) { + log.warn("Timeout reading from " + control + ", is this a Tor ControlPort?"); } catch(Exception e) { log.warn("Error connecting to " + control + ", no Tor ControlPort configured?"); } } } + private static boolean authenticate(Socket socket) throws IOException, TorAuthenticationException { + socket.getOutputStream().write("PROTOCOLINFO\r\n".getBytes()); + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + String line; + File cookieFile = null; + while((line = reader.readLine()) != null) { + Matcher authMatcher = TOR_AUTH_METHODS.matcher(line); + if(authMatcher.matches()) { + String methods = authMatcher.group(1); + if(methods.contains("COOKIE") && !authMatcher.group(3).isEmpty()) { + cookieFile = new File(authMatcher.group(3)); + } + } + if(TOR_OK.matcher(line).matches()) { + break; + } + } + + if(cookieFile != null && cookieFile.exists()) { + byte[] cookieBytes = Files.readAllBytes(cookieFile.toPath()); + String authentication = "AUTHENTICATE " + Utils.bytesToHex(cookieBytes) + "\r\n"; + socket.getOutputStream().write(authentication.getBytes()); + } else { + socket.getOutputStream().write("AUTHENTICATE \"\"\r\n".getBytes()); + } + + line = reader.readLine(); + if(TOR_OK.matcher(line).matches()) { + return true; + } else { + throw new TorAuthenticationException(line); + } + } + private static void writeNewNym(Socket socket) throws IOException { log.debug("Sending NEWNYM to " + socket); - socket.getOutputStream().write("AUTHENTICATE \"\"\r\n".getBytes()); socket.getOutputStream().write("SIGNAL NEWNYM\r\n".getBytes()); } + + private static class TorAuthenticationException extends Exception { + public TorAuthenticationException(String message) { + super(message); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index fd98e6a6..6d97dedc 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -906,7 +906,7 @@ public class HeadersController extends TransactionFormController implements Init //TODO: Remove once Cobo Vault support has been removed boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT)); - boolean addBbqrOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD) || keystore.getSource().equals(KeystoreSource.SW_WATCH)); + boolean addBbqrOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD) || keystore.getSource().equals(KeystoreSource.SW_WATCH) || keystore.getSource().equals(KeystoreSource.SW_SEED)); boolean selectBbqrOption = headersForm.getSigningWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD)); //Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.css b/src/main/resources/com/sparrowwallet/sparrow/app.css index 16bee435..2a3a668c 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.css +++ b/src/main/resources/com/sparrowwallet/sparrow/app.css @@ -16,6 +16,21 @@ -fx-font-size: 20; } +.background-link { + -fx-padding: 0; + -fx-border-width: 0; +} + +.background-link:visited, +.background-link:hover:armed { + -fx-text-fill: -fx-accent; + -fx-underline: false; +} + +.background-link:hover:visited { + -fx-underline: true; +} + .drag-over > .background-text { -fx-fill: #383a42; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index e7893fe3..56fb044f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -143,7 +143,7 @@ - + @@ -156,12 +156,13 @@ - + - - - - + + + + + diff --git a/src/main/resources/com/sparrowwallet/sparrow/darktheme.css b/src/main/resources/com/sparrowwallet/sparrow/darktheme.css index 1c10033d..87575cb4 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/darktheme.css +++ b/src/main/resources/com/sparrowwallet/sparrow/darktheme.css @@ -156,11 +156,25 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{ -fx-border-color: #626367; } -.root .wallet-subtabs > .tab-header-area .tab { +.root .tab-pane > .tab-header-area > .headers-region { + -fx-color: derive(-fx-base, 40%); + -fx-mark-color: ladder(-fx-base, white 30%, derive(-fx-base,-63%) 31%); +} + +.root .tab-pane > .tab-header-area > .headers-region > .tab .tab-label .label { + -fx-text-fill: derive(white, -8%); +} + +.root .tab-pane > .tab-header-area > .headers-region > .tab:selected .tab-label .label, +.root .tab-pane > .tab-header-area > .headers-region > .tab:hover .tab-label .label { + -fx-text-fill: white; +} + +.root .wallet-subtabs > .tab-header-area > .headers-region > .tab { -fx-background-color: derive(#2284bb, 32%); } -.root .wallet-subtabs > .tab-header-area .tab:selected { +.root .wallet-subtabs > .tab-header-area > .headers-region > .tab:selected { -fx-background-color: #2284bb; } diff --git a/src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java b/src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java index 97101c5c..4b6ef525 100644 --- a/src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java +++ b/src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java @@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.MnemonicException; import com.sparrowwallet.drongo.wallet.Wallet; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -15,6 +16,7 @@ import java.io.*; public class StorageTest extends IoTest { @Test public void loadWallet() throws IOException, MnemonicException, StorageException { + System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, "true"); Storage storage = new Storage(getFile("sparrow-single-wallet")); Wallet wallet = storage.loadEncryptedWallet("pass").getWallet(); Assertions.assertTrue(wallet.isValid()); @@ -64,6 +66,7 @@ public class StorageTest extends IoTest { @Test public void saveWallet() throws IOException, MnemonicException, StorageException { + System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, "true"); Storage storage = new Storage(getFile("sparrow-single-wallet")); Wallet wallet = storage.loadEncryptedWallet("pass").getWallet(); Assertions.assertTrue(wallet.isValid()); @@ -80,4 +83,9 @@ public class StorageTest extends IoTest { wallet = temp2Storage.loadEncryptedWallet("pass").getWallet(); Assertions.assertTrue(wallet.isValid()); } + + @AfterEach + void tearDown() { + System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, "false"); + } } diff --git a/src/test/resources/com/sparrowwallet/sparrow/io/cc-multisig-export-1.txt b/src/test/resources/com/sparrowwallet/sparrow/io/cc-multisig-export-1.txt index 6868af5f..06dc0999 100644 --- a/src/test/resources/com/sparrowwallet/sparrow/io/cc-multisig-export-1.txt +++ b/src/test/resources/com/sparrowwallet/sparrow/io/cc-multisig-export-1.txt @@ -2,7 +2,7 @@ # Name: CC-2-of-4 Policy: 2 of 4 -Derivation: m/48'/1'/0'/2' +Derivation: m/48'/0'/0'/2' Format: P2WSH 0F056943: xpub6EfEGa5isJbQFSswM5Uptw5BSq2Td1ZDJr3QUNUcMySpC7itZ3ccypVHtLPnvMzKQ2qxrAgH49vhVxRcaQLFbixAVRR8RACrYTp88Uv9h8Z