Refactor event handling

AppLauchEvent became a sealed interface with implemented Events RevealRunningApp-, OpenFile- and ValtCreateEvent.

Signed-off-by: Armin Schrenk <armin.schrenk@skymatic.de>
This commit is contained in:
Armin Schrenk
2026-07-13 13:45:21 +02:00
parent 77dbf9414a
commit ea806ec97a
14 changed files with 195 additions and 203 deletions

View File

@@ -1,28 +1,16 @@
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, URI uri) {
public enum EventType {
REVEAL_APP,
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);
}
/**
* An event triggering an action in the running application instance.
* <p>
* Produced by the launch-argument handling (see {@link LaunchArgsParser} and the {@code *RequestHandler}s) and consumed
* by the UI's {@code AppLaunchEventHandler}. Each permitted subtype represents one supported action:
* <ul>
* <li>{@link RevealRunningEvent} - reveal the already-running app,</li>
* <li>{@link OpenFileEvent} - open one or more paths,</li>
* <li>{@link VaultCreationEvent} - create a vault from a deeplink.</li>
* </ul>
*/
public sealed interface AppLaunchEvent permits RevealRunningEvent, OpenFileEvent, VaultCreationEvent {
}

View File

@@ -1,38 +0,0 @@
package org.cryptomator.launcher;
import java.net.URI;
import java.util.Arrays;
import java.util.Optional;
/**
* Enumerates the supported deeplink actions and maps an incoming {@link URI} to one of them.
* <p>
* A deeplink action is identified by the URI's host and path, e.g. {@code cryptomator://vault/create} maps to
* {@link #VAULT_CREATE}. Unknown host/path combinations yield an empty {@link Optional} and should be treated as
* unsupported. New actions are added by extending this enum.
*/
public enum DeeplinkAction {
VAULT_CREATE("vault", "/create");
private final String host;
private final String path;
DeeplinkAction(String host, String path) {
this.host = host;
this.path = path;
}
/**
* Matches the given URI against the known deeplink actions.
*
* @param uri the deeplink URI
* @return the matching action, or an empty optional if the host/path is not recognized
*/
public static Optional<DeeplinkAction> match(URI uri) {
return Arrays.stream(values()) //
.filter(action -> action.host.equalsIgnoreCase(uri.getHost()) && action.path.equals(uri.getPath())) //
.findFirst();
}
}

View File

@@ -41,7 +41,7 @@ class FileOpenRequestHandler {
private void openFiles(OpenFilesEvent evt) {
Collection<Path> pathsToOpen = evt.getFiles().stream().map(File::toPath).toList();
AppLaunchEvent launchEvent = AppLaunchEvent.openFiles(pathsToOpen);
AppLaunchEvent launchEvent = new OpenFileEvent(pathsToOpen);
tryToEnqueueFileOpenRequest(launchEvent);
}
@@ -60,7 +60,7 @@ class FileOpenRequestHandler {
}
}).filter(Objects::nonNull).toList();
if (!pathsToOpen.isEmpty()) {
AppLaunchEvent launchEvent = AppLaunchEvent.openFiles(pathsToOpen);
AppLaunchEvent launchEvent = new OpenFileEvent(pathsToOpen);
tryToEnqueueFileOpenRequest(launchEvent);
}
}

View File

@@ -26,7 +26,7 @@ class IpcMessageHandler implements IpcMessageListener {
@Override
public void revealRunningApp() {
launchEventQueue.add(AppLaunchEvent.revealApp());
launchEventQueue.add(new RevealRunningEvent());
}
@Override

View File

@@ -21,7 +21,7 @@ public class NoopRequestHandler {
}
public void revealApp() {
AppLaunchEvent launchEvent = AppLaunchEvent.revealApp();
AppLaunchEvent launchEvent = new RevealRunningEvent();
if (!launchEventQueue.offer(launchEvent)) {
LOG.warn("Could not enqueue application launch event {}.", launchEvent);
}

View File

@@ -0,0 +1,13 @@
package org.cryptomator.launcher;
import java.nio.file.Path;
import java.util.Collection;
/**
* Requests that the given paths (e.g. {@code .cryptomator} vault files) are opened.
*
* @param pathsToOpen the paths to open
*/
public record OpenFileEvent(Collection<Path> pathsToOpen) implements AppLaunchEvent {
}

View File

@@ -0,0 +1,8 @@
package org.cryptomator.launcher;
/**
* Requests that the already-running application instance reveals itself (brings its main window to the front).
*/
public record RevealRunningEvent() implements AppLaunchEvent {
}

View File

@@ -7,13 +7,24 @@ import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.BlockingQueue;
import java.util.function.Function;
@Singleton
public class URIOpenRequestHandler {
private static final Logger LOG = LoggerFactory.getLogger(URIOpenRequestHandler.class);
/**
* The registered deeplink parsers, tried in order. Each returns a matching event, an empty optional if the URI is
* not its concern, or throws {@link IllegalArgumentException} if the URI is its concern but malformed.
*/
private static final List<Function<URI, Optional<? extends AppLaunchEvent>>> DEEPLINK_PARSERS = List.of( //
VaultCreationEvent::tryParse //
);
private final BlockingQueue<AppLaunchEvent> launchEventQueue;
@Inject
@@ -22,10 +33,26 @@ public class URIOpenRequestHandler {
}
public void handleLaunchArgs(URI uri) {
AppLaunchEvent launchEvent = AppLaunchEvent.openUri(uri);
AppLaunchEvent launchEvent = toLaunchEvent(uri);
if (!launchEventQueue.offer(launchEvent)) {
LOG.warn("Could not enqueue application launch event {}.", launchEvent);
}
}
private AppLaunchEvent toLaunchEvent(URI uri) {
try {
for (var parser : DEEPLINK_PARSERS) {
var event = parser.apply(uri);
if (event.isPresent()) {
return event.get();
}
}
} catch (IllegalArgumentException e) {
LOG.warn("Received malformed deeplink {}: {}. Revealing running app instead.", uri, e.getMessage());
return new RevealRunningEvent();
}
LOG.warn("Received unsupported deeplink {}, revealing running app instead.", uri);
return new RevealRunningEvent();
}
}

View File

@@ -1,17 +1,18 @@
package org.cryptomator.launcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
/**
* Parsed representation of a {@code cryptomator://vault/create?name=&template=} deeplink.
* Requests creation of a new vault from a {@code cryptomator://vault/create?name=&template=} deeplink.
* <p>
* The {@code template} is a Base64URL-encoded ZIP archive holding a ready-made (signed) vault. The {@code name} fixes
* the vault's directory name and is restricted to a single, safe path segment so that resolving it against a
@@ -20,19 +21,27 @@ import org.slf4j.LoggerFactory;
* @param name the fixed vault name (a single, safe path segment)
* @param template the Base64URL-decoded vault template
*/
public record VaultCreateDeeplink(String name, byte[] template) {
public record VaultCreationEvent(String name, byte[] template) implements AppLaunchEvent {
private static final Logger LOG = LoggerFactory.getLogger(VaultCreateDeeplink.class);
private static final Logger LOG = LoggerFactory.getLogger(VaultCreationEvent.class);
private static final String SCHEME = "cryptomator";
private static final String HOST = "vault";
private static final String PATH = "/create";
/**
* Parses the query parameters of a {@code vault/create} deeplink.
* Attempts to interpret the given URI as a {@code cryptomator://vault/create?name=&template=} deeplink.
*
* @param uri the deeplink URI
* @return the parsed deeplink
* @throws IllegalArgumentException if a required parameter is missing, the template is not valid Base64URL, or the
* name is not a single safe path segment
* @return the parsed event, or an empty optional if the URI's scheme, host or path do not identify a
* vault-creation deeplink
* @throws IllegalArgumentException if the URI identifies a vault-creation deeplink, but a required parameter is
* missing, the template is not valid Base64URL, or the name is not a single safe
* path segment
*/
public static VaultCreateDeeplink parse(URI uri) {
public static Optional<VaultCreationEvent> tryParse(URI uri) {
if (!SCHEME.equalsIgnoreCase(uri.getScheme()) || !HOST.equalsIgnoreCase(uri.getHost()) || !PATH.equals(uri.getPath())) {
return Optional.empty();
}
var params = parseQuery(uri.getRawQuery());
var name = params.get("name");
var templateParam = params.get("template");
@@ -49,11 +58,11 @@ public record VaultCreateDeeplink(String name, byte[] template) {
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Query parameter 'template' is not valid Base64URL.", e);
}
var leftoverParams = params.keySet().stream().filter(k -> ! (k.equals("template") || k.equals("name"))).toList();
if(!leftoverParams.isEmpty()) {
LOG.debug("Ignoring unknow parameters {}", leftoverParams);
var leftoverParams = params.keySet().stream().filter(k -> !(k.equals("template") || k.equals("name"))).toList();
if (!leftoverParams.isEmpty()) {
LOG.debug("Ignoring unknown parameters {}", leftoverParams);
}
return new VaultCreateDeeplink(name, template);
return Optional.of(new VaultCreationEvent(name, template));
}
private static void validateName(String name) {

View File

@@ -4,6 +4,9 @@ import org.cryptomator.common.vaults.NotAVaultDirectoryException;
import org.cryptomator.common.vaults.Vault;
import org.cryptomator.common.vaults.VaultListManager;
import org.cryptomator.launcher.AppLaunchEvent;
import org.cryptomator.launcher.OpenFileEvent;
import org.cryptomator.launcher.RevealRunningEvent;
import org.cryptomator.launcher.VaultCreationEvent;
import org.cryptomator.ui.common.VaultService;
import org.cryptomator.ui.dialogs.Dialogs;
import org.slf4j.Logger;
@@ -14,7 +17,6 @@ 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;
@@ -64,17 +66,16 @@ class AppLaunchEventHandler {
}
private void handleLaunchEvent(AppLaunchEvent event) {
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());
switch (event) {
case RevealRunningEvent _ -> appWindows.showMainWindow();
case OpenFileEvent openFileEvent -> openFileEvent.pathsToOpen().forEach(this::openPotentialVault);
case VaultCreationEvent vaultCreationEvent -> handleVaultCreation(vaultCreationEvent);
}
}
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);
private void handleVaultCreation(VaultCreationEvent event) {
// TODO (deeplink step 5): open the dedicated vault-creation dialog via appWindows
LOG.warn("Received vault-creation deeplink for '{}', but handling is not yet implemented.", event.name());
appWindows.showMainWindow();
}

View File

@@ -1,40 +0,0 @@
package org.cryptomator.launcher;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.util.Optional;
public class DeeplinkActionTest {
@Test
@DisplayName("cryptomator://vault/create maps to VAULT_CREATE")
public void testVaultCreateMatches() {
var result = DeeplinkAction.match(URI.create("cryptomator://vault/create?name=foo&template=bar"));
Assertions.assertEquals(Optional.of(DeeplinkAction.VAULT_CREATE), result);
}
@Test
@DisplayName("host matching is case-insensitive")
public void testHostCaseInsensitive() {
var result = DeeplinkAction.match(URI.create("cryptomator://VAULT/create"));
Assertions.assertEquals(Optional.of(DeeplinkAction.VAULT_CREATE), result);
}
@Test
@DisplayName("an unknown host yields empty")
public void testUnknownHost() {
Assertions.assertEquals(Optional.empty(), DeeplinkAction.match(URI.create("cryptomator://foo/create")));
}
@Test
@DisplayName("an unknown path yields empty")
public void testUnknownPath() {
Assertions.assertEquals(Optional.empty(), DeeplinkAction.match(URI.create("cryptomator://vault/bar")));
}
}

View File

@@ -40,8 +40,7 @@ public class FileOpenRequestHandlerTest {
public void testOpenArgsWithCorrectPaths() {
inTest.handleLaunchArgs(List.of("foo", "bar"));
AppLaunchEvent evt = queue.poll();
Assertions.assertNotNull(evt);
OpenFileEvent evt = Assertions.assertInstanceOf(OpenFileEvent.class, queue.poll());
Collection<Path> paths = evt.pathsToOpen();
MatcherAssert.assertThat(paths, CoreMatchers.hasItems(Paths.get("foo"), Paths.get("bar")));
}
@@ -60,7 +59,7 @@ public class FileOpenRequestHandlerTest {
@Test
@DisplayName("./cryptomator.exe foo (with full event queue)")
public void testOpenArgsWithFullQueue() {
queue.add(AppLaunchEvent.openFiles(Collections.emptyList()));
queue.add(new OpenFileEvent(Collections.emptyList()));
Assumptions.assumeTrue(queue.remainingCapacity() == 0);
inTest.handleLaunchArgs(List.of("foo"));

View File

@@ -1,69 +0,0 @@
package org.cryptomator.launcher;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class VaultCreateDeeplinkTest {
private static final byte[] TEMPLATE_BYTES = "a-ready-made-vault-zip".getBytes(StandardCharsets.UTF_8);
private static final String TEMPLATE_B64 = Base64.getUrlEncoder().withoutPadding().encodeToString(TEMPLATE_BYTES);
@Test
@DisplayName("a valid vault/create deeplink is parsed")
public void testValid() {
var inTest = VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault&template=" + TEMPLATE_B64));
Assertions.assertEquals("MyVault", inTest.name());
Assertions.assertArrayEquals(TEMPLATE_BYTES, inTest.template());
}
@Test
@DisplayName("a URL-encoded name is decoded")
public void testUrlEncodedName() {
var inTest = VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=My%20Vault&template=" + TEMPLATE_B64));
Assertions.assertEquals("My Vault", inTest.name());
}
@Test
@DisplayName("a missing name fails")
public void testMissingName() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a blank name fails")
public void testBlankName() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a missing template fails")
public void testMissingTemplate() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault")));
}
@Test
@DisplayName("an invalid Base64URL template fails")
public void testInvalidTemplate() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=MyVault&template=@@@")));
}
@Test
@DisplayName("a name containing a path separator fails")
public void testNameWithSlash() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=foo%2Fbar&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a name with parent-dir traversal fails")
public void testNameWithTraversal() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreateDeeplink.parse(URI.create("cryptomator://vault/create?name=..&template=" + TEMPLATE_B64)));
}
}

View File

@@ -0,0 +1,94 @@
package org.cryptomator.launcher;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Optional;
public class VaultCreationEventTest {
private static final byte[] TEMPLATE_BYTES = "a-ready-made-vault-zip".getBytes(StandardCharsets.UTF_8);
private static final String TEMPLATE_B64 = Base64.getUrlEncoder().withoutPadding().encodeToString(TEMPLATE_BYTES);
@Test
@DisplayName("a valid vault/create deeplink is parsed")
public void testValid() {
var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=MyVault&template=" + TEMPLATE_B64)).orElseThrow();
Assertions.assertEquals("MyVault", inTest.name());
Assertions.assertArrayEquals(TEMPLATE_BYTES, inTest.template());
}
@Test
@DisplayName("a URL-encoded name is decoded")
public void testUrlEncodedName() {
var inTest = VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=My%20Vault&template=" + TEMPLATE_B64)).orElseThrow();
Assertions.assertEquals("My Vault", inTest.name());
}
@Test
@DisplayName("a non-cryptomator scheme yields empty")
public void testWrongScheme() {
Assertions.assertEquals(Optional.empty(), VaultCreationEvent.tryParse(URI.create("foobar://vault/create?name=MyVault&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("an unknown host yields empty")
public void testWrongHost() {
Assertions.assertEquals(Optional.empty(), VaultCreationEvent.tryParse(URI.create("cryptomator://foo/create?name=MyVault&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("an unknown path yields empty")
public void testWrongPath() {
Assertions.assertEquals(Optional.empty(), VaultCreationEvent.tryParse(URI.create("cryptomator://vault/bar?name=MyVault&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a matching host is recognized case-insensitively")
public void testHostCaseInsensitive() {
Assertions.assertTrue(VaultCreationEvent.tryParse(URI.create("cryptomator://VAULT/create?name=MyVault&template=" + TEMPLATE_B64)).isPresent());
}
@Test
@DisplayName("a missing name fails")
public void testMissingName() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a blank name fails")
public void testBlankName() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a missing template fails")
public void testMissingTemplate() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=MyVault")));
}
@Test
@DisplayName("an invalid Base64URL template fails")
public void testInvalidTemplate() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=MyVault&template=@@@")));
}
@Test
@DisplayName("a name containing a path separator fails")
public void testNameWithSlash() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=foo%2Fbar&template=" + TEMPLATE_B64)));
}
@Test
@DisplayName("a name with parent-dir traversal fails")
public void testNameWithTraversal() {
Assertions.assertThrows(IllegalArgumentException.class, () -> VaultCreationEvent.tryParse(URI.create("cryptomator://vault/create?name=..&template=" + TEMPLATE_B64)));
}
}