diff --git a/build.gradle b/build.gradle index ab66358d..55f16c4e 100644 --- a/build.gradle +++ b/build.gradle @@ -71,9 +71,6 @@ dependencies { } implementation('dev.bwt:bwt-jni:0.1.7') implementation('net.sourceforge.javacsv:javacsv:2.0') - implementation('tk.pratanumandal:unique4j:1.3') { - exclude group: 'com.google.code.gson' - } implementation('org.slf4j:jul-to-slf4j:1.7.30') { exclude group: 'org.slf4j' } diff --git a/src/main/java/com/sparrowwallet/sparrow/MainApp.java b/src/main/java/com/sparrowwallet/sparrow/MainApp.java index cf7cb8d8..77480188 100644 --- a/src/main/java/com/sparrowwallet/sparrow/MainApp.java +++ b/src/main/java/com/sparrowwallet/sparrow/MainApp.java @@ -15,6 +15,8 @@ import com.sparrowwallet.sparrow.net.PublicElectrumServer; import com.sparrowwallet.sparrow.net.ServerType; import com.sparrowwallet.sparrow.preferences.PreferenceGroup; import com.sparrowwallet.sparrow.preferences.PreferencesDialog; +import com.sparrowwallet.sparrow.instance.InstanceException; +import com.sparrowwallet.sparrow.instance.InstanceList; import javafx.application.Application; import javafx.scene.text.Font; import javafx.stage.Stage; @@ -23,8 +25,6 @@ import org.controlsfx.tools.Platform; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.bridge.SLF4JBridgeHandler; -import tk.pratanumandal.unique4j.Unique4jList; -import tk.pratanumandal.unique4j.exception.Unique4jException; import java.io.File; import java.util.*; @@ -39,7 +39,7 @@ public class MainApp extends Application { private Stage mainStage; - private static SparrowUnique sparrowUnique; + private static SparrowInstance sparrowInstance; @Override public void init() throws Exception { @@ -122,8 +122,8 @@ public class MainApp extends Application { public void stop() throws Exception { AppServices.get().stop(); mainStage.close(); - if(sparrowUnique != null) { - sparrowUnique.freeLock(); + if(sparrowInstance != null) { + sparrowInstance.freeLock(); } } @@ -175,9 +175,9 @@ public class MainApp extends Application { List fileUriArguments = jCommander.getUnknownOptions(); try { - sparrowUnique = new SparrowUnique(fileUriArguments); - sparrowUnique.acquireLock(); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired - } catch(Unique4jException e) { + sparrowInstance = new SparrowInstance(fileUriArguments); + sparrowInstance.acquireLock(); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired + } catch(InstanceException e) { getLogger().error("Could not access application lock", e); } @@ -194,10 +194,10 @@ public class MainApp extends Application { return LoggerFactory.getLogger(MainApp.class); } - private static class SparrowUnique extends Unique4jList { + private static class SparrowInstance extends InstanceList { private final List fileUriArguments; - public SparrowUnique(List fileUriArguments) { + public SparrowInstance(List fileUriArguments) { super(MainApp.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty()); this.fileUriArguments = fileUriArguments; } diff --git a/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java b/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java new file mode 100644 index 00000000..09c7cc8f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/instance/Instance.java @@ -0,0 +1,531 @@ +/** + * 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 java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.RandomAccessFile; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; + +/** + * The 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 { + + // 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; + + /** + * 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) { + exception.printStackTrace(); + } + + /** + * 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() {} + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/instance/InstanceException.java b/src/main/java/com/sparrowwallet/sparrow/instance/InstanceException.java new file mode 100644 index 00000000..5bbaf5c4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/instance/InstanceException.java @@ -0,0 +1,69 @@ +/** + * Copyright 2019 Pratanu Mandal + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.sparrowwallet.sparrow.instance; + +/** + * The InstanceException class is a wrapper for all exceptions thrown from Instance. + * + * @author Pratanu Mandal + * @since 1.1 + * + */ +public class InstanceException extends Exception { + + private static final long serialVersionUID = 268060627071973613L; + + /** + * Constructs a new exception with null as its detail message. + */ + public InstanceException() { + super(); + } + + /** + * Constructs a new exception with the specified detail message.
+ * The cause is not initialized, and may subsequently be initialized by a call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + */ + public InstanceException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause.

+ * Note that the detail message associated with cause is not automatically incorporated in this exception's detail message. + * + * @param message the detail message (which is saved for later retrieval by the {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). (A null value is permitted, and indicates that the cause is nonexistent or unknown.) + */ + public InstanceException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified cause and a detail message of (cause==null ? null : cause.toString()) (which typically contains the class and detail message of cause).
+ * This constructor is useful for exceptions that are little more than wrappers for other throwables (for example, {@link java.security.PrivilegedActionException}). + * + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). (A null value is permitted, and indicates that the cause is nonexistent or unknown.) + */ + public InstanceException(Throwable cause) { + super(cause); + } + +} diff --git a/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java b/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java new file mode 100644 index 00000000..44cfd8c2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/instance/InstanceList.java @@ -0,0 +1,172 @@ +package com.sparrowwallet.sparrow.instance; + +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +/** + * The 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); + } + + /** + * 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); + } + + /** + * Internal method used in first instance to receive and parse messages from subsequent instances.
+ * The use of this method directly in InstanceList is discouraged. Use receiveMessageList() instead.

+ * + * This method is not synchronized. + * + * @param message message received by first instance from subsequent instances + */ + @Override + protected final void receiveMessage(String message) { + if (message == null) { + receiveMessageList(null); + } + else { + // parse the JSON array string into an array of string arguments + JsonArray jsonArgs = JsonParser.parseString(message).getAsJsonArray(); + + List stringArgs = new ArrayList(jsonArgs.size()); + + for (int i = 0; i < jsonArgs.size(); i++) { + JsonElement element = jsonArgs.get(i); + stringArgs.add(element.getAsString()); + } + + // return the parsed string list + receiveMessageList(stringArgs); + } + } + + /** + * Internal method used in subsequent instances to parse and send message to first instance.
+ * The use of this method directly in InstanceList is discouraged. Use sendMessageList() instead.

+ * + * 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 + */ + @Override + protected final String sendMessage() { + // convert arguments to JSON array string + JsonArray jsonArgs = new JsonArray(); + + List stringArgs = sendMessageList(); + + if (stringArgs == null) return null; + + for (String arg : stringArgs) { + jsonArgs.add(arg); + } + + // return the JSON array string + return jsonArgs.toString(); + } + + /** + * Method used in first instance to receive list of messages from subsequent instances.

+ * + * This method is not synchronized. + * + * @param messageList list of messages received by first instance from subsequent instances + */ + protected abstract void receiveMessageList(List messageList); + + /** + * Method used in subsequent instances to send list of messages 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 list of messages sent from subsequent instances + */ + protected abstract List sendMessageList(); + +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 4317a529..88c42f04 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -27,7 +27,7 @@ open module com.sparrowwallet.sparrow { requires bwt.jni; requires jtorctl; requires javacsv; - requires unique4j; requires jul.to.slf4j; requires bridj; + requires com.google.gson; } \ No newline at end of file