mirror of
https://github.com/cryptomator/cryptomator.git
synced 2026-08-24 08:06:02 +00:00
Implement uri handling inside app.
Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
Generated
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user