Implement uri handling inside app.

Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
Armin Schrenk
2026-06-10 15:48:25 +02:00
parent 9c41e348fa
commit a428ae6260
11 changed files with 287 additions and 15 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="25" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_26" project-jdk-name="temurin-26" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
@@ -1,13 +1,28 @@
package org.cryptomator.launcher;
import java.net.URI;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
public record AppLaunchEvent(AppLaunchEvent.EventType type, Collection<Path> pathsToOpen) {
public record AppLaunchEvent(AppLaunchEvent.EventType type, Collection<Path> pathsToOpen, URI uri) {
public enum EventType {
REVEAL_APP,
OPEN_FILE
OPEN_FILE,
OPEN_URI
}
static AppLaunchEvent revealApp() {
return new AppLaunchEvent(EventType.REVEAL_APP, List.of(), null);
}
static AppLaunchEvent openFiles(Collection<Path> pathsToOpen) {
return new AppLaunchEvent(EventType.OPEN_FILE, pathsToOpen, null);
}
static AppLaunchEvent openUri(URI uri) {
return new AppLaunchEvent(EventType.OPEN_URI, List.of(), uri);
}
}
@@ -25,7 +25,7 @@ class CryptomatorModule {
@Provides
@Singleton
@Named("launchEventQueue")
static BlockingQueue<AppLaunchEvent> provideFileOpenRequests() {
static BlockingQueue<AppLaunchEvent> provideLaunchEventQueue() {
return new ArrayBlockingQueue<>(10);
}
@@ -41,7 +41,7 @@ class FileOpenRequestHandler {
private void openFiles(OpenFilesEvent evt) {
Collection<Path> pathsToOpen = evt.getFiles().stream().map(File::toPath).toList();
AppLaunchEvent launchEvent = new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, pathsToOpen);
AppLaunchEvent launchEvent = AppLaunchEvent.openFiles(pathsToOpen);
tryToEnqueueFileOpenRequest(launchEvent);
}
@@ -60,7 +60,7 @@ class FileOpenRequestHandler {
}
}).filter(Objects::nonNull).toList();
if (!pathsToOpen.isEmpty()) {
AppLaunchEvent launchEvent = new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, pathsToOpen);
AppLaunchEvent launchEvent = AppLaunchEvent.openFiles(pathsToOpen);
tryToEnqueueFileOpenRequest(launchEvent);
}
}
@@ -68,7 +68,7 @@ class FileOpenRequestHandler {
private void tryToEnqueueFileOpenRequest(AppLaunchEvent launchEvent) {
if (!launchEventQueue.offer(launchEvent)) {
LOG.warn("Could not enqueue application launch event.", launchEvent);
LOG.warn("Could not enqueue application launch event {}.", launchEvent);
}
}
@@ -7,7 +7,6 @@ import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.BlockingQueue;
@@ -16,24 +15,28 @@ class IpcMessageHandler implements IpcMessageListener {
private static final Logger LOG = LoggerFactory.getLogger(IpcMessageHandler.class);
private final FileOpenRequestHandler fileOpenRequestHandler;
private final LaunchArgsParser launchArgsParser;
private final BlockingQueue<AppLaunchEvent> launchEventQueue;
@Inject
public IpcMessageHandler(FileOpenRequestHandler fileOpenRequestHandler, @Named("launchEventQueue") BlockingQueue<AppLaunchEvent> launchEventQueue) {
this.fileOpenRequestHandler = fileOpenRequestHandler;
public IpcMessageHandler(LaunchArgsParser launchArgsParser, @Named("launchEventQueue") BlockingQueue<AppLaunchEvent> launchEventQueue) {
this.launchArgsParser = launchArgsParser;
this.launchEventQueue = launchEventQueue;
}
@Override
public void revealRunningApp() {
launchEventQueue.add(new AppLaunchEvent(AppLaunchEvent.EventType.REVEAL_APP, Collections.emptyList()));
launchEventQueue.add(AppLaunchEvent.revealApp());
}
@Override
public void handleLaunchArgs(List<String> args) {
LOG.debug("Received launch args: {}", args.stream().reduce((a, b) -> a + ", " + b).orElse(""));
fileOpenRequestHandler.handleLaunchArgs(args);
LOG.debug("Received launch args: {}", args);
try {
launchArgsParser.process(args);
} catch (IllegalArgumentException e) {
LOG.warn("Ignoring malformed launch args: {}", e.getMessage());
}
}
}
@@ -0,0 +1,88 @@
package org.cryptomator.launcher;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.net.URI;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
/**
* Preprocesses the launch arguments and delegates them to the matching handler.
* <p>
* An argument is treated as a URI if it starts with a (non-{@code file}) scheme of at least two characters, e.g.
* {@code cryptomator://…}. Everything else - including plain paths and {@code file://…} URIs - is treated as a file path
* and forwarded to the {@link FileOpenRequestHandler}. The two-character minimum prevents Windows drive letters
* (e.g. {@code C:\…}) from being misinterpreted as URIs.
* <p>
* URIs and file paths must not be mixed and at most a single URI is accepted, which has to be the first argument.
*/
@Singleton
class LaunchArgsParser {
private static final Pattern SCHEME_PATTERN = Pattern.compile("^([a-zA-Z][a-zA-Z0-9+.-]+):.*");
private static final String FILE_SCHEME = "file";
private final FileOpenRequestHandler fileOpenRequestHandler;
private final URIOpenRequestHandler uriOpenRequestHandler;
private final NoopRequestHandler noopRequestHandler;
@Inject
public LaunchArgsParser(FileOpenRequestHandler fileOpenRequestHandler, URIOpenRequestHandler uriOpenRequestHandler, NoopRequestHandler noopRequestHandler) {
this.fileOpenRequestHandler = fileOpenRequestHandler;
this.uriOpenRequestHandler = uriOpenRequestHandler;
this.noopRequestHandler = noopRequestHandler;
}
/**
* Classifies the given launch arguments and delegates them to the responsible handler.
*
* @param args the raw launch arguments
* @throws IllegalArgumentException if URIs and file paths are mixed, if more than one URI is given, if a URI is not
* the first argument, or if a URI argument is malformed
*/
public void process(List<String> args) {
if(args.isEmpty()) {
noopRequestHandler.revealApp();
return;
}
var classified = args.stream().map(LaunchArgsParser::classify).toList();
var uris = classified.stream().filter(arg -> arg.kind() == Kind.URI).toList();
if (uris.isEmpty()) {
var paths = classified.stream().map(Arg::value).toList();
fileOpenRequestHandler.handleLaunchArgs(paths);
return;
}
if (uris.size() > 1) {
throw new IllegalArgumentException("Only a single URI argument is accepted, but got " + uris.size() + ".");
}
if (classified.getFirst().kind() != Kind.URI) {
throw new IllegalArgumentException("URI argument must be the first parameter.");
}
if (classified.size() > 1) {
throw new IllegalArgumentException("Mixing a URI with file paths is not supported.");
}
uriOpenRequestHandler.handleLaunchArgs(URI.create(classified.getFirst().value()));
}
private static Arg classify(String arg) {
var matcher = SCHEME_PATTERN.matcher(arg);
if (!matcher.matches()) {
return new Arg(Kind.PATH, arg);
}
var scheme = matcher.group(1);
if (FILE_SCHEME.equalsIgnoreCase(scheme)) {
// file:// URIs (e.g. passed by Linux file managers) are file paths in disguise
return new Arg(Kind.PATH, Path.of(URI.create(arg)).toString());
}
return new Arg(Kind.URI, arg);
}
private enum Kind {PATH, URI}
private record Arg(Kind kind, String value) {}
}
@@ -0,0 +1,29 @@
package org.cryptomator.launcher;
import javax.inject.Inject;
import javax.inject.Named;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Singleton;
import java.util.concurrent.BlockingQueue;
@Singleton
public class NoopRequestHandler {
private static final Logger LOG = LoggerFactory.getLogger(NoopRequestHandler.class);
private final BlockingQueue<AppLaunchEvent> launchEventQueue;
@Inject
public NoopRequestHandler(@Named("launchEventQueue") BlockingQueue<AppLaunchEvent> launchEventQueue) {
this.launchEventQueue = launchEventQueue;
}
public void revealApp() {
AppLaunchEvent launchEvent = AppLaunchEvent.revealApp();
if (!launchEventQueue.offer(launchEvent)) {
LOG.warn("Could not enqueue application launch event {}.", launchEvent);
}
}
}
@@ -0,0 +1,31 @@
package org.cryptomator.launcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.net.URI;
import java.util.concurrent.BlockingQueue;
@Singleton
public class URIOpenRequestHandler {
private static final Logger LOG = LoggerFactory.getLogger(URIOpenRequestHandler.class);
private final BlockingQueue<AppLaunchEvent> launchEventQueue;
@Inject
public URIOpenRequestHandler(@Named("launchEventQueue") BlockingQueue<AppLaunchEvent> launchEventQueue) {
this.launchEventQueue = launchEventQueue;
}
public void handleLaunchArgs(URI uri) {
AppLaunchEvent launchEvent = AppLaunchEvent.openUri(uri);
if (!launchEventQueue.offer(launchEvent)) {
LOG.warn("Could not enqueue application launch event {}.", launchEvent);
}
}
}
@@ -14,6 +14,7 @@ import javax.inject.Named;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.BlockingQueue;
@@ -66,10 +67,17 @@ class AppLaunchEventHandler {
switch (event.type()) {
case REVEAL_APP -> appWindows.showMainWindow();
case OPEN_FILE -> event.pathsToOpen().forEach(this::openPotentialVault);
case OPEN_URI -> handleUri(event.uri());
default -> LOG.warn("Unsupported event type: {}", event.type());
}
}
private void handleUri(URI uri) {
// TODO: dispatch to a handler depending on the URI (e.g. host/path) once deeplink actions are defined
LOG.warn("Received deeplink {}, but handling of this scheme is not yet implemented.", uri);
appWindows.showMainWindow();
}
// TODO deduplicate MainWindowController...
private void openPotentialVault(Path path) {
Path potentialVaultPath = path.getFileName().toString().endsWith(CRYPTOMATOR_FILENAME_EXT) ? path.getParent() : path;
@@ -60,7 +60,7 @@ public class FileOpenRequestHandlerTest {
@Test
@DisplayName("./cryptomator.exe foo (with full event queue)")
public void testOpenArgsWithFullQueue() {
queue.add(new AppLaunchEvent(AppLaunchEvent.EventType.OPEN_FILE, Collections.emptyList()));
queue.add(AppLaunchEvent.openFiles(Collections.emptyList()));
Assumptions.assumeTrue(queue.remainingCapacity() == 0);
inTest.handleLaunchArgs(List.of("foo"));
@@ -0,0 +1,98 @@
package org.cryptomator.launcher;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import java.net.URI;
import java.util.List;
public class LaunchArgsParserTest {
private FileOpenRequestHandler fileOpenRequestHandler;
private URIOpenRequestHandler uriOpenRequestHandler;
private NoopRequestHandler noopRequestHandler;
private LaunchArgsParser inTest;
@BeforeEach
public void setup() {
fileOpenRequestHandler = Mockito.mock(FileOpenRequestHandler.class);
uriOpenRequestHandler = Mockito.mock(URIOpenRequestHandler.class);
noopRequestHandler = Mockito.mock(NoopRequestHandler.class);
inTest = new LaunchArgsParser(fileOpenRequestHandler, uriOpenRequestHandler, noopRequestHandler);
}
@Test
@DisplayName("only file paths are forwarded to the FileOpenRequestHandler")
public void testOnlyPaths() {
inTest.process(List.of("foo", "bar"));
Mockito.verify(fileOpenRequestHandler).handleLaunchArgs(List.of("foo", "bar"));
Mockito.verifyNoInteractions(uriOpenRequestHandler);
Mockito.verifyNoInteractions(noopRequestHandler);
}
@Test
@DisplayName("empty args are forwarded to the NoopRequestHandler")
public void testEmptyArgs() {
inTest.process(List.of());
Mockito.verify(noopRequestHandler).revealApp();
Mockito.verifyNoInteractions(uriOpenRequestHandler, fileOpenRequestHandler);
}
@Test
@DisplayName("a Windows path is not mistaken for a URI")
public void testWindowsPathIsNotAUri() {
inTest.process(List.of("C:\\Users\\foo\\vault.cryptomator"));
Mockito.verify(fileOpenRequestHandler).handleLaunchArgs(List.of("C:\\Users\\foo\\vault.cryptomator"));
Mockito.verifyNoInteractions(uriOpenRequestHandler, noopRequestHandler);
}
@Test
@DisplayName("a single cryptomator:// URI is forwarded to the URIOpenRequestHandler")
public void testSingleUri() {
inTest.process(List.of("cryptomator://vault/foo"));
Mockito.verify(uriOpenRequestHandler).handleLaunchArgs(URI.create("cryptomator://vault/foo"));
Mockito.verifyNoInteractions(fileOpenRequestHandler, noopRequestHandler);
}
@Test
@DisplayName("a file:// URI is converted to a path and forwarded to the FileOpenRequestHandler")
public void testFileUriIsTreatedAsPath() {
inTest.process(List.of("file:///tmp/vault.cryptomator"));
var captor = ArgumentCaptor.forClass(List.class);
Mockito.verify(fileOpenRequestHandler).handleLaunchArgs(captor.capture());
Mockito.verifyNoInteractions(uriOpenRequestHandler, noopRequestHandler);
Assertions.assertEquals(1, captor.getValue().size());
Assertions.assertFalse(captor.getValue().getFirst().toString().startsWith("file:"));
}
@Test
@DisplayName("mixing a URI with a file path fails")
public void testMixedUriAndPathFails() {
Assertions.assertThrows(IllegalArgumentException.class, () -> inTest.process(List.of("cryptomator://vault/foo", "bar")));
Mockito.verifyNoInteractions(fileOpenRequestHandler, uriOpenRequestHandler, noopRequestHandler);
}
@Test
@DisplayName("more than one URI fails")
public void testMultipleUrisFail() {
Assertions.assertThrows(IllegalArgumentException.class, () -> inTest.process(List.of("cryptomator://vault/foo", "cryptomator://vault/bar")));
Mockito.verifyNoInteractions(fileOpenRequestHandler, uriOpenRequestHandler, noopRequestHandler);
}
@Test
@DisplayName("a URI that is not the first parameter fails")
public void testUriNotFirstFails() {
Assertions.assertThrows(IllegalArgumentException.class, () -> inTest.process(List.of("foo", "cryptomator://vault/bar")));
Mockito.verifyNoInteractions(fileOpenRequestHandler, uriOpenRequestHandler, noopRequestHandler);
}
}