Jelajahi Sumber

Merge pull request #27 from Tillerino/master

Single Running Instance + Double-clicking folders/files shows in GUI
Sebastian Stenzel 10 tahun lalu
induk
melakukan
72b1ff78c3

+ 4 - 0
main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/FileNamingConventions.java

@@ -63,4 +63,8 @@ interface FileNamingConventions {
 	 */
 	PathMatcher ENCRYPTED_FILE_GLOB_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**/*{" + BASIC_FILE_EXT + "," + LONG_NAME_FILE_EXT + "}");
 
+	/**
+	 * On OSX, folders with this extension are treated as a package.
+	 */
+	String FOLDER_EXTENSION = ".cryptomator";
 }

+ 21 - 2
main/pom.xml

@@ -32,8 +32,10 @@
 		<commons-collections.version>4.0</commons-collections.version>
 		<commons-lang3.version>3.3.2</commons-lang3.version>
 		<commons-codec.version>1.10</commons-codec.version>
-        	<jackson-databind.version>2.4.4</jackson-databind.version>
-        </properties>
+		<jackson-databind.version>2.4.4</jackson-databind.version>
+		<mockito.version>1.10.19</mockito.version>
+		<axetDesktop.version>2.2.3</axetDesktop.version>
+	</properties>
 
 	<dependencyManagement>
 		<dependencies>
@@ -117,6 +119,19 @@
 				<version>${junit.version}</version>
 				<scope>test</scope>
 			</dependency>
+
+			<dependency>
+				<groupId>org.mockito</groupId>
+				<artifactId>mockito-core</artifactId>
+				<version>${mockito.version}</version>
+				<scope>test</scope>
+			</dependency>
+
+			<dependency>
+				<groupId>com.github.axet</groupId>
+				<artifactId>desktop</artifactId>
+				<version>${axetDesktop.version}</version>
+			</dependency>
 		</dependencies>
 	</dependencyManagement>
 
@@ -137,6 +152,10 @@
 			<groupId>junit</groupId>
 			<artifactId>junit</artifactId>
 		</dependency>
+		<dependency>
+			<groupId>org.mockito</groupId>
+			<artifactId>mockito-core</artifactId>
+		</dependency>
 	</dependencies>
 
 	<modules>

+ 9 - 4
main/ui/pom.xml

@@ -19,7 +19,7 @@
 
 	<properties>
 		<javafx.application.name>Cryptomator</javafx.application.name>
-		<exec.mainClass>org.cryptomator.ui.MainApplication</exec.mainClass>
+		<exec.mainClass>org.cryptomator.ui.Main</exec.mainClass>
 		<javafx.tools.ant.jar>${java.home}/../lib/ant-javafx.jar</javafx.tools.ant.jar>
 		<controlsfx.version>8.20.8</controlsfx.version>
 	</properties>
@@ -52,9 +52,14 @@
 		
 		<!-- UI -->
 		<dependency>
-		    <groupId>org.controlsfx</groupId>
-		    <artifactId>controlsfx</artifactId>
-		    <version>${controlsfx.version}</version>
+			<groupId>org.controlsfx</groupId>
+			<artifactId>controlsfx</artifactId>
+			<version>${controlsfx.version}</version>
+		</dependency>
+
+		<dependency>
+			<groupId>com.github.axet</groupId>
+			<artifactId>desktop</artifactId>
 		</dependency>
 	</dependencies>
 

+ 76 - 0
main/ui/src/main/java/org/cryptomator/ui/Main.java

@@ -0,0 +1,76 @@
+/*******************************************************************************
+ * Copyright (c) 2014 cryptomator.org
+ * This file is licensed under the terms of the MIT license.
+ * See the LICENSE.txt file for more info.
+ * 
+ * Contributors:
+ *     Tillmann Gaida - initial implementation
+ ******************************************************************************/
+package org.cryptomator.ui;
+
+import java.io.File;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+import javafx.application.Application;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.cryptomator.ui.util.SingleInstanceManager;
+import org.cryptomator.ui.util.SingleInstanceManager.RemoteInstance;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.axet.desktop.os.mac.AppleHandlers;
+
+public class Main {
+	public static final Logger LOG = LoggerFactory.getLogger(MainApplication.class);
+
+	public static final CompletableFuture<Consumer<File>> OPEN_FILE_HANDLER = new CompletableFuture<>();
+
+	public static void main(String[] args) {
+		if (SystemUtils.IS_OS_MAC_OSX) {
+			/*
+			 * On OSX we're in an awkward position. We need to register a
+			 * handler in the main thread of this application. However, we can't
+			 * even pass objects to the application, so we're forced to use a
+			 * static CompletableFuture for the handler, which actually opens
+			 * the file in the application.
+			 */
+			try {
+				AppleHandlers.getAppleHandlers().addOpenFileListener(file -> {
+					try {
+						OPEN_FILE_HANDLER.get().accept(file);
+					} catch (Exception e) {
+						LOG.error("exception handling file open event", e);
+						throw new RuntimeException(e);
+					}
+				});
+			} catch (RuntimeException e) {
+				// Since we're trying to call OS-specific code, we'll just have
+				// to hope for the best.
+				LOG.error("exception adding OSX file open handler", e);
+			}
+		}
+
+		/*
+		 * Before starting the application, we check if there is already an
+		 * instance running on this computer. If so, we send our command line
+		 * arguments to that instance and quit.
+		 */
+		final Optional<RemoteInstance> remoteInstance = SingleInstanceManager.getRemoteInstance(MainApplication.APPLICATION_KEY);
+
+		if (remoteInstance.isPresent()) {
+			try (RemoteInstance instance = remoteInstance.get()) {
+				LOG.info("An instance of Cryptomator is already running at {}.", instance.getRemotePort());
+				for (int i = 0; i < args.length; i++) {
+					remoteInstance.get().sendMessage(args[i], 1000);
+				}
+			} catch (Exception e) {
+				LOG.error("Error forwarding arguments to remote instance", e);
+			}
+		} else {
+			Application.launch(MainApplication.class, args);
+		}
+	}
+}

+ 69 - 5
main/ui/src/main/java/org/cryptomator/ui/MainApplication.java

@@ -9,8 +9,13 @@
 package org.cryptomator.ui;
 
 import java.io.IOException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ResourceBundle;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 
 import javafx.application.Application;
 import javafx.application.Platform;
@@ -19,25 +24,39 @@ import javafx.scene.Parent;
 import javafx.scene.Scene;
 import javafx.stage.Stage;
 
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.SystemUtils;
+import org.cryptomator.crypto.aes256.Aes256Cryptor;
 import org.cryptomator.ui.settings.Settings;
 import org.cryptomator.ui.util.ActiveWindowStyleSupport;
+import org.cryptomator.ui.util.SingleInstanceManager;
+import org.cryptomator.ui.util.SingleInstanceManager.LocalInstance;
 import org.cryptomator.ui.util.TrayIconUtil;
 import org.cryptomator.webdav.WebDavServer;
 import org.eclipse.jetty.util.ConcurrentHashSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class MainApplication extends Application {
 
 	private static final Set<Runnable> SHUTDOWN_TASKS = new ConcurrentHashSet<>();
 	private static final CleanShutdownPerformer CLEAN_SHUTDOWN_PERFORMER = new CleanShutdownPerformer();
 
-	public static void main(String[] args) {
-		Application.launch(args);
-		Runtime.getRuntime().addShutdownHook(CLEAN_SHUTDOWN_PERFORMER);
-	}
+	public static final String APPLICATION_KEY = "CryptomatorGUI";
+
+	private static final Logger LOG = LoggerFactory.getLogger(MainApplication.class);
+
+	private ExecutorService executorService;
 
 	@Override
 	public void start(final Stage primaryStage) throws IOException {
+		Runtime.getRuntime().addShutdownHook(MainApplication.CLEAN_SHUTDOWN_PERFORMER);
+
+		executorService = Executors.newCachedThreadPool();
+		addShutdownTask(() -> {
+			executorService.shutdown();
+		});
+
 		WebDavServer.getInstance().start();
 		chooseNativeStylesheet();
 		final ResourceBundle rb = ResourceBundle.getBundle("localization");
@@ -55,6 +74,47 @@ public class MainApplication extends Application {
 		TrayIconUtil.init(primaryStage, rb, () -> {
 			quit();
 		});
+
+		for (String arg : getParameters().getUnnamed()) {
+			handleCommandLineArg(ctrl, arg);
+		}
+
+		if (org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.OSX)) {
+			Main.OPEN_FILE_HANDLER.complete(file -> handleCommandLineArg(ctrl, file.getAbsolutePath()));
+		}
+
+		LocalInstance cryptomatorGuiInstance = SingleInstanceManager.startLocalInstance(APPLICATION_KEY, executorService);
+		addShutdownTask(() -> {
+			cryptomatorGuiInstance.close();
+		});
+
+		cryptomatorGuiInstance.registerListener(arg -> handleCommandLineArg(ctrl, arg));
+	}
+
+	void handleCommandLineArg(final MainController ctrl, String arg) {
+		Path file = FileSystems.getDefault().getPath(arg);
+		if (!Files.exists(file)) {
+			try {
+				if (!Files.isDirectory(Files.createDirectories(file))) {
+					return;
+				}
+			} catch (IOException e) {
+				return;
+			}
+			// directory created.
+		} else if (Files.isRegularFile(file)) {
+			if (StringUtils.endsWithIgnoreCase(file.getFileName().toString(), Aes256Cryptor.MASTERKEY_FILE_EXT)) {
+				file = file.getParent();
+			} else {
+				// is a file, but not a masterkey file
+				return;
+			}
+		}
+		Path f = file;
+		Platform.runLater(() -> {
+			ctrl.addDirectory(f);
+			ctrl.toFront();
+		});
 	}
 
 	private void chooseNativeStylesheet() {
@@ -95,7 +155,11 @@ public class MainApplication extends Application {
 		@Override
 		public void run() {
 			SHUTDOWN_TASKS.forEach(r -> {
-				r.run();
+				try {
+					r.run();
+				} catch (RuntimeException e) {
+					LOG.error("exception while shutting down", e);
+				}
 			});
 			SHUTDOWN_TASKS.clear();
 		}

+ 24 - 2
main/ui/src/main/java/org/cryptomator/ui/MainController.java

@@ -11,6 +11,8 @@ package org.cryptomator.ui;
 import java.io.File;
 import java.io.IOException;
 import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Collection;
 import java.util.ResourceBundle;
 import java.util.stream.Collectors;
@@ -75,8 +77,19 @@ public class MainController implements Initializable, InitializationListener, Un
 	private void didClickAddDirectory(ActionEvent event) {
 		final DirectoryChooser dirChooser = new DirectoryChooser();
 		final File file = dirChooser.showDialog(stage);
-		if (file != null && file.canWrite()) {
-			final Directory dir = new Directory(file.toPath());
+		addDirectory(file.toPath());
+	}
+
+	/**
+	 * adds the given directory or selects it if it is already in the list of
+	 * directories.
+	 * 
+	 * @param file
+	 *            non-null, writable, existing directory
+	 */
+	void addDirectory(final Path file) {
+		if (file != null && Files.isWritable(file)) {
+			final Directory dir = new Directory(file);
 			if (!directoryList.getItems().contains(dir)) {
 				directoryList.getItems().add(dir);
 			}
@@ -199,4 +212,13 @@ public class MainController implements Initializable, InitializationListener, Un
 		this.stage = stage;
 	}
 
+	/**
+	 * Attempts to make the application window visible.
+	 */
+	public void toFront() {
+		stage.setIconified(false);
+		stage.show();
+		stage.toFront();
+	}
+
 }

+ 6 - 1
main/ui/src/main/java/org/cryptomator/ui/model/Directory.java

@@ -8,6 +8,7 @@ import java.nio.file.Path;
 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
 
+import org.apache.commons.lang3.StringUtils;
 import org.cryptomator.crypto.Cryptor;
 import org.cryptomator.crypto.SamplingDecorator;
 import org.cryptomator.crypto.aes256.Aes256Cryptor;
@@ -116,7 +117,11 @@ public class Directory implements Serializable {
 	 * @return Directory name without preceeding path components
 	 */
 	public String getName() {
-		return path.getFileName().toString();
+		String name = path.getFileName().toString();
+		if (StringUtils.endsWithIgnoreCase(name, Aes256Cryptor.FOLDER_EXTENSION)) {
+			name = name.substring(0, name.length() - Aes256Cryptor.FOLDER_EXTENSION.length());
+		}
+		return name;
 	}
 
 	public Cryptor getCryptor() {

+ 80 - 0
main/ui/src/main/java/org/cryptomator/ui/util/ListenerRegistry.java

@@ -0,0 +1,80 @@
+/*******************************************************************************
+ * Copyright (c) 2014 cryptomator.org
+ * This file is licensed under the terms of the MIT license.
+ * See the LICENSE.txt file for more info.
+ * 
+ * Contributors:
+ *     Tillmann Gaida - initial implementation
+ ******************************************************************************/
+package org.cryptomator.ui.util;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.BiConsumer;
+
+/**
+ * Manages and broadcasts events to a set of listeners. The types of the
+ * listener and event are entirely unbound. Instead, a method must be supplied
+ * to broadcast an event to a single listener.
+ * 
+ * @author Tillmann Gaida
+ *
+ * @param <LISTENER>
+ *            The type of listener.
+ * @param <EVENT>
+ *            The type of event.
+ */
+public class ListenerRegistry<LISTENER, EVENT> {
+	final BiConsumer<LISTENER, EVENT> listenerCaller;
+
+	/**
+	 * Constructs a new registry.
+	 * 
+	 * @param listenerCaller
+	 *            The method which broadcasts an event to a single listener.
+	 */
+	public ListenerRegistry(BiConsumer<LISTENER, EVENT> listenerCaller) {
+		super();
+		this.listenerCaller = listenerCaller;
+	}
+
+	/**
+	 * The handle of a registered listener.
+	 */
+	public interface ListenerRegistration {
+		void unregister();
+	}
+
+	final AtomicLong serial = new AtomicLong();
+	/*
+	 * Since this is a {@link ConcurrentSkipListMap}, we can at the same time
+	 * add to, remove from, and iterate over it. More importantly, a Listener
+	 * can remove itself while being called from the {@link #broadcast(Object)}
+	 * method.
+	 */
+	final Map<Long, LISTENER> listeners = new ConcurrentSkipListMap<>();
+
+	public ListenerRegistration registerListener(LISTENER listener) {
+		final long s = serial.incrementAndGet();
+
+		listeners.put(s, listener);
+
+		return () -> {
+			listeners.remove(s);
+		};
+	}
+
+	/**
+	 * Broadcasts the given event to all registered listeners. If a listener
+	 * causes an unchecked exception, that exception is thrown immediately
+	 * without calling the other listeners.
+	 * 
+	 * @param event
+	 */
+	public void broadcast(EVENT event) {
+		for (LISTENER listener : listeners.values()) {
+			listenerCaller.accept(listener, event);
+		}
+	}
+}

+ 365 - 0
main/ui/src/main/java/org/cryptomator/ui/util/SingleInstanceManager.java

@@ -0,0 +1,365 @@
+/*******************************************************************************
+ * Copyright (c) 2014 Sebastian Stenzel
+ * This file is licensed under the terms of the MIT license.
+ * See the LICENSE.txt file for more info.
+ * 
+ * Contributors:
+ *     Sebastian Stenzel - initial API and implementation
+ ******************************************************************************/
+package org.cryptomator.ui.util;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
+import java.nio.channels.ClosedSelectorException;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.channels.SelectableChannel;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.nio.channels.ServerSocketChannel;
+import java.nio.channels.SocketChannel;
+import java.nio.channels.WritableByteChannel;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.prefs.Preferences;
+
+import org.apache.commons.io.IOUtils;
+import org.cryptomator.ui.Main;
+import org.cryptomator.ui.util.ListenerRegistry.ListenerRegistration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Classes and methods to manage running this application in a mode, which only
+ * shows one instance.
+ * 
+ * @author Tillmann Gaida
+ */
+public class SingleInstanceManager {
+	private static final Logger LOG = LoggerFactory.getLogger(SingleInstanceManager.class);
+
+	/**
+	 * Connection to a running instance
+	 */
+	public static class RemoteInstance implements Closeable {
+		final SocketChannel channel;
+
+		RemoteInstance(SocketChannel channel) {
+			super();
+			this.channel = channel;
+		}
+
+		/**
+		 * Sends a message to the running instance.
+		 * 
+		 * @param string
+		 *            May not be longer than 2^16 - 1 bytes.
+		 * @param timeout
+		 *            timeout in milliseconds. this should be larger than the
+		 *            precision of {@link System#currentTimeMillis()}.
+		 * @return true if the message was sent within the given timeout.
+		 * @throws IOException
+		 */
+		public boolean sendMessage(String string, long timeout) throws IOException {
+			Objects.requireNonNull(string);
+			byte[] message = string.getBytes();
+			if (message.length >= 256 * 256) {
+				throw new IOException("Message too long.");
+			}
+
+			ByteBuffer buf = ByteBuffer.allocate(message.length + 2);
+			buf.put((byte) (message.length / 256));
+			buf.put((byte) (message.length % 256));
+			buf.put(message);
+
+			buf.flip();
+			TimeoutTask.attempt(t -> {
+				if (channel.write(buf) < 0) {
+					return true;
+				}
+				return !buf.hasRemaining();
+			}, timeout, 10);
+			return !buf.hasRemaining();
+		}
+
+		@Override
+		public void close() throws IOException {
+			channel.close();
+		}
+
+		public int getRemotePort() throws IOException {
+			return ((InetSocketAddress) channel.getRemoteAddress()).getPort();
+		}
+	}
+
+	public static interface MessageListener {
+		void handleMessage(String message);
+	}
+
+	/**
+	 * Represents a socket making this the main instance of the application.
+	 */
+	public static class LocalInstance implements Closeable {
+		private class ChannelState {
+			ByteBuffer write = ByteBuffer.wrap(applicationKey.getBytes());
+			ByteBuffer readLength = ByteBuffer.allocate(2);
+			ByteBuffer readMessage = null;
+		}
+
+		final ListenerRegistry<MessageListener, String> registry = new ListenerRegistry<>(MessageListener::handleMessage);
+		final String applicationKey;
+		final ServerSocketChannel channel;
+		final Selector selector;
+		int port = 0;
+
+		public LocalInstance(String applicationKey, ServerSocketChannel channel, Selector selector) {
+			Objects.requireNonNull(applicationKey);
+			this.applicationKey = applicationKey;
+			this.channel = channel;
+			this.selector = selector;
+		}
+
+		/**
+		 * Register a listener for
+		 * 
+		 * @param listener
+		 * @return
+		 */
+		public ListenerRegistration registerListener(MessageListener listener) {
+			Objects.requireNonNull(listener);
+			return registry.registerListener(listener);
+		}
+
+		void handleSelection(SelectionKey key) throws IOException {
+			if (key.isAcceptable()) {
+				final SocketChannel accepted = channel.accept();
+				if (accepted != null) {
+					LOG.info("accepted incoming connection");
+					accepted.configureBlocking(false);
+					accepted.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
+				}
+			}
+
+			if (key.attachment() == null) {
+				key.attach(new ChannelState());
+			}
+
+			ChannelState state = (ChannelState) key.attachment();
+
+			if (key.isWritable() && state.write != null) {
+				((WritableByteChannel) key.channel()).write(state.write);
+				if (!state.write.hasRemaining()) {
+					state.write = null;
+				}
+				LOG.debug("wrote welcome. switching to read only.");
+				key.interestOps(SelectionKey.OP_READ);
+			}
+
+			if (key.isReadable()) {
+				ByteBuffer buffer = state.readLength != null ? state.readLength : state.readMessage;
+
+				if (((ReadableByteChannel) key.channel()).read(buffer) < 0) {
+					key.cancel();
+				}
+
+				if (!buffer.hasRemaining()) {
+					buffer.flip();
+					if (state.readLength != null) {
+						int length = (buffer.get() + 256) % 256;
+						length = length * 256 + ((buffer.get() + 256) % 256);
+
+						state.readLength = null;
+						state.readMessage = ByteBuffer.allocate(length);
+					} else {
+						byte[] bytes = new byte[buffer.limit()];
+						buffer.get(bytes);
+
+						state.readMessage = null;
+						state.readLength = ByteBuffer.allocate(2);
+
+						registry.broadcast(new String(bytes, "UTF-8"));
+					}
+				}
+			}
+		}
+
+		public void close() {
+			IOUtils.closeQuietly(selector);
+			IOUtils.closeQuietly(channel);
+			if (getSavedPort(applicationKey).orElse(-1).equals(port)) {
+				Preferences.userNodeForPackage(Main.class).remove(applicationKey);
+			}
+		}
+
+		void selectionLoop() {
+			try {
+				final Set<SelectionKey> keysToRemove = new HashSet<>();
+				while (selector.select() > 0) {
+					final Set<SelectionKey> keys = selector.selectedKeys();
+					for (SelectionKey key : keys) {
+						if (Thread.interrupted()) {
+							return;
+						}
+						try {
+							handleSelection(key);
+						} catch (IOException | IllegalStateException e) {
+							LOG.error("exception in selector", e);
+						} finally {
+							keysToRemove.add(key);
+						}
+					}
+					keys.removeAll(keysToRemove);
+				}
+			} catch (ClosedSelectorException e) {
+				return;
+			} catch (Exception e) {
+				LOG.error("error while selecting", e);
+			}
+		}
+	}
+
+	/**
+	 * Checks if there is a valid port at
+	 * {@link Preferences#userNodeForPackage(Class)} for {@link Main} under the
+	 * given applicationKey, tries to connect to the port at the loopback
+	 * address and checks if the port identifies with the applicationKey.
+	 * 
+	 * @param applicationKey
+	 *            key used to load the port and check the identity of the
+	 *            connection.
+	 * @return
+	 */
+	public static Optional<RemoteInstance> getRemoteInstance(String applicationKey) {
+		Optional<Integer> port = getSavedPort(applicationKey);
+
+		if (!port.isPresent()) {
+			return Optional.empty();
+		}
+
+		SocketChannel channel = null;
+		boolean close = true;
+		try {
+			channel = SocketChannel.open();
+			channel.configureBlocking(false);
+			LOG.info("connecting to instance {}", port.get());
+			channel.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port.get()));
+
+			SocketChannel fChannel = channel;
+			if (!TimeoutTask.attempt(t -> fChannel.finishConnect(), 1000, 10)) {
+				return Optional.empty();
+			}
+
+			LOG.info("connected to instance {}", port.get());
+
+			final byte[] bytes = applicationKey.getBytes();
+			ByteBuffer buf = ByteBuffer.allocate(bytes.length);
+			tryFill(channel, buf, 1000);
+			if (buf.hasRemaining()) {
+				return Optional.empty();
+			}
+
+			buf.flip();
+
+			for (int i = 0; i < bytes.length; i++) {
+				if (buf.get() != bytes[i]) {
+					return Optional.empty();
+				}
+			}
+
+			close = false;
+			return Optional.of(new RemoteInstance(channel));
+		} catch (Exception e) {
+			return Optional.empty();
+		} finally {
+			if (close) {
+				IOUtils.closeQuietly(channel);
+			}
+		}
+	}
+
+	static Optional<Integer> getSavedPort(String applicationKey) {
+		int port = Preferences.userNodeForPackage(Main.class).getInt(applicationKey, -1);
+
+		if (port == -1) {
+			LOG.info("no running instance found");
+			return Optional.empty();
+		}
+
+		return Optional.of(port);
+	}
+
+	/**
+	 * Creates a server socket on a free port and saves the port in
+	 * {@link Preferences#userNodeForPackage(Class)} for {@link Main} under the
+	 * given applicationKey.
+	 * 
+	 * @param applicationKey
+	 *            key used to save the port and identify upon connection.
+	 * @param exec
+	 *            the task which is submitted is interruptable.
+	 * @return
+	 * @throws IOException
+	 */
+	public static LocalInstance startLocalInstance(String applicationKey, ExecutorService exec) throws IOException {
+		final ServerSocketChannel channel = ServerSocketChannel.open();
+		channel.configureBlocking(false);
+		channel.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
+
+		final int port = ((InetSocketAddress) channel.getLocalAddress()).getPort();
+		Preferences.userNodeForPackage(Main.class).putInt(applicationKey, port);
+		LOG.info("InstanceManager bound to port {}", port);
+
+		Selector selector = Selector.open();
+		channel.register(selector, SelectionKey.OP_ACCEPT);
+
+		LocalInstance instance = new LocalInstance(applicationKey, channel, selector);
+
+		exec.submit(() -> {
+			try {
+				instance.port = ((InetSocketAddress) channel.getLocalAddress()).getPort();
+			} catch (IOException e) {
+
+			}
+			instance.selectionLoop();
+		});
+
+		return instance;
+	}
+
+	/**
+	 * tries to fill the given buffer for the given time
+	 * 
+	 * @param channel
+	 * @param buf
+	 * @param timeout
+	 * @throws ClosedChannelException
+	 * @throws IOException
+	 */
+	public static <T extends SelectableChannel & ReadableByteChannel> void tryFill(T channel, final ByteBuffer buf, int timeout) throws IOException {
+		if (channel.isBlocking()) {
+			throw new IllegalStateException("Channel is in blocking mode.");
+		}
+
+		try (Selector selector = Selector.open()) {
+			channel.register(selector, SelectionKey.OP_READ);
+
+			TimeoutTask.attempt(remainingTime -> {
+				if (!buf.hasRemaining()) {
+					return true;
+				}
+				if (selector.select(remainingTime) > 0) {
+					if (channel.read(buf) < 0) {
+						return true;
+					}
+				}
+				return !buf.hasRemaining();
+			}, timeout, 1);
+		}
+	}
+}

+ 81 - 0
main/ui/src/main/java/org/cryptomator/ui/util/TimeoutTask.java

@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * Copyright (c) 2014 cryptomator.org
+ * This file is licensed under the terms of the MIT license.
+ * See the LICENSE.txt file for more info.
+ * 
+ * Contributors:
+ *     Tillmann Gaida - initial implementation
+ ******************************************************************************/
+package org.cryptomator.ui.util;
+
+/**
+ * A task which is supposed to be repeated until it succeeds.
+ * 
+ * @author Tillmann Gaida
+ *
+ * @param <E>
+ *            The type of checked exception that this task may throw.
+ */
+public interface TimeoutTask<E extends Exception> {
+	/**
+	 * Attempts to execute the task.
+	 * 
+	 * @param timeout
+	 *            The time remaining to finish the task.
+	 * @return true if the task finished, false if it needs to be attempted
+	 *         again.
+	 * @throws E
+	 * @throws InterruptedException
+	 */
+	boolean attempt(long timeout) throws E, InterruptedException;
+
+	/**
+	 * Attempts a task until a timeout occurs. Checks for this timeout are based
+	 * on {@link System#currentTimeMillis()}, so they are very crude. The task
+	 * is guaranteed to be attempted once.
+	 * 
+	 * @param task
+	 *            the task to perform.
+	 * @param timeout
+	 *            time in millis before this method stops attempting to finish
+	 *            the task. greater than zero.
+	 * @param sleepTimes
+	 *            time in millis to sleep between attempts. greater than zero.
+	 * @return true if the task was finished, false if the task never always
+	 *         returned false or as soon as the task throws an
+	 *         {@link InterruptedException}.
+	 * @throws E
+	 *             From the task.
+	 */
+	public static <E extends Exception> boolean attempt(TimeoutTask<E> task, long timeout, long sleepTimes) throws E {
+		if (timeout <= 0 || sleepTimes <= 0) {
+			throw new IllegalArgumentException();
+		}
+
+		long currentTime = System.currentTimeMillis();
+
+		long tryUntil = currentTime + timeout;
+
+		for (;; currentTime = System.currentTimeMillis()) {
+			if (currentTime >= tryUntil) {
+				return false;
+			}
+
+			try {
+				if (task.attempt(tryUntil - currentTime)) {
+					return true;
+				}
+
+				currentTime = System.currentTimeMillis();
+
+				if (currentTime + sleepTimes < tryUntil) {
+					Thread.sleep(sleepTimes);
+				} else {
+					return false;
+				}
+			} catch (InterruptedException e) {
+				return false;
+			}
+		}
+	}
+}

+ 53 - 0
main/ui/src/test/java/org/cryptomator/ui/util/ListenerRegistryTest.java

@@ -0,0 +1,53 @@
+/*******************************************************************************
+ * Copyright (c) 2014 cryptomator.org
+ * This file is licensed under the terms of the MIT license.
+ * See the LICENSE.txt file for more info.
+ * 
+ * Contributors:
+ *     Tillmann Gaida - initial implementation
+ ******************************************************************************/
+package org.cryptomator.ui.util;
+
+import static org.junit.Assert.*;
+
+import java.util.Iterator;
+import java.util.concurrent.ConcurrentSkipListMap;
+
+import org.junit.Test;
+
+public class ListenerRegistryTest {
+	/**
+	 * This test looks at how concurrent modifications affect the iterator of a
+	 * {@link ConcurrentSkipListMap}. It shows that concurrent modifications
+	 * work just fine, however the state of the iterator including the next
+	 * value are advanced during retrieval of a value, so it's not possible to
+	 * remove the next value.
+	 * 
+	 * @throws Exception
+	 */
+	@Test
+	public void testConcurrentSkipListMap() throws Exception {
+		ConcurrentSkipListMap<Integer, Integer> map = new ConcurrentSkipListMap<>();
+
+		map.put(1, 1);
+		map.put(2, 2);
+		map.put(3, 3);
+		map.put(4, 4);
+		map.put(5, 5);
+
+		final Iterator<Integer> iterator = map.values().iterator();
+
+		assertTrue(iterator.hasNext());
+		assertEquals((Integer) 1, iterator.next());
+		map.remove(2);
+		assertTrue(iterator.hasNext());
+		// iterator returns 2 anyway.
+		assertEquals((Integer) 2, iterator.next());
+		assertTrue(iterator.hasNext());
+		map.remove(4);
+		assertEquals((Integer) 3, iterator.next());
+		assertTrue(iterator.hasNext());
+		// this time we removed 4 before retrieving 3, so it is skipped.
+		assertEquals((Integer) 5, iterator.next());
+	}
+}

+ 200 - 0
main/ui/src/test/java/org/cryptomator/ui/util/SingleInstanceManagerTest.java

@@ -0,0 +1,200 @@
+/*******************************************************************************
+ * Copyright (c) 2014 cryptomator.org
+ * This file is licensed under the terms of the MIT license.
+ * See the LICENSE.txt file for more info.
+ * 
+ * Contributors:
+ *     Tillmann Gaida - initial implementation
+ ******************************************************************************/
+package org.cryptomator.ui.util;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.nio.ByteBuffer;
+import java.nio.channels.SocketChannel;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ForkJoinTask;
+import java.util.concurrent.TimeUnit;
+
+import org.cryptomator.ui.util.SingleInstanceManager.LocalInstance;
+import org.cryptomator.ui.util.SingleInstanceManager.MessageListener;
+import org.cryptomator.ui.util.SingleInstanceManager.RemoteInstance;
+import org.junit.Test;
+
+public class SingleInstanceManagerTest {
+	@Test(timeout = 10000)
+	public void testTryFillTimeout() throws Exception {
+		try (final ServerSocket socket = new ServerSocket(0)) {
+			// we need to asynchronously accept the connection
+			final ForkJoinTask<?> forked = ForkJoinTask.adapt(() -> {
+				try {
+					socket.setSoTimeout(1000);
+
+					socket.accept();
+				} catch (Exception e) {
+					throw new RuntimeException(e);
+				}
+			}).fork();
+
+			try (SocketChannel channel = SocketChannel.open()) {
+				channel.configureBlocking(false);
+				channel.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), socket.getLocalPort()));
+				TimeoutTask.attempt(t -> channel.finishConnect(), 1000, 1);
+				final ByteBuffer buffer = ByteBuffer.allocate(1);
+				SingleInstanceManager.tryFill(channel, buffer, 1000);
+				assertTrue(buffer.hasRemaining());
+			}
+
+			forked.join();
+		}
+	}
+
+	@Test(timeout = 10000)
+	public void testTryFill() throws Exception {
+		try (final ServerSocket socket = new ServerSocket(0)) {
+			// we need to asynchronously accept the connection
+			final ForkJoinTask<?> forked = ForkJoinTask.adapt(() -> {
+				try {
+					socket.setSoTimeout(1000);
+
+					socket.accept().getOutputStream().write(1);
+				} catch (Exception e) {
+					throw new RuntimeException(e);
+				}
+			}).fork();
+
+			try (SocketChannel channel = SocketChannel.open()) {
+				channel.configureBlocking(false);
+				channel.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), socket.getLocalPort()));
+				TimeoutTask.attempt(t -> channel.finishConnect(), 1000, 1);
+				final ByteBuffer buffer = ByteBuffer.allocate(1);
+				SingleInstanceManager.tryFill(channel, buffer, 1000);
+				assertFalse(buffer.hasRemaining());
+			}
+
+			forked.join();
+		}
+	}
+
+	String appKey = "APPKEY";
+
+	@Test
+	public void testOneMessage() throws Exception {
+		ExecutorService exec = Executors.newCachedThreadPool();
+
+		try {
+			final LocalInstance server = SingleInstanceManager.startLocalInstance(appKey, exec);
+			final Optional<RemoteInstance> r = SingleInstanceManager.getRemoteInstance(appKey);
+
+			CountDownLatch latch = new CountDownLatch(1);
+
+			final MessageListener listener = spy(new MessageListener() {
+				@Override
+				public void handleMessage(String message) {
+					latch.countDown();
+				}
+			});
+			server.registerListener(listener);
+
+			assertTrue(r.isPresent());
+
+			String message = "Is this thing on?";
+			assertTrue(r.get().sendMessage(message, 1000));
+			System.out.println("wrote message");
+
+			latch.await(10, TimeUnit.SECONDS);
+
+			verify(listener).handleMessage(message);
+		} finally {
+			exec.shutdownNow();
+		}
+	}
+
+	@Test(timeout = 60000)
+	public void testALotOfMessages() throws Exception {
+		final int connectors = 256;
+		final int messagesPerConnector = 256;
+
+		ExecutorService exec = Executors.newSingleThreadExecutor();
+		ExecutorService exec2 = Executors.newFixedThreadPool(16);
+
+		try (final LocalInstance server = SingleInstanceManager.startLocalInstance(appKey, exec)) {
+
+			Set<String> sentMessages = new ConcurrentSkipListSet<>();
+			Set<String> receivedMessages = new HashSet<>();
+
+			CountDownLatch sendLatch = new CountDownLatch(connectors);
+			CountDownLatch receiveLatch = new CountDownLatch(connectors * messagesPerConnector);
+
+			server.registerListener(message -> {
+				receivedMessages.add(message);
+				receiveLatch.countDown();
+			});
+
+			Set<RemoteInstance> instances = Collections.synchronizedSet(new HashSet<>());
+
+			for (int i = 0; i < connectors; i++) {
+				exec2.submit(() -> {
+					try {
+						final Optional<RemoteInstance> r = SingleInstanceManager.getRemoteInstance(appKey);
+						assertTrue(r.isPresent());
+						instances.add(r.get());
+
+						for (int j = 0; j < messagesPerConnector; j++) {
+							exec2.submit(() -> {
+								try {
+									for (;;) {
+										final String message = UUID.randomUUID().toString();
+										if (!sentMessages.add(message)) {
+											continue;
+										}
+										r.get().sendMessage(message, 1000);
+										break;
+									}
+								} catch (Exception e) {
+									e.printStackTrace();
+								}
+							});
+						}
+
+						sendLatch.countDown();
+					} catch (Throwable e) {
+						e.printStackTrace();
+					}
+				});
+			}
+
+			assertTrue(sendLatch.await(1, TimeUnit.MINUTES));
+
+			exec2.shutdown();
+			assertTrue(exec2.awaitTermination(1, TimeUnit.MINUTES));
+
+			assertTrue(receiveLatch.await(1, TimeUnit.MINUTES));
+
+			assertEquals(sentMessages, receivedMessages);
+
+			for (RemoteInstance remoteInstance : instances) {
+				try {
+					remoteInstance.close();
+				} catch (Exception e) {
+					e.printStackTrace();
+				}
+			}
+		} finally {
+			exec.shutdownNow();
+			exec2.shutdownNow();
+		}
+	}
+}