add welcome and preferences dialogs

This commit is contained in:
Craig Raw 2020-06-05 13:29:43 +02:00
parent 6731823bef
commit 1e250193fd
27 changed files with 1184 additions and 40 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -48,8 +48,15 @@ dependencies {
mainClassName = 'com.sparrowwallet.sparrow/com.sparrowwallet.sparrow.MainApp'
compileJava {
options.with {
fork = true
compilerArgs.addAll(["--add-exports", "org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow"])
}
}
run {
applicationDefaultJvmArgs = ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png", "--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls", "--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls", "--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow"]
applicationDefaultJvmArgs = ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png", "--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls", "--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls", "--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow", "--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow"]
}
jlink {
@ -64,7 +71,7 @@ jlink {
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png']
launcher {
name = 'sparrow'
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls", "--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls", "--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow"]
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls", "--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls", "--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow", "--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow"]
}
addExtraDependencies("javafx")
jpackage {

View file

@ -70,6 +70,9 @@ public class AppController implements Initializable {
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
ElectrumServer.PingService pingService = new ElectrumServer.PingService();
//pingService.
}
void initializeView() {

View file

@ -1,10 +1,11 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.WelcomeDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
@ -13,6 +14,8 @@ import javafx.scene.image.Image;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.GlyphFontRegistry;
import java.util.Optional;
public class MainApp extends Application {
@Override
@ -20,6 +23,21 @@ public class MainApp extends Application {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Mode mode = Config.get().getMode();
if(true || mode == null) {
WelcomeDialog welcomeDialog = new WelcomeDialog(getHostServices());
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
if(optionalMode.isPresent()) {
mode = optionalMode.get();
Config.get().setMode(mode);
if(mode.equals(Mode.ONLINE)) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
preferencesDialog.showAndWait();
}
}
}
FXMLLoader transactionLoader = new FXMLLoader(getClass().getResource("app.fxml"));
Parent root = transactionLoader.load();
AppController appController = transactionLoader.getController();
@ -31,17 +49,10 @@ public class MainApp extends Application {
stage.setMinWidth(650);
stage.setMinHeight(700);
stage.setScene(scene);
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/sparrow.png")));
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow.png")));
appController.initializeView();
Wallet wallet = new Wallet();
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(ScriptType.P2WPKH);
// KeystoreImportDialog dlg = new KeystoreImportDialog(wallet);
// dlg.showAndWait();
stage.show();
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow;
public enum Mode {
OFFLINE, ONLINE
}

View file

@ -0,0 +1,102 @@
package com.sparrowwallet.sparrow.control;
import java.text.DecimalFormatSymbols;
import java.util.regex.Pattern;
import javafx.beans.NamedArg;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextFormatter.Change;
public class TextFieldValidator {
private static final String CURRENCY_SYMBOL = DecimalFormatSymbols.getInstance().getCurrencySymbol();
private static final char DECIMAL_SEPARATOR = DecimalFormatSymbols.getInstance().getDecimalSeparator();
private final Pattern INPUT_PATTERN;
public TextFieldValidator(@NamedArg("modus") ValidationModus modus, @NamedArg("countOf") int countOf) {
this(modus.createPattern(countOf));
}
public TextFieldValidator(@NamedArg("regex") String regex) {
this(Pattern.compile(regex));
}
public TextFieldValidator(Pattern inputPattern) {
INPUT_PATTERN = inputPattern;
}
public static TextFieldValidator maxFractionDigits(int countOf) {
return new TextFieldValidator(maxFractionPattern(countOf));
}
public static TextFieldValidator maxIntegers(int countOf) {
return new TextFieldValidator(maxIntegerPattern(countOf));
}
public static TextFieldValidator integersOnly() {
return new TextFieldValidator(integersOnlyPattern());
}
public TextFormatter<Object> getFormatter() {
return new TextFormatter<>(this::validateChange);
}
private Change validateChange(Change c) {
if (validate(c.getControlNewText())) {
return c;
}
return null;
}
public boolean validate(String input) {
return INPUT_PATTERN.matcher(input).matches();
}
private static Pattern maxFractionPattern(int countOf) {
return Pattern.compile("\\d*(\\\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?");
}
private static Pattern maxCurrencyFractionPattern(int countOf) {
return Pattern.compile("^\\\\" + CURRENCY_SYMBOL + "?\\s?\\d*(\\\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?\\s?\\\\" + CURRENCY_SYMBOL + "?");
}
private static Pattern maxIntegerPattern(int countOf) {
return Pattern.compile("\\d{0," + countOf + "}");
}
private static Pattern integersOnlyPattern() {
return Pattern.compile("\\d*");
}
public enum ValidationModus {
MAX_CURRENCY_FRACTION_DIGITS {
@Override
public Pattern createPattern(int countOf) {
return maxCurrencyFractionPattern(countOf);
}
},
MAX_FRACTION_DIGITS {
@Override
public Pattern createPattern(int countOf) {
return maxFractionPattern(countOf);
}
},
MAX_INTEGERS {
@Override
public Pattern createPattern(int countOf) {
return maxIntegerPattern(countOf);
}
},
INTEGERS_ONLY {
@Override
public Pattern createPattern(int countOf) {
return integersOnlyPattern();
}
};
public abstract Pattern createPattern(int countOf);
}
}

View file

@ -0,0 +1,16 @@
package com.sparrowwallet.sparrow.control;
import impl.org.controlsfx.skin.ToggleSwitchSkin;
import javafx.scene.control.Skin;
import org.controlsfx.control.ToggleSwitch;
public class UnlabeledToggleSwitch extends ToggleSwitch {
@Override protected Skin<?> createDefaultSkin() {
return new ToggleSwitchSkin(this) {
@Override
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) - 20;
}
};
}
}

View file

@ -23,7 +23,7 @@ public class WalletNameDialog extends Dialog<String> {
this.name = (CustomTextField)TextFields.createClearableTextField();
final DialogPane dialogPane = getDialogPane();
setTitle("Wallet Password");
setTitle("Wallet Name");
dialogPane.setHeaderText("Enter a name for this wallet:");
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);

View file

@ -0,0 +1,105 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.Mode;
import javafx.application.HostServices;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.controlsfx.control.HyperlinkLabel;
import org.controlsfx.control.StatusBar;
import org.controlsfx.control.ToggleSwitch;
public class WelcomeDialog extends Dialog<Mode> {
private static final String[] ELECTRUM_SERVERS = new String[]{
"ElectrumX", "https://github.com/spesmilo/electrumx",
"electrs", "https://github.com/romanz/electrs",
"esplora-electrs", "https://github.com/Blockstream/electrs",
"Electrum Personal Server", "https://github.com/chris-belcher/electrum-personal-server",
"Bitcoin Wallet Tracker", "https://github.com/shesek/bwt"};
private final HostServices hostServices;
public WelcomeDialog(HostServices services) {
this.hostServices = services;
final DialogPane dialogPane = getDialogPane();
setTitle("Welcome to Sparrow");
dialogPane.setHeaderText("Welcome to Sparrow!");
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(500);
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
final ButtonType onlineButtonType = new javafx.scene.control.ButtonType("Configure Now", ButtonBar.ButtonData.OK_DONE);
final ButtonType offlineButtonType = new javafx.scene.control.ButtonType("Configure Later or Use Offline", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(onlineButtonType, offlineButtonType);
final VBox content = new VBox(20);
content.setPadding(new Insets(20, 20, 20, 20));
content.getChildren().add(createParagraph("Sparrow can operate in both an online and offline mode. In the online mode it connects to your Electrum server to display transaction history. In the offline mode it is useful as a transaction editor and as an airgapped multisig coordinator."));
content.getChildren().add(createParagraph("For privacy and security reasons it is not recommended to use a public Electrum server. Install an Electrum server that connects to your full node to index the blockchain and provide full privacy. Examples include:"));
VBox linkBox = new VBox();
for(int i = 0; i < ELECTRUM_SERVERS.length; i+=2) {
linkBox.getChildren().add(createBulletedLink(ELECTRUM_SERVERS[i], ELECTRUM_SERVERS[i+1]));
}
content.getChildren().add(linkBox);
content.getChildren().add(createParagraph("You can change your mode at any time using the toggle in the status bar:"));
content.getChildren().add(createStatusBar(onlineButtonType, offlineButtonType));
dialogPane.setContent(content);
setResultConverter(dialogButton -> dialogButton == onlineButtonType ? Mode.ONLINE : Mode.OFFLINE);
}
private Label createParagraph(String text) {
Label label = new Label(text);
label.setWrapText(true);
return label;
}
private HyperlinkLabel createBulletedLink(String name, String url) {
HyperlinkLabel label = new HyperlinkLabel(" \u2022 [" + name + "]");
label.setOnAction(event -> {
hostServices.showDocument(url);
});
return label;
}
private StatusBar createStatusBar(ButtonType onlineButtonType, ButtonType offlineButtonType) {
StatusBar statusBar = new StatusBar();
statusBar.setText("Online Mode");
statusBar.getRightItems().add(createToggle(statusBar, onlineButtonType, offlineButtonType));
return statusBar;
}
private ToggleSwitch createToggle(StatusBar statusBar, ButtonType onlineButtonType, ButtonType offlineButtonType) {
ToggleSwitch toggleSwitch = new UnlabeledToggleSwitch();
toggleSwitch.selectedProperty().addListener((observable, oldValue, newValue) -> {
Button onlineButton = (Button) getDialogPane().lookupButton(onlineButtonType);
onlineButton.setDefaultButton(newValue);
Button offlineButton = (Button) getDialogPane().lookupButton(offlineButtonType);
offlineButton.setDefaultButton(!newValue);
statusBar.setText(newValue ? "Online Mode" : "Offline Mode");
});
toggleSwitch.setSelected(true);
return toggleSwitch;
}
}

View file

@ -15,13 +15,16 @@ public class FontAwesome5 extends GlyphFont {
* The individual glyphs offered by the FontAwesome5 font.
*/
public static enum Glyph implements INamedCharacter {
CHECK_CIRCLE('\uf058'),
CIRCLE('\uf111'),
EXCLAMATION_CIRCLE('\uf06a'),
ELLIPSIS_H('\uf141'),
EYE('\uf06e'),
KEY('\uf084'),
LAPTOP('\uf109'),
LOCK('\uf023'),
LOCK_OPEN('\uf3c1'),
QUESTION_CIRCLE('\uf059'),
SD_CARD('\uf7c2'),
WALLET('\uf555');

View file

@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.sparrow.Mode;
import java.io.*;
import java.lang.reflect.Type;
@ -8,10 +9,13 @@ import java.lang.reflect.Type;
public class Config {
public static final String CONFIG_FILENAME = ".config";
private Mode mode;
private Integer keyDerivationPeriod;
private File hwi;
private String electrumServer;
private File electrumServerCert;
private boolean useProxy;
private String proxyServer;
private static Config INSTANCE;
@ -54,6 +58,15 @@ public class Config {
return INSTANCE;
}
public Mode getMode() {
return mode;
}
public void setMode(Mode mode) {
this.mode = mode;
flush();
}
public Integer getKeyDerivationPeriod() {
return keyDerivationPeriod;
}
@ -90,6 +103,24 @@ public class Config {
flush();
}
public boolean isUseProxy() {
return useProxy;
}
public void setUseProxy(boolean useProxy) {
this.useProxy = useProxy;
flush();
}
public String getProxyServer() {
return proxyServer;
}
public void setProxyServer(String proxyServer) {
this.proxyServer = proxyServer;
flush();
}
private void flush() {
Gson gson = getGson();
try {

View file

@ -14,6 +14,8 @@ import org.jetbrains.annotations.NotNull;
import javax.net.SocketFactory;
import javax.net.ssl.*;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.Socket;
import java.security.*;
import java.security.cert.Certificate;
@ -24,13 +26,16 @@ import java.util.*;
import java.util.stream.Collectors;
public class ElectrumServer {
private static final String[] SUPPORTED_VERSIONS = new String[]{"1.3", "1.4.2"};
private static Transport transport;
private synchronized Transport getTransport() throws ServerException {
private static synchronized Transport getTransport() throws ServerException {
if(transport == null) {
try {
String electrumServer = Config.get().getElectrumServer();
File electrumServerCert = Config.get().getElectrumServerCert();
String proxyServer = Config.get().getProxyServer();
if(electrumServer == null) {
throw new ServerException("Electrum server URL not specified");
@ -46,7 +51,21 @@ public class ElectrumServer {
}
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
transport = protocol.getTransport(server, electrumServerCert);
if(Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) {
HostAndPort proxy = HostAndPort.fromString(proxyServer);
if(electrumServerCert != null) {
transport = protocol.getTransport(server, electrumServerCert, proxy);
} else {
transport = protocol.getTransport(server, proxy);
}
} else {
if(electrumServerCert != null) {
transport = protocol.getTransport(server, electrumServerCert);
} else {
transport = protocol.getTransport(server);
}
}
} catch (Exception e) {
throw new ServerException(e);
}
@ -55,10 +74,31 @@ public class ElectrumServer {
return transport;
}
public String getServerVersion() throws ServerException {
public void ping() throws ServerException {
JsonRpcClient client = new JsonRpcClient(getTransport());
List<String> serverVersion = client.createRequest().returnAsList(String.class).method("server.version").id(1).param("client_name", "Sparrow").param("protocol_version", "1.4").execute();
return serverVersion.get(1);
client.createRequest().returnAs(Void.class).method("server.ping").id(1).execute();
}
public List<String> getServerVersion() throws ServerException {
JsonRpcClient client = new JsonRpcClient(getTransport());
return client.createRequest().returnAsList(String.class).method("server.version").id(1).param("client_name", "Sparrow").param("protocol_version", SUPPORTED_VERSIONS).execute();
}
public String getServerBanner() throws ServerException {
JsonRpcClient client = new JsonRpcClient(getTransport());
return client.createRequest().returnAs(String.class).method("server.banner").id(1).execute();
}
public static synchronized void closeActiveConnection() throws ServerException {
try {
if(transport != null) {
Closeable closeableTransport = (Closeable)transport;
closeableTransport.close();
transport = null;
}
} catch (IOException e) {
throw new ServerException(e);
}
}
public Map<WalletNode, Set<BlockTransactionHash>> getHistory(Wallet wallet) throws ServerException {
@ -327,11 +367,11 @@ public class ElectrumServer {
}
}
private static class TcpTransport implements Transport {
private static final int DEFAULT_PORT = 50001;
public static class TcpTransport implements Transport, Closeable {
public static final int DEFAULT_PORT = 50001;
protected final HostAndPort server;
private final SocketFactory socketFactory;
protected final SocketFactory socketFactory;
private Socket socket;
@ -376,12 +416,19 @@ public class ElectrumServer {
protected Socket createSocket() throws IOException {
return socketFactory.createSocket(server.getHost(), server.getPortOrDefault(DEFAULT_PORT));
}
@Override
public void close() throws IOException {
if(socket != null) {
socket.close();
}
}
}
private static class TcpOverTlsTransport extends TcpTransport {
private static final int DEFAULT_PORT = 50002;
public static class TcpOverTlsTransport extends TcpTransport {
public static final int DEFAULT_PORT = 50002;
private final SSLSocketFactory sslSocketFactory;
protected final SSLSocketFactory sslSocketFactory;
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException {
super(server);
@ -428,6 +475,70 @@ public class ElectrumServer {
}
}
public static class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
public static final int DEFAULT_PROXY_PORT = 1080;
private HostAndPort proxy;
public ProxyTcpOverTlsTransport(HostAndPort server, HostAndPort proxy) throws KeyManagementException, NoSuchAlgorithmException {
super(server);
this.proxy = proxy;
}
public ProxyTcpOverTlsTransport(HostAndPort server, File crtFile, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
super(server, crtFile);
this.proxy = proxy;
}
@Override
protected Socket createSocket() throws IOException {
InetSocketAddress proxyAddr = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT));
Socket underlying = new Socket(new Proxy(Proxy.Type.SOCKS, proxyAddr));
underlying.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(DEFAULT_PORT)));
SSLSocket sslSocket = (SSLSocket)sslSocketFactory.createSocket(underlying, proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT), true);
sslSocket.startHandshake();
return sslSocket;
}
}
public static class ServerVersionService extends Service<List<String>> {
@Override
protected Task<List<String>> createTask() {
return new Task<List<String>>() {
protected List<String> call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
return electrumServer.getServerVersion();
}
};
}
}
public static class ServerBannerService extends Service<String> {
@Override
protected Task<String> createTask() {
return new Task<>() {
protected String call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
return electrumServer.getServerBanner();
}
};
}
}
public static class PingService extends Service<Boolean> {
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
electrumServer.ping();
return true;
}
};
}
}
public static class TransactionHistoryService extends Service<Boolean> {
private final Wallet wallet;
@ -452,23 +563,55 @@ public class ElectrumServer {
public enum Protocol {
TCP {
@Override
public Transport getTransport(HostAndPort server, File serverCert) throws IOException {
public Transport getTransport(HostAndPort server) {
return new TcpTransport(server);
}
@Override
public Transport getTransport(HostAndPort server, File serverCert) {
return new TcpTransport(server);
}
@Override
public Transport getTransport(HostAndPort server, HostAndPort proxy) {
throw new UnsupportedOperationException("TCP protocol does not support proxying");
}
@Override
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) {
throw new UnsupportedOperationException("TCP protocol does not support proxying");
}
},
SSL{
@Override
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
return new TcpOverTlsTransport(server);
}
@Override
public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
if(serverCert != null && serverCert.exists()) {
return new TcpOverTlsTransport(server, serverCert);
} else {
return new TcpOverTlsTransport(server);
}
return new TcpOverTlsTransport(server, serverCert);
}
@Override
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws NoSuchAlgorithmException, KeyManagementException {
return new ProxyTcpOverTlsTransport(server, proxy);
}
@Override
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
return new ProxyTcpOverTlsTransport(server, serverCert, proxy);
}
};
public abstract Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException;
public abstract Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public abstract Transport getTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public abstract Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public HostAndPort getServerHostAndPort(String url) {
return HostAndPort.fromString(url.substring(this.toUrlString().length()));
}
@ -477,6 +620,18 @@ public class ElectrumServer {
return toString().toLowerCase() + "://";
}
public String toUrlString(String host) {
return toUrlString(HostAndPort.fromHost(host));
}
public String toUrlString(String host, int port) {
return toUrlString(HostAndPort.fromParts(host, port));
}
public String toUrlString(HostAndPort hostAndPort) {
return toUrlString() + hostAndPort.toString();
}
public static Protocol getProtocol(String url) {
if(url.startsWith("tcp://")) {
return TCP;

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.preferences;
public enum PreferenceGroup {
GENERAL, SERVER;
}

View file

@ -0,0 +1,78 @@
package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.keystoreimport.KeystoreImportDetailController;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.StackPane;
import java.io.IOException;
import java.net.URL;
import java.util.ResourceBundle;
public class PreferencesController implements Initializable {
private Config config;
@FXML
private ToggleGroup preferencesMenu;
@FXML
private StackPane preferencesPane;
@Override
public void initialize(URL location, ResourceBundle resources) {
}
public Config getConfig() {
return config;
}
public void initializeView(Config config) {
this.config = config;
preferencesMenu.selectedToggleProperty().addListener((observable, oldValue, selectedToggle) -> {
if(selectedToggle == null) {
oldValue.setSelected(true);
return;
}
PreferenceGroup preferenceGroup = (PreferenceGroup) selectedToggle.getUserData();
String fxmlName = preferenceGroup.toString().toLowerCase();
setPreferencePane(fxmlName);
});
}
public void selectGroup(PreferenceGroup preferenceGroup) {
for(Toggle toggle : preferencesMenu.getToggles()) {
if(toggle.getUserData().equals(preferenceGroup)) {
Platform.runLater(() -> preferencesMenu.selectToggle(toggle));
return;
}
}
}
FXMLLoader setPreferencePane(String fxmlName) {
preferencesPane.getChildren().removeAll(preferencesPane.getChildren());
try {
FXMLLoader preferencesDetailLoader = new FXMLLoader(AppController.class.getResource("preferences/" + fxmlName + ".fxml"));
Node preferenceGroupNode = preferencesDetailLoader.load();
PreferencesDetailController controller = preferencesDetailLoader.getController();
controller.setMasterController(this);
controller.initializeView(config);
preferencesPane.getChildren().add(preferenceGroupNode);
return preferencesDetailLoader;
} catch (IOException e) {
throw new IllegalStateException("Can't find pane", e);
}
}
}

View file

@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.sparrow.io.Config;
import javafx.fxml.Initializable;
public abstract class PreferencesDetailController {
private PreferencesController masterController;
public PreferencesController getMasterController() {
return masterController;
}
void setMasterController(PreferencesController masterController) {
this.masterController = masterController;
}
public void initializeView(Config config) {
}
}

View file

@ -0,0 +1,33 @@
package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.io.Config;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import org.controlsfx.tools.Borders;
import java.io.IOException;
public class PreferencesDialog extends Dialog<Void> {
public PreferencesDialog(PreferenceGroup initialGroup) {
final DialogPane dialogPane = getDialogPane();
try {
FXMLLoader preferencesLoader = new FXMLLoader(AppController.class.getResource("preferences/preferences.fxml"));
dialogPane.setContent(Borders.wrap(preferencesLoader.load()).lineBorder().outerPadding(0).innerPadding(0).buildAll());
PreferencesController preferencesController = preferencesLoader.getController();
preferencesController.initializeView(Config.get());
preferencesController.selectGroup(initialGroup);
final ButtonType closeButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(closeButtonType);
dialogPane.setPrefWidth(650);
dialogPane.setPrefHeight(500);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,306 @@
package com.sparrowwallet.sparrow.preferences;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.control.TextFieldValidator;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ElectrumServer;
import com.sparrowwallet.sparrow.io.ServerException;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.jetbrains.annotations.NotNull;
import javax.net.ssl.SSLHandshakeException;
import java.io.File;
import java.io.FileInputStream;
import java.security.cert.CertificateFactory;
import java.util.List;
public class ServerPreferencesController extends PreferencesDetailController {
@FXML
private TextField host;
@FXML
private TextField port;
@FXML
private UnlabeledToggleSwitch useSsl;
@FXML
private TextField certificate;
@FXML
private Button certificateSelect;
@FXML
private UnlabeledToggleSwitch useProxy;
@FXML
private TextField proxyHost;
@FXML
private TextField proxyPort;
@FXML
private Button testConnection;
@FXML
private TextArea testResults;
private final ValidationSupport validationSupport = new ValidationSupport();
@Override
public void initializeView(Config config) {
Platform.runLater(this::setupValidation);
port.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
proxyPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
host.textProperty().addListener(getElectrumServerListener(config));
port.textProperty().addListener(getElectrumServerListener(config));
proxyHost.textProperty().addListener(getProxyListener(config));
proxyPort.textProperty().addListener(getProxyListener(config));
useSsl.selectedProperty().addListener((observable, oldValue, newValue) -> {
setElectrumServerInConfig(config);
certificate.setDisable(!newValue);
certificateSelect.setDisable(!newValue);
});
certificate.textProperty().addListener((observable, oldValue, newValue) -> {
File crtFile = getCertificate(newValue);
if(crtFile != null) {
config.setElectrumServerCert(crtFile);
} else {
config.setElectrumServerCert(null);
}
});
certificateSelect.setOnAction(event -> {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Select Electrum Server certificate");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*.*"),
new FileChooser.ExtensionFilter("CRT", "*.crt")
);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
certificate.setText(file.getAbsolutePath());
}
});
useProxy.selectedProperty().addListener((observable, oldValue, newValue) -> {
config.setUseProxy(newValue);
proxyHost.setText(proxyHost.getText() + " ");
proxyHost.setText(proxyHost.getText().trim());
proxyHost.setDisable(!newValue);
proxyPort.setDisable(!newValue);
if(newValue) {
useSsl.setSelected(true);
useSsl.setDisable(true);
} else {
useSsl.setDisable(false);
}
});
testConnection.setOnAction(event -> {
try {
ElectrumServer.closeActiveConnection();
} catch (ServerException e) {
testResults.setText("Failed to disconnect:\n" + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()));
}
ElectrumServer.ServerVersionService serverVersionService = new ElectrumServer.ServerVersionService();
serverVersionService.setOnSucceeded(successEvent -> {
List<String> serverVersion = serverVersionService.getValue();
testResults.setText("Connected to " + serverVersion.get(0) + " on protocol version " + serverVersion.get(1));
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE, Color.rgb(80, 161, 79)));
ElectrumServer.ServerBannerService serverBannerService = new ElectrumServer.ServerBannerService();
serverBannerService.setOnSucceeded(bannerSuccessEvent -> {
testResults.setText(testResults.getText() + "\nServer Banner: " + serverBannerService.getValue());
});
serverBannerService.setOnFailed(bannerFailEvent -> {
testResults.setText(testResults.getText() + "\nServer Banner: None");
});
serverBannerService.start();
});
serverVersionService.setOnFailed(failEvent -> {
Throwable e = failEvent.getSource().getException();
String reason = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
if(e.getCause() != null && e.getCause() instanceof SSLHandshakeException) {
reason = "SSL Handshake Error\n" + reason;
}
testResults.setText("Could not connect:\n\n" + reason);
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.EXCLAMATION_CIRCLE, Color.rgb(202, 18, 67)));
});
testResults.setText("Connecting to " + config.getElectrumServer() + "...");
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
serverVersionService.start();
});
String electrumServer = config.getElectrumServer();
if(electrumServer != null) {
ElectrumServer.Protocol protocol = ElectrumServer.Protocol.getProtocol(electrumServer);
if(protocol != null) {
boolean ssl = protocol.equals(ElectrumServer.Protocol.SSL);
useSsl.setSelected(ssl);
certificate.setDisable(!ssl);
certificateSelect.setDisable(!ssl);
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
host.setText(server.getHost());
if(server.hasPort()) {
port.setText(Integer.toString(server.getPort()));
}
}
}
File certificateFile = config.getElectrumServerCert();
if(certificateFile != null) {
certificate.setText(certificateFile.getAbsolutePath());
}
useProxy.setSelected(config.isUseProxy());
proxyHost.setDisable(!config.isUseProxy());
proxyPort.setDisable(!config.isUseProxy());
if(config.isUseProxy()) {
useSsl.setSelected(true);
useSsl.setDisable(true);
}
String proxyServer = config.getProxyServer();
if(proxyServer != null) {
HostAndPort server = HostAndPort.fromString(proxyServer);
proxyHost.setText(server.getHost());
if(server.hasPort()) {
proxyPort.setText(Integer.toString(server.getPort()));
}
}
}
private void setupValidation() {
validationSupport.registerValidator(host, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid host name", getHost(newValue) == null)
));
validationSupport.registerValidator(port, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
));
validationSupport.registerValidator(proxyHost, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Proxy host required", useProxy.isSelected() && newValue.isEmpty()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid host name", getHost(newValue) == null)
));
validationSupport.registerValidator(proxyPort, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid proxy port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
));
validationSupport.registerValidator(certificate, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid certificate file", newValue != null && !newValue.isEmpty() && getCertificate(newValue) == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
}
@NotNull
private ChangeListener<String> getElectrumServerListener(Config config) {
return (observable, oldValue, newValue) -> {
setElectrumServerInConfig(config);
};
}
private void setElectrumServerInConfig(Config config) {
String hostAsString = getHost(host.getText());
Integer portAsInteger = getPort(port.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
config.setElectrumServer(getProtocol().toUrlString(hostAsString, portAsInteger));
} else if(hostAsString != null) {
config.setElectrumServer(getProtocol().toUrlString(hostAsString));
}
}
@NotNull
private ChangeListener<String> getProxyListener(Config config) {
return (observable, oldValue, newValue) -> {
String hostAsString = getHost(proxyHost.getText());
Integer portAsInteger = getPort(proxyPort.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
config.setProxyServer(HostAndPort.fromParts(hostAsString, portAsInteger).toString());
} else if(hostAsString != null) {
config.setProxyServer(HostAndPort.fromHost(hostAsString).toString());
}
};
}
private ElectrumServer.Protocol getProtocol() {
return (useSsl.isSelected() ? ElectrumServer.Protocol.SSL : ElectrumServer.Protocol.TCP);
}
private String getHost(String text) {
try {
return HostAndPort.fromHost(text).getHost();
} catch(IllegalArgumentException e) {
return null;
}
}
private Integer getPort(String text) {
try {
return Integer.parseInt(text);
} catch(NumberFormatException e) {
return null;
}
}
private File getCertificate(String crtFileLocation) {
try {
File crtFile = new File(crtFileLocation);
if(!crtFile.exists()) {
return null;
}
CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
return crtFile;
} catch (Exception e) {
return null;
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName, Color color) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(13);
if(color != null) {
glyph.setColor(color);
}
return glyph;
}
private static boolean isValidPort(int port) {
return port >= 0 && port <= 65535;
}
}

BIN
src/main/resources/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -30,10 +30,6 @@
-fx-padding: -20 0 0 0;
}
.toggle-switch {
-fx-translate-x: -20px;
}
.tab-error > .tab-container {
-fx-effect: dropshadow(three-pass-box, rgba(202, 18, 67, .6), 7, 0, 0, 0);
}
@ -84,18 +80,18 @@
-fx-border-color: transparent;
}
.titled-description-pane .hyperlink {
.hyperlink {
-fx-padding: 0;
-fx-border-width: 0;
-fx-fill: #1e88cf;
}
.titled-description-pane .hyperlink:visited {
.hyperlink:visited {
-fx-text-fill: #1e88cf;
-fx-underline: false;
}
.titled-description-pane .hyperlink:hover:visited {
.hyperlink:hover:visited {
-fx-underline: true;
}

View file

@ -0,0 +1,34 @@
.dialog-pane .content {
-fx-padding: 0;
}
.list-menu {
-fx-pref-width: 160;
-fx-background-color: #3da0e3;
}
.list-item {
-fx-pref-width: 160;
-fx-padding: 0 20 0 20;
-fx-background-color: #3da0e3;
}
.list-item * {
-fx-fill: #fff;
}
.list-item:hover {
-fx-background-color: #4aa7e5;
}
.list-item:selected {
-fx-background-color: #1e88cf;
}
#preferencesPane {
-fx-background-color: -fx-background;
}
.scroll-pane {
-fx-background-color: transparent;
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.preferences.PreferenceGroup?>
<?import org.controlsfx.glyphfont.Glyph?>
<BorderPane stylesheets="@../general.css, @preferences.css" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.sparrowwallet.sparrow.preferences.PreferencesController">
<padding>
<Insets top="0" left="0" right="0" bottom="0" />
</padding>
<left>
<VBox styleClass="list-menu">
<ToggleButton VBox.vgrow="ALWAYS" text="Server" wrapText="true" textAlignment="CENTER" contentDisplay="TOP" styleClass="list-item" maxHeight="Infinity" toggleGroup="$preferencesMenu">
<toggleGroup>
<ToggleGroup fx:id="preferencesMenu" />
</toggleGroup>
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="SERVER" />
</graphic>
<userData>
<PreferenceGroup fx:constant="SERVER"/>
</userData>
</ToggleButton>
</VBox>
</left>
<center>
<StackPane fx:id="preferencesPane">
</StackPane>
</center>
</BorderPane>

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>
<?import tornadofx.control.Form?>
<?import tornadofx.control.Fieldset?>
<?import tornadofx.control.Field?>
<?import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch?>
<?import org.controlsfx.glyphfont.Glyph?>
<GridPane hgap="10.0" vgap="10.0" stylesheets="@preferences.css, @../general.css" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.preferences.ServerPreferencesController">
<padding>
<Insets left="25.0" right="25.0" top="25.0" />
</padding>
<columnConstraints>
<ColumnConstraints percentWidth="100" />
</columnConstraints>
<rowConstraints>
<RowConstraints />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Electrum Server">
<Field text="URL:">
<TextField fx:id="host" promptText="e.g. 127.0.0.1"/>
<TextField fx:id="port" promptText="e.g. 50002" prefWidth="80" />
</Field>
<Field text="Use SSL:">
<UnlabeledToggleSwitch fx:id="useSsl"/>
</Field>
<Field text="Certificate:" styleClass="label-button">
<TextField fx:id="certificate" editable="false" promptText="Optional server certificate (.crt)"/>
<Button fx:id="certificateSelect" maxWidth="25" minWidth="-Infinity" prefWidth="30" text="Ed">
<graphic>
<Glyph fontFamily="FontAwesome" icon="EDIT" prefWidth="15" />
</graphic>
</Button>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Proxy">
<Field text="Use Proxy:">
<UnlabeledToggleSwitch fx:id="useProxy"/>
</Field>
<Field text="Proxy URL:">
<TextField fx:id="proxyHost" />
<TextField fx:id="proxyPort" prefWidth="80" />
</Field>
</Fieldset>
</Form>
<StackPane GridPane.columnIndex="0" GridPane.rowIndex="1">
<Button fx:id="testConnection" text="Test Connection">
<graphic>
<Glyph fontFamily="FontAwesome" icon="QUESTION_CIRCLE" prefWidth="13" />
</graphic>
</Button>
</StackPane>
<StackPane GridPane.columnIndex="0" GridPane.rowIndex="2">
<padding>
<Insets top="10.0" bottom="20.0"/>
</padding>
<TextArea fx:id="testResults" editable="false" wrapText="true"/>
</StackPane>
</GridPane>

View file

@ -10,12 +10,12 @@
<?import org.controlsfx.control.SegmentedButton?>
<?import javafx.collections.FXCollections?>
<?import java.lang.String?>
<?import org.controlsfx.control.ToggleSwitch?>
<?import com.sparrowwallet.sparrow.control.RelativeTimelockSpinner?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
<?import com.sparrowwallet.sparrow.control.IdLabel?>
<?import com.sparrowwallet.sparrow.control.CoinLabel?>
<?import com.sparrowwallet.sparrow.control.AddressLabel?>
<?import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch?>
<GridPane hgap="10.0" vgap="10.0" stylesheets="@input.css, @../script.css, @../general.css" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.transaction.InputController">
<padding>
@ -90,7 +90,7 @@
<CopyableLabel fx:id="signatures" />
</Field>
<Field text="RBF:">
<ToggleSwitch fx:id="rbf"/>
<UnlabeledToggleSwitch fx:id="rbf"/>
</Field>
</Fieldset>
</Form>

BIN
src/main/resources/image/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:vectornator="http://vectornator.io" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.1" viewBox="0 0 472.646 589">
<metadata>
<vectornator:setting key="DynamicGuides" value="1"/>
<vectornator:setting key="CMYKEnabledKey" value="0"/>
<vectornator:setting key="Units" value="Points"/>
<vectornator:setting key="RulersVisible" value="1"/>
<vectornator:setting key="SnapToGuides" value="1"/>
<vectornator:setting key="GridSpacing" value="36"/>
</metadata>
<defs>
<path d="M0+0L472.646+0L472.646+589L0+589L0+0Z" id="Mask"/>
</defs>
<g id="Untitled" vectornator:layerName="Untitled">
<path d="M438.26+269.704L431.407+280.358L472.645+295.31L472.645+291.106L446.023+267.188L438.26+269.704Z" opacity="1" fill="#d1d3d3"/>
<path d="M425.612+299.312L436.32+298.817L456.732+297.318L472.646+295.311L431.408+280.359L419.107+275.902L425.612+299.312Z" opacity="1" fill="#8a8c8c"/>
<path d="M438.26+269.704L431.407+280.358L419.107+275.901L426.428+273.532L438.26+269.704Z" opacity="1" fill="#aba89e"/>
<path d="M425.612+299.312L361.204+299.312L398.039+282.487L425.612+299.312Z" opacity="1" fill="#8a8c8c"/>
<path d="M426.428+273.532L413.377+264.257L409.403+261.432L437.216+239.682L446.024+267.188L438.26+269.704L426.428+273.532Z" opacity="1" fill="#99999a"/>
<path d="M411.397+268.192L407.783+273.238L402.389+264.618L402.456+264.551L411.397+268.192Z" opacity="1" fill="#ffa400"/>
<path d="M409.403+261.433L413.378+264.257L411.397+268.192L402.456+264.552L406.632+260.804L409.403+261.433Z" opacity="1" fill="#ff5000"/>
<path d="M437.216+239.682L409.403+261.432L399.685+219.859L437.015+239.066L437.216+239.682Z" opacity="1" fill="#8a8c8c"/>
<path d="M407.783+273.238L411.397+268.192L413.378+264.257L426.429+273.532L419.107+275.902L407.14+274.135L407.783+273.238Z" opacity="1" fill="#3e4543"/>
<path d="M402.389+264.618L407.783+273.238L407.14+274.135L396.178+270.2L402.389+264.618Z" opacity="1" fill="#80622d"/>
<path d="M407.14+274.135L419.107+275.902L398.186+282.42L397.691+282.286L397.677+282.273L358.848+271.458L396.178+270.2L407.14+274.135Z" opacity="1" fill="#d3ccbd"/>
<path d="M402.455+264.552L402.389+264.619L398.922+259.064L406.632+260.804L402.455+264.552Z" opacity="1" fill="#ef7622"/>
<path d="M398.922+259.064L402.389+264.619L396.178+270.2L395.429+262.557L398.922+259.064Z" opacity="1" fill="#d49000"/>
<path d="M399.685+219.859L409.403+261.433L406.632+260.803L398.922+259.064L391.989+257.484L398.641+219.926L399.685+219.859Z" opacity="1" fill="#d8d1ca"/>
<path d="M398.641+219.926L391.989+257.484L374.776+221.359L398.641+219.926Z" opacity="1" fill="#bcb9af"/>
<path d="M398.186+282.42L419.107+275.901L425.612+299.312L398.039+282.487L398.186+282.42Z" opacity="1" fill="#d8d1ca"/>
<path d="M398.038+282.487L361.203+299.312L360.373+299.312L357.59+271.498L358.848+271.458L397.677+282.273L397.69+282.286L398.038+282.487Z" opacity="1" fill="#333e48"/>
<path d="M360.373+299.312L392.845+334.5L386.487+341.648L386.166+342.947L385.952+343.094L331.944+314.531L360.373+299.312Z" opacity="1" fill="#d1d3d3"/>
<path d="M374.776+221.359L391.989+257.484L357.617+270.963L374.776+221.359Z" opacity="1" fill="#7d868c"/>
<path d="M386.113+343.174L379.769+368.766L368.566+406.391L329.402+383.208L385.953+343.094L386.113+343.174Z" opacity="1" fill="#555759"/>
<path d="M385.952+343.094L329.401+383.208L331.944+314.531L385.952+343.094Z" opacity="1" fill="#99999a"/>
<path d="M356.399+270.775L324.235+265.689L343.214+246.709L348.649+241.275L374.776+221.358L357.617+270.962L356.399+270.775Z" opacity="1" fill="#5c6670"/>
<path d="M368.566+406.391L367.468+407.984L367.455+407.984L295.23+409.094L295.216+409.094L295.083+408.814L294.641+407.876L329.402+383.208L368.566+406.391Z" opacity="1" fill="#d1d3d3"/>
<path d="M367.454+407.984L343.147+443.025L319.228+456.437L295.229+409.094L367.454+407.984Z" opacity="1" fill="#7d868c"/>
<path d="M361.203+299.312L425.611+299.312L412.895+310.529L404.195+321.732L392.845+334.5L360.373+299.312L361.203+299.312Z" opacity="1" fill="#333e48"/>
<path d="M357.59+271.498L360.373+299.311L331.944+314.53L324.235+265.689L356.399+270.775L357.549+271.096L357.59+271.498Z" opacity="1" fill="#bcb9af"/>
<path d="M357.59+271.498L358.848+271.458L357.55+271.097L357.59+271.498Z" opacity="1" fill="#383a35"/>
<path d="M391.989+257.484L398.923+259.064L395.429+262.557L396.178+270.2L358.848+271.458L357.55+271.096L356.399+270.775L357.617+270.963L391.989+257.484Z" opacity="1" fill="#333e48"/>
<g opacity="1" vectornator:mask="#Mask">
<clipPath id="ClipPath">
<use xlink:href="#Mask" fill="none" overflow="visible"/>
</clipPath>
<g clip-path="url(#ClipPath)">
<g opacity="1">
<path d="M368.07+495.066C368.566+496.565+367.562+501.771+367.562+501.771L361.365+509.467L363.064+502.762L365.567+497.26L362.569+489.564L348.154+483.354L351.861+490.555L355.863+501.771L351.352+505.265L344.647+513.978L346.655+505.773L351.352+499.763C351.352+499.763+348.649+494.062+348.154+492.054C347.645+490.059+338.945+482.845+338.945+482.845L336.442+478.348L350.188+476.42L350.202+476.42L369.569+481.855L369.569+492.054L367.066+485.348L358.353+482.35L365.567+488.052C365.567+488.052+367.562+493.553+368.07+495.066" opacity="1" fill="#333e48"/>
<path d="M350.188+476.42L336.442+478.348L313.527+463.932L345.651+475.149L350.188+476.42Z" opacity="1" fill="#7f7f73"/>
<path d="M336.937+129.485L344.647+155.612L314.892+239.348L304.813+267.697L308.575+221.105L310.663+195.218L312.644+170.711L313.527+171.581L313.192+169.774L336.937+129.485Z" opacity="1" fill="#4f5858"/>
<path d="M344.647+155.612L336.937+219.364L324.235+265.689L314.892+239.347L344.647+155.612Z" opacity="1" fill="#4b4f54"/>
<path d="M336.937+219.364L343.214+246.709L324.235+265.689L336.937+219.364Z" opacity="1" fill="#3e4543"/>
<path d="M336.937+129.485L313.192+169.774L307.611+139.711L307.611+139.698L308.267+102.314C315.026+108.752+319.336+113.008+319.738+113.785C321.932+117.987+336.937+129.485+336.937+129.485" opacity="1" fill="#7d868c"/>
<path d="M336.442+478.348L322.241+474.64L302.819+466.141L313.527+463.932L336.442+478.348Z" opacity="1" fill="#d3ccbd"/>
<path d="M331.945+314.53L329.402+383.208L278.472+373.972L267.697+351.351L331.289+314.905L331.945+314.53Z" opacity="1" fill="#5c6670"/>
<path d="M330.927+314.289L331.288+314.905L267.697+351.351L299.539+313.941L300.316+313.031L330.927+314.289Z" opacity="1" fill="#5c6670"/>
<path d="M329.402+383.208L294.641+407.876L278.472+373.973L329.402+383.208Z" opacity="1" fill="#99999a"/>
<path d="M320.728+495.561L322.736+486.057L336.442+478.348L338.945+482.845L325.734+488.56L324.235+494.557L326.738+501.771L320.728+495.561Z" opacity="1" fill="#707c7c"/>
<path d="M314.892+239.347L324.235+265.689L304.813+267.696L314.892+239.347Z" opacity="1" fill="#5c6670"/>
<path d="M302.337+292.592L304.813+267.696L324.234+265.689L300.449+311.692L302.337+292.592Z" opacity="1" fill="#3e4543"/>
<path d="M319.229+456.437L313.527+463.932L302.819+466.141L289.528+459.395L319.229+456.437Z" opacity="1" fill="#707c7c"/>
<path d="M311.533+525.676L305.817+515.477L297.813+508.972L319.684+513.71L323.231+516.976C323.231+516.976+327.233+522.678+327.742+525.181C328.237+527.684+329.241+533.386+329.241+535.394C329.241+537.388+321.237+543.599+321.237+543.599L325.239+537.897L322.736+525.676L312.523+518.475C312.523+518.475+317.529+531.392+318.024+533.386C318.533+535.394+316.03+540.092+314.022+540.601C312.095+541.082+306.206+546.262+305.831+546.583L313.527+535.394L311.533+525.676Z" opacity="1" fill="#333e48"/>
<path d="M319.684+513.71L297.813+508.972L296.488+496.483L296.609+496.564L303.314+500.272L314.022+508.477L319.684+513.71Z" opacity="1" fill="#707c7c"/>
<path d="M312.644+170.71L310.663+195.218L199.247+122.284L169.319+98.3652L259.171+134.156L311.854+169.947L311.88+169.96L312.644+170.71Z" opacity="1" fill="#909c9c"/>
<path d="M311.854+169.947L259.171+134.156L199.943+93.908L176.828+66.241L167.124+54.034L167.124+45.829L172.825+41.827L197.734+58.237L311.854+169.947Z" opacity="1" fill="#7d868c"/>
<path d="M310.662+195.218L308.574+221.104L209.954+153.671L208.951+153.404L176.827+129.485L175.328+117.786L180.026+114.28L199.246+122.284L310.662+195.218Z" opacity="1" fill="#7d868c"/>
<path d="M308.267+102.314L307.611+139.698L307.611+139.711L307.584+141.464L199.568+24.2927L308.267+102.314Z" opacity="1" fill="#909c9c"/>
<path d="M308.267+102.314L199.568+24.2927L188.031+5.20669L191.738-0.000312805L200.746+2.99869C200.746+2.99869+278.405+73.8707+308.267+102.314" opacity="1" fill="#5c6670"/>
<path d="M200.238+25.4177L199.569+24.2927L307.585+141.464L307.611+139.711L313.192+169.773L312.644+170.711L311.881+169.961L311.854+169.947L197.735+58.2367L178.327+31.1197L176.319+21.4157L183.533+15.4057L200.238+25.4177Z" opacity="1" fill="#4f5858"/>
<path d="M304.653+267.603L220.663+190.238L209.446+178.527L203.664+168.568L203.678+168.568L308.575+221.104L304.813+267.696L304.653+267.603Z" opacity="1" fill="#4b4f54"/>
<path d="M304.813+267.697L302.337+292.593L251.783+239.576L304.653+267.604L304.813+267.697Z" opacity="1" fill="#3e4543"/>
<path d="M304.653+267.603L251.783+239.575L223.862+210.65L215.162+197.439L214.653+188.739L220.663+190.238L304.653+267.603Z" opacity="1" fill="#383a35"/>
<path d="M330.927+314.289L300.316+313.031L300.449+311.692L324.235+265.689L331.945+314.53L331.288+314.905L330.927+314.289Z" opacity="1" fill="#555759"/>
<path d="M300.316+313.031L299.54+313.941L298.134+313.887L300.316+313.031Z" opacity="1" fill="#a3402a"/>
<path d="M300.316+313.031L298.134+313.887L209.553+310.474L233.794+255.556L300.316+313.031Z" opacity="1" fill="#5c6670"/>
<path d="M299.539+313.942L267.696+351.352L225.2+370.399L222.657+368.07L203.45+366.062L195.245+361.645L248.275+333.444L298.134+313.888L299.539+313.942Z" opacity="1" fill="#555759"/>
<path d="M298.134+313.888L248.276+333.444L209.553+310.489L209.553+310.475L298.134+313.888Z" opacity="1" fill="#c3c6c8"/>
<path d="M294.614+503.27L281.778+487.088L296.488+496.484L297.813+508.972L292.111+517.98L291.107+526.68L296.609+536.893L288.604+530.883L286.41+519.185L289.608+512.479L294.614+503.27Z" opacity="1" fill="#3e4543"/>
<path d="M296.475+496.377L296.488+496.483L281.778+487.088L280.399+473.141L296.475+496.377Z" opacity="1" fill="#333e48"/>
<path d="M289.019+459.421L288.43+459.315L295.216+409.094L295.23+409.094L319.229+456.437L289.528+459.395L289.113+459.435L289.033+459.421L289.019+459.421Z" opacity="1" fill="#333e48"/>
<path d="M295.216+409.094L288.43+459.315L205.444+444.725L295.216+409.094Z" opacity="1" fill="#a0a1a2"/>
<path d="M295.216+409.094L205.444+444.725L254.299+386.903L295.216+409.094Z" opacity="1" fill="#8a8c8c"/>
<path d="M295.216+409.094L254.299+386.903L278.472+373.973L294.641+407.876L294.613+407.89L295.082+408.814L295.216+409.094Z" opacity="1" fill="#5c6670"/>
<path d="M289.113+459.435L289.528+459.395L302.819+466.141L280.399+473.141L289.113+459.435Z" opacity="1" fill="#5c6670"/>
<path d="M289.113+459.435L280.399+473.141L281.778+487.088L280.399+485.348L269.692+475.149L289.113+459.435Z" opacity="1" fill="#d3ccbd"/>
<path d="M289.113+459.435L269.692+475.149L263.495+476.849L250.043+459.595L288.418+459.421L289.019+459.421L289.033+459.421L289.113+459.435Z" opacity="1" fill="#7d868c"/>
<path d="M288.43+459.315L288.417+459.421L250.042+459.595L250.002+459.595L211.333+459.756L211.32+459.756L205.444+444.725L288.43+459.315Z" opacity="1" fill="#5c6670"/>
<path d="M267.697+351.352L278.472+373.973L254.3+386.903L267.697+351.352Z" opacity="1" fill="#333e48"/>
<path d="M252.158+478.535L263.495+476.848C263.495+476.848+233.366+547.573+227.021+563.073L252.158+478.535Z" opacity="1" fill="#555759"/>
<path d="M199.943+93.9079L259.172+134.156L169.319+98.3649L165.116+88.6609L167.124+81.6609L174.325+81.6609L200.746+94.8719L199.943+93.9079Z" opacity="1" fill="#707c7c"/>
<path d="M254.299+386.902L205.444+444.724L214.653+405.895L224.999+371.081L225.254+371.161L254.299+386.902Z" opacity="1" fill="#333e48"/>
<path d="M252.158+478.535L227.021+563.073C226.325+564.786+225.924+565.817+225.87+566.018C225.361+568.013+215.162+577.222+215.162+577.222L204.95+577.717L252.158+478.535Z" opacity="1" fill="#7d868c"/>
<path d="M250.002+459.595L250.042+459.595L263.494+476.849L252.157+478.535L229.858+481.855L250.002+459.595Z" opacity="1" fill="#5c6670"/>
<path d="M250.002+459.595L229.858+481.855L223.313+477.21L216.152+472.137L211.32+459.77L211.333+459.756L250.002+459.595Z" opacity="1" fill="#8a8c8c"/>
<path d="M248.276+333.444L195.245+361.645L195.232+361.645L193.237+360.561L185.836+352.25L209.553+310.489L248.276+333.444Z" opacity="1" fill="#7d868c"/>
<path d="M227.864+250.43L233.794+255.556L179.465+251.126L159.106+249.466L158.049+249.185L111.041+201.482L202.567+229.362L227.864+250.43Z" opacity="1" fill="#bcb9af"/>
<path d="M229.858+481.855L202.647+580.367C200.465+582.777+197.293+585.935+195.74+585.935C193.237+585.935+180.535+588.424+180.535+588.424L175.114+588.599L229.858+481.855Z" opacity="1" fill="#5c6670"/>
<path d="M229.858+481.855L175.114+588.599L163.228+589.001L153.403+587.929L151.49+585.039L223.313+477.21L229.858+481.855Z" opacity="1" fill="#555759"/>
<path d="M222.55+210.209L227.863+250.431L202.566+229.363L149.937+185.527L222.55+210.209Z" opacity="1" fill="#5c6670"/>
<path d="M225.254+371.161L225+371.081L225.2+370.398L267.697+351.351L254.299+386.902L225.254+371.161Z" opacity="1" fill="#8a8a8d"/>
<path d="M223.862+210.651L251.783+239.576L302.338+292.593L300.45+311.693L300.316+313.032L233.794+255.557L227.864+250.431L222.55+210.209L223.862+210.651Z" opacity="1" fill="#555759"/>
<path d="M223.313+477.21L151.49+585.038L142.696+585.935L131.493+581.719L127.491+572.215L223.313+477.21Z" opacity="1" fill="#8a8c8c"/>
<path d="M223.313+477.21L127.491+572.215L211.32+459.77L216.152+472.137L223.313+477.21Z" opacity="1" fill="#5c6670"/>
<path d="M211.333+459.756L211.32+459.77L211.32+459.756L211.333+459.756Z" opacity="1" fill="#7d868c"/>
<path d="M211.32+459.756L211.32+459.77L110.371+555.872L148.906+509.467L205.444+444.725L211.32+459.756Z" opacity="1" fill="#99999a"/>
<path d="M211.32+459.77L127.49+572.215L117.787+573.714L108.578+568.013L109.582+556.81L110.344+555.886L110.371+555.873L211.32+459.77Z" opacity="1" fill="#7d868c"/>
<path d="M233.793+255.557L209.553+310.475L209.54+310.475L209.178+310.261L179.464+251.126L233.793+255.557Z" opacity="1" fill="#7d868c"/>
<path d="M209.54+310.475L209.553+310.475L209.553+310.489L185.835+352.249L185.032+351.352L169.319+351.861L163.122+347.859L156.415+341.153L151.41+333.444L152.788+332.895L209.54+310.475Z" opacity="1" fill="#99999a"/>
<path d="M209.178+310.261L209.539+310.475L152.787+332.895L168.448+286.115L209.178+310.261Z" opacity="1" fill="#5c6670"/>
<path d="M204.949+577.717C204.949+577.717+203.985+578.895+202.647+580.366L229.858+481.855L252.157+478.535L204.949+577.717Z" opacity="1" fill="#949a90"/>
<path d="M202.941+167.324L202.446+152.908L208.951+153.404L209.955+153.671L308.574+221.104L203.677+168.569L203.664+168.569L202.941+167.324Z" opacity="1" fill="#3e4543"/>
<path d="M149.937+185.527L202.566+229.362L111.041+201.482L36.1253+155.117L149.923+185.527L149.937+185.527Z" opacity="1" fill="#d1d3d3"/>
<path d="M168.449+286.115L167.164+286.115L86.1585+286.115C86.1585+286.115+57.5415+270.2+57.0325+268.688C56.5375+267.189+52.0405+257.485+52.0405+257.485L53.0315+250.98L133.929+269.277L135.361+269.598L168.449+286.115Z" opacity="1" fill="#d1d3d3"/>
<path d="M168.449+286.115L152.788+332.895L151.41+333.444L139.203+332.44L129.499+328.946L123.288+322.227L120.986+312.858L167.164+286.115L168.449+286.115Z" opacity="1" fill="#99999a"/>
<path d="M167.163+286.115L120.985+312.858L120.785+312.028L94.8714+308.521L88.6614+298.817L86.1584+286.115L167.163+286.115Z" opacity="1" fill="#5c6670"/>
<path d="M111.041+201.482L158.048+249.186L156.309+248.717L35.7248+187.602L18.2038+173.32L4.0018+154.608L0.495804+143.204L2.9988+137.703L15.7008+144.904L36.1258+155.117L111.041+201.482Z" opacity="1" fill="#333e48"/>
<path d="M133.929+269.276L53.031+250.979L30.116+244.273L153.217+247.901L156.308+248.717L158.048+249.186L159.106+249.466L133.929+269.276Z" opacity="1" fill="#5c6670"/>
<path d="M133.929+269.276L159.106+249.466L179.464+251.126L209.179+310.261L168.449+286.114L135.361+269.597L133.929+269.276Z" opacity="1" fill="#99999a"/>
<path d="M149.924+185.527L36.1256+155.117L9.7036+122.284L8.6996+113.784L12.2066+109.582L54.0346+141.196C54.0346+141.196+108.578+167.819+111.576+169.827C114.575+171.821+136.7+181.03+136.7+181.03L149.924+185.527Z" opacity="1" fill="#99999a"/>
<path d="M153.217+247.901L38.9766+217.464L40.1276+217.651L11.7116+202.941L2.9986+190.239L-0.000396729+179.531L4.00259+175.328L37.1156+188.74L35.7246+187.602L156.309+248.717L153.217+247.901Z" opacity="1" fill="#99999a"/>
<path d="M38.9632+217.464L38.9772+217.464L153.216+247.901L30.1162+244.274L18.7122+232.575L14.7102+218.856L16.2092+213.649L38.9632+217.464Z" opacity="1" fill="#333e48"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB