From e742bc7d1dd788e509bd56bd1ca8ece298c0fb5b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 25 Nov 2025 08:45:01 -0600 Subject: [PATCH 1/3] Add Windows URI scheme registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic registration of URI schemes with the Windows Registry, enabling users to click custom links (e.g., fiji://open) in browsers or other applications to launch the Java application. Key components: - SchemeInstaller interface for platform-independent scheme registration - WindowsSchemeInstaller using Windows reg commands (no JNA dependency) - LinkHandler.getSchemes() for handlers to declare supported schemes - DefaultLinkService auto-registers schemes on context initialization The launcher sets the scijava.app.executable system property to specify the executable path for registration. Registration uses HKEY_CURRENT_USER and requires no admin privileges. Includes comprehensive tests and documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 56 ++++ doc/WINDOWS.md | 136 +++++++++ .../org/scijava/links/DefaultLinkService.java | 85 +++++- .../java/org/scijava/links/LinkHandler.java | 17 ++ .../links/installer/SchemeInstaller.java | 83 ++++++ .../installer/WindowsSchemeInstaller.java | 265 ++++++++++++++++++ .../installer/WindowsSchemeInstallerTest.java | 217 ++++++++++++++ 7 files changed, 855 insertions(+), 4 deletions(-) create mode 100644 doc/WINDOWS.md create mode 100644 src/main/java/org/scijava/links/installer/SchemeInstaller.java create mode 100644 src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java create mode 100644 src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java diff --git a/README.md b/README.md index e03d6cc..3030896 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,59 @@ that enables handling of URI-based links via plugins. It is kept separate from the SciJava Common core classes because it requires Java 11 as a minimum, due to its use of java.awt.Desktop features not present in Java 8. + +## Features + +- **Link handling**: Register custom handlers for URI schemes through the `LinkHandler` plugin interface +- **CLI integration**: Automatic handling of URI arguments passed on the command line via `ConsoleArgument` +- **OS integration**: Automatic registration of URI schemes with the operating system (Windows supported, macOS/Linux planned) + +## Usage + +### Creating a Link Handler + +Implement the `LinkHandler` interface to handle custom URI schemes: + +```java +@Plugin(type = LinkHandler.class) +public class MyLinkHandler extends AbstractLinkHandler { + + @Override + public boolean supports(URI uri) { + return "myapp".equals(uri.getScheme()); + } + + @Override + public void handle(URI uri) { + // Handle the URI + System.out.println("Handling: " + uri); + } + + @Override + public List getSchemes() { + // Return schemes to register with the OS + return Arrays.asList("myapp"); + } +} +``` + +### OS Registration + +On Windows, URI schemes returned by `LinkHandler.getSchemes()` are automatically registered +in the Windows Registry when the `LinkService` initializes. This allows users to click +links like `myapp://action` in web browsers or other applications, which will launch your +Java application with the URI as a command-line argument. + +The registration uses `HKEY_CURRENT_USER` and requires no administrator privileges. + +See [doc/WINDOWS.md](doc/WINDOWS.md) for details. + +## Architecture + +- `LinkService` - Service for routing URIs to appropriate handlers +- `LinkHandler` - Plugin interface for implementing custom URI handlers +- `LinkArgument` - Console argument plugin that recognizes URIs on the command line +- `SchemeInstaller` - Interface for OS-specific URI scheme registration +- `WindowsSchemeInstaller` - Windows implementation using registry commands + +The launcher should set the `scijava.app.executable` system property to enable URI scheme registration. diff --git a/doc/WINDOWS.md b/doc/WINDOWS.md new file mode 100644 index 0000000..ed8a7b1 --- /dev/null +++ b/doc/WINDOWS.md @@ -0,0 +1,136 @@ +# Windows URI Scheme Registration + +This document describes how URI scheme registration works on Windows in scijava-links. + +## Overview + +When a SciJava application starts on Windows, the `DefaultLinkService` automatically: + +1. Collects all URI schemes from registered `LinkHandler` plugins via `getSchemes()` +2. Reads the executable path from the `scijava.app.executable` system property +3. Registers each scheme in the Windows Registry under `HKEY_CURRENT_USER\Software\Classes` + +## Registry Structure + +For a scheme named `myapp`, the following registry structure is created: + +``` +HKEY_CURRENT_USER\Software\Classes\myapp + (Default) = "URL:myapp" + URL Protocol = "" + shell\ + open\ + command\ + (Default) = "C:\Path\To\App.exe" "%1" +``` + +## Implementation Details + +### SchemeInstaller Interface + +The `SchemeInstaller` interface provides a platform-independent API for URI scheme registration: + +- `isSupported()` - Checks if the installer works on the current platform +- `install(scheme, executablePath)` - Registers a URI scheme +- `isInstalled(scheme)` - Checks if a scheme is already registered +- `getInstalledPath(scheme)` - Gets the executable path for a registered scheme +- `uninstall(scheme)` - Removes a URI scheme registration + +### WindowsSchemeInstaller + +The Windows implementation uses the `reg` command-line tool to manipulate the registry: + +- **No JNA dependency**: Uses native Windows `reg` commands via `ProcessBuilder` +- **No admin rights**: Registers under `HKEY_CURRENT_USER` (not `HKEY_LOCAL_MACHINE`) +- **Idempotent**: Safely handles re-registration with the same or different paths +- **Robust error handling**: Proper timeouts, error logging, and validation + +### Executable Path Configuration + +The launcher must set the `scijava.app.executable` system property to the absolute path of the application's executable. This property is used by `DefaultLinkService` during URI scheme registration. + +Example launcher configuration: +```bash +java -Dscijava.app.executable="C:\Program Files\MyApp\MyApp.exe" -jar myapp.jar +``` + +On Windows, the launcher typically sets this to the `.exe` file path. On macOS, it would be the path inside the `.app` bundle. On Linux, it would be the shell script or executable. + +## Example Handler + +Here's a complete example of a `LinkHandler` that registers a custom scheme: + +```java +package com.example; + +import org.scijava.links.AbstractLinkHandler; +import org.scijava.links.LinkHandler; +import org.scijava.log.LogService; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +@Plugin(type = LinkHandler.class) +public class ExampleLinkHandler extends AbstractLinkHandler { + + @Parameter(required = false) + private LogService log; + + @Override + public boolean supports(final URI uri) { + return "example".equals(uri.getScheme()); + } + + @Override + public void handle(final URI uri) { + if (log != null) { + log.info("Handling example URI: " + uri); + } + + // Parse the URI and perform actions + String operation = Links.operation(uri); + Map params = Links.query(uri); + + // Your business logic here + // ... + } + + @Override + public List getSchemes() { + // This tells the system to register "example://" links on Windows + return Arrays.asList("example"); + } +} +``` + +## Testing + +The Windows scheme installation can be tested on Windows systems: + +```bash +mvn test -Dtest=WindowsSchemeInstallerTest +``` + +Tests are automatically skipped on non-Windows platforms using JUnit's `Assume.assumeTrue()`. + +To test with a specific executable path, set the system property: +```bash +mvn test -Dscijava.app.executable="C:\Path\To\App.exe" +``` + +## Platform Notes + +**macOS**: URI schemes are declared in the application's `Info.plist` within the `.app` bundle. This is configured at build/packaging time, not at runtime, since the bundle is typically code-signed and immutable. + +**Linux**: URI schemes are declared in `.desktop` files, which is part of broader desktop integration (icons, MIME types, etc.). This functionality belongs in `scijava-plugins-platforms` rather than this component. + +**Windows**: Runtime registration is appropriate because the Windows Registry is designed for runtime modifications, and registration under `HKEY_CURRENT_USER` requires no elevated privileges. + +## Future Enhancements + +- **Scheme validation**: Validate scheme names against RFC 3986 +- **User prompts**: Optional confirmation before registering schemes +- **Uninstallation**: Automatic cleanup on application uninstall diff --git a/src/main/java/org/scijava/links/DefaultLinkService.java b/src/main/java/org/scijava/links/DefaultLinkService.java index b5c8750..cc1dd29 100644 --- a/src/main/java/org/scijava/links/DefaultLinkService.java +++ b/src/main/java/org/scijava/links/DefaultLinkService.java @@ -30,12 +30,18 @@ import org.scijava.event.ContextCreatedEvent; import org.scijava.event.EventHandler; +import org.scijava.links.installer.SchemeInstaller; +import org.scijava.links.installer.WindowsSchemeInstaller; +import org.scijava.log.LogService; import org.scijava.plugin.AbstractHandlerService; +import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; import org.scijava.service.Service; import java.awt.Desktop; import java.net.URI; +import java.util.HashSet; +import java.util.Set; /** * Default implementation of {@link LinkService}. @@ -45,13 +51,84 @@ @Plugin(type = Service.class) public class DefaultLinkService extends AbstractHandlerService implements LinkService { + @Parameter(required = false) + private LogService log; + @EventHandler private void onEvent(final ContextCreatedEvent evt) { // Register URI handler with the desktop system, if possible. - if (!Desktop.isDesktopSupported()) return; - final Desktop desktop = Desktop.getDesktop(); - if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) return; - desktop.setOpenURIHandler(event -> handle(event.getURI())); + if (Desktop.isDesktopSupported()) { + final Desktop desktop = Desktop.getDesktop(); + if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { + desktop.setOpenURIHandler(event -> handle(event.getURI())); + } + } + + // Register URI schemes with the operating system (Windows only). + installSchemes(); + } + + /** + * Installs URI schemes with the operating system. + *

+ * This method collects all schemes supported by registered {@link LinkHandler} + * plugins and registers them with the OS (currently Windows only). + *

+ */ + private void installSchemes() { + // Create the appropriate installer for this platform + final SchemeInstaller installer = createInstaller(); + if (installer == null || !installer.isSupported()) { + if (log != null) log.debug("Scheme installation not supported on this platform"); + return; + } + + // Get executable path from system property + final String executablePath = System.getProperty("scijava.app.executable"); + if (executablePath == null) { + if (log != null) log.debug("No executable path set (scijava.app.executable property)"); + return; + } + + // Collect all schemes from registered handlers + final Set schemes = collectSchemes(); + if (schemes.isEmpty()) { + if (log != null) log.debug("No URI schemes to register"); + return; + } + + // Install each scheme + for (final String scheme : schemes) { + try { + installer.install(scheme, executablePath); + } + catch (final Exception e) { + if (log != null) log.error("Failed to install URI scheme: " + scheme, e); + } + } + } + + /** + * Creates the appropriate {@link SchemeInstaller} for the current platform. + *

+ * Currently only Windows is supported. macOS uses Info.plist in the .app bundle + * (configured at build time), and Linux .desktop file management belongs in + * scijava-plugins-platforms. + *

+ */ + private SchemeInstaller createInstaller() { + return new WindowsSchemeInstaller(log); + } + + /** + * Collects all URI schemes from registered {@link LinkHandler} plugins. + */ + private Set collectSchemes() { + final Set schemes = new HashSet<>(); + for (final LinkHandler handler : getInstances()) { + schemes.addAll(handler.getSchemes()); + } + return schemes; } } diff --git a/src/main/java/org/scijava/links/LinkHandler.java b/src/main/java/org/scijava/links/LinkHandler.java index 5dfded8..6949179 100644 --- a/src/main/java/org/scijava/links/LinkHandler.java +++ b/src/main/java/org/scijava/links/LinkHandler.java @@ -31,6 +31,8 @@ import org.scijava.plugin.HandlerPlugin; import java.net.URI; +import java.util.Collections; +import java.util.List; /** * A plugin for handling URI links. @@ -46,6 +48,21 @@ public interface LinkHandler extends HandlerPlugin { */ void handle(URI uri); + /** + * Gets the URI schemes that this handler supports. + *

+ * This method is used for registering URI schemes with the operating system. + * Handlers should return a list of scheme names (e.g., "fiji", "imagej") + * that they can handle. Return an empty list if the handler does not + * require OS-level scheme registration. + *

+ * + * @return List of URI schemes supported by this handler + */ + default List getSchemes() { + return Collections.emptyList(); + } + @Override default Class getType() { return URI.class; diff --git a/src/main/java/org/scijava/links/installer/SchemeInstaller.java b/src/main/java/org/scijava/links/installer/SchemeInstaller.java new file mode 100644 index 0000000..a95ee85 --- /dev/null +++ b/src/main/java/org/scijava/links/installer/SchemeInstaller.java @@ -0,0 +1,83 @@ +/*- + * #%L + * URL scheme handlers for SciJava. + * %% + * Copyright (C) 2023 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.links.installer; + +import java.io.IOException; + +/** + * Interface for installing URI scheme handlers in the operating system. + *

+ * Implementations provide OS-specific logic for registering custom URI schemes + * so that clicking links with those schemes will launch the Java application. + *

+ * + * @author Curtis Rueden + */ +public interface SchemeInstaller { + + /** + * Checks if this installer is supported on the current platform. + * + * @return true if the installer can run on this OS + */ + boolean isSupported(); + + /** + * Installs a URI scheme handler in the operating system. + * + * @param scheme The URI scheme to register (e.g., "fiji", "imagej") + * @param executablePath The absolute path to the executable to launch + * @throws IOException if installation fails + */ + void install(String scheme, String executablePath) throws IOException; + + /** + * Checks if a URI scheme is already registered. + * + * @param scheme The URI scheme to check + * @return true if the scheme is already registered + */ + boolean isInstalled(String scheme); + + /** + * Gets the executable path registered for a given scheme. + * + * @param scheme The URI scheme to query + * @return The registered executable path, or null if not registered + */ + String getInstalledPath(String scheme); + + /** + * Uninstalls a URI scheme handler from the operating system. + * + * @param scheme The URI scheme to unregister + * @throws IOException if uninstallation fails + */ + void uninstall(String scheme) throws IOException; +} diff --git a/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java b/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java new file mode 100644 index 0000000..0ba3592 --- /dev/null +++ b/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java @@ -0,0 +1,265 @@ +/*- + * #%L + * URL scheme handlers for SciJava. + * %% + * Copyright (C) 2023 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.links.installer; + +import org.scijava.log.LogService; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +/** + * Windows implementation of {@link SchemeInstaller} using the Windows Registry. + *

+ * This implementation uses the {@code reg} command-line tool to manipulate + * the Windows Registry under {@code HKEY_CURRENT_USER\Software\Classes}. + * No administrator privileges are required. + *

+ * + * @author Curtis Rueden + * @author Marwan Zouinkhi + */ +public class WindowsSchemeInstaller implements SchemeInstaller { + + private static final long COMMAND_TIMEOUT_SECONDS = 10; + + private final LogService log; + + public WindowsSchemeInstaller(final LogService log) { + this.log = log; + } + + @Override + public boolean isSupported() { + final String os = System.getProperty("os.name"); + return os != null && os.toLowerCase().contains("win"); + } + + @Override + public void install(final String scheme, final String executablePath) throws IOException { + if (!isSupported()) { + throw new UnsupportedOperationException("Windows registry installation not supported on: " + System.getProperty("os.name")); + } + + // Validate inputs + if (scheme == null || scheme.isEmpty()) { + throw new IllegalArgumentException("Scheme cannot be null or empty"); + } + if (executablePath == null || executablePath.isEmpty()) { + throw new IllegalArgumentException("Executable path cannot be null or empty"); + } + + // Check if already installed with same path + if (isInstalled(scheme)) { + final String existingPath = getInstalledPath(scheme); + if (executablePath.equals(existingPath)) { + if (log != null) log.debug("Scheme '" + scheme + "' already registered to: " + existingPath); + return; + } + } + + // Registry key paths (HKCU = HKEY_CURRENT_USER, no admin rights needed) + final String keyPath = "HKCU\\Software\\Classes\\" + scheme; + final String shellPath = keyPath + "\\shell"; + final String openPath = shellPath + "\\open"; + final String commandPath = openPath + "\\command"; + + // Commands to register the URI scheme + final String[][] commands = { + {"reg", "add", keyPath, "/f"}, + {"reg", "add", keyPath, "/ve", "/d", "URL:" + scheme, "/f"}, + {"reg", "add", keyPath, "/v", "URL Protocol", "/f"}, + {"reg", "add", shellPath, "/f"}, + {"reg", "add", openPath, "/f"}, + {"reg", "add", commandPath, "/ve", "/d", "\"" + executablePath + "\" \"%1\"", "/f"} + }; + + // Execute commands + for (final String[] command : commands) { + if (!executeCommand(command)) { + throw new IOException("Failed to execute registry command: " + String.join(" ", command)); + } + } + + if (log != null) log.info("Registered URI scheme '" + scheme + "' to: " + executablePath); + } + + @Override + public boolean isInstalled(final String scheme) { + if (!isSupported()) return false; + + final String keyPath = "HKCU\\Software\\Classes\\" + scheme; + return executeCommand(new String[]{"reg", "query", keyPath}); + } + + @Override + public String getInstalledPath(final String scheme) { + if (!isInstalled(scheme)) return null; + + final String commandPath = "HKCU\\Software\\Classes\\" + scheme + "\\shell\\open\\command"; + try { + final ProcessBuilder pb = new ProcessBuilder("reg", "query", commandPath, "/ve"); + final Process process = pb.start(); + + // Read output + final StringBuilder output = new StringBuilder(); + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + } + + // Wait for completion + final boolean finished = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return null; + } + + if (process.exitValue() != 0) { + return null; + } + + // Parse output to extract path + // Format: " (Default) REG_SZ \"C:\path\to\app.exe\" \"%1\"" + return parsePathFromRegQueryOutput(output.toString()); + } + catch (final IOException | InterruptedException e) { + if (log != null) log.debug("Failed to query registry for scheme: " + scheme, e); + return null; + } + } + + @Override + public void uninstall(final String scheme) throws IOException { + if (!isSupported()) { + throw new UnsupportedOperationException("Windows registry uninstallation not supported on: " + System.getProperty("os.name")); + } + + if (!isInstalled(scheme)) { + if (log != null) log.debug("Scheme '" + scheme + "' is not installed"); + return; + } + + final String keyPath = "HKCU\\Software\\Classes\\" + scheme; + final String[] command = {"reg", "delete", keyPath, "/f"}; + + if (!executeCommand(command)) { + throw new IOException("Failed to uninstall scheme: " + scheme); + } + + if (log != null) log.info("Uninstalled URI scheme: " + scheme); + } + + // -- Helper methods -- + + /** + * Executes a Windows command and returns whether it succeeded. + */ + private boolean executeCommand(final String[] command) { + try { + final ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + final Process process = pb.start(); + + // Consume output to prevent blocking + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + final StringBuilder output = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + if (log != null && !output.toString().trim().isEmpty()) { + log.debug("Command output: " + output); + } + } + + // Wait for completion + final boolean finished = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + if (log != null) log.warn("Command timed out: " + String.join(" ", command)); + process.destroyForcibly(); + return false; + } + + final int exitCode = process.exitValue(); + if (exitCode != 0 && log != null) { + log.debug("Command failed with exit code " + exitCode + ": " + String.join(" ", command)); + } + + return exitCode == 0; + } + catch (final IOException e) { + if (log != null) log.error("Failed to execute command: " + String.join(" ", command), e); + return false; + } + catch (final InterruptedException e) { + if (log != null) log.error("Command interrupted: " + String.join(" ", command), e); + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * Parses the executable path from {@code reg query} output. + * Expected format: " (Default) REG_SZ \"C:\path\to\app.exe\" \"%1\"" + */ + private String parsePathFromRegQueryOutput(final String output) { + // Find the line containing REG_SZ or REG_EXPAND_SZ + for (final String line : output.split("\n")) { + if (line.contains("REG_SZ") || line.contains("REG_EXPAND_SZ")) { + // Extract the value after REG_SZ + final int regSzIndex = line.indexOf("REG_SZ"); + final int regExpandSzIndex = line.indexOf("REG_EXPAND_SZ"); + final int startIndex = Math.max(regSzIndex, regExpandSzIndex) + (regSzIndex > 0 ? 6 : 13); + + if (startIndex < line.length()) { + String value = line.substring(startIndex).trim(); + // Remove "%1" parameter if present + if (value.endsWith(" \"%1\"")) { + value = value.substring(0, value.length() - 5).trim(); + } else if (value.endsWith(" %1")) { + value = value.substring(0, value.length() - 3).trim(); + } + // Remove surrounding quotes if present + if (value.startsWith("\"") && value.endsWith("\"") && value.length() > 1) { + value = value.substring(1, value.length() - 1); + } + return value; + } + } + } + return null; + } +} diff --git a/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java b/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java new file mode 100644 index 0000000..7f2e4bc --- /dev/null +++ b/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java @@ -0,0 +1,217 @@ +/*- + * #%L + * URL scheme handlers for SciJava. + * %% + * Copyright (C) 2023 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.links.installer; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; + +import static org.junit.Assert.*; + +/** + * Tests {@link WindowsSchemeInstaller}. + * + * @author Curtis Rueden + */ +public class WindowsSchemeInstallerTest { + + private static final String TEST_SCHEME = "scijava-test"; + private WindowsSchemeInstaller installer; + + @Before + public void setUp() { + installer = new WindowsSchemeInstaller(null); + // Only run tests on Windows + Assume.assumeTrue("Tests only run on Windows", installer.isSupported()); + } + + @After + public void tearDown() { + // Clean up test scheme if it was installed + if (installer != null && installer.isInstalled(TEST_SCHEME)) { + try { + installer.uninstall(TEST_SCHEME); + } + catch (final IOException e) { + // Ignore cleanup errors + } + } + } + + @Test + public void testIsSupported() { + final String os = System.getProperty("os.name"); + final boolean expectedSupport = os != null && os.toLowerCase().contains("win"); + assertEquals(expectedSupport, installer.isSupported()); + } + + @Test + public void testInstallAndUninstall() throws IOException { + // Arrange + final String execPath = "C:\\Program Files\\Test\\test.exe"; + + // Ensure scheme is not already installed + if (installer.isInstalled(TEST_SCHEME)) { + installer.uninstall(TEST_SCHEME); + } + assertFalse("Test scheme should not be installed initially", installer.isInstalled(TEST_SCHEME)); + + // Act - Install + installer.install(TEST_SCHEME, execPath); + + // Assert - Installed + assertTrue("Scheme should be installed", installer.isInstalled(TEST_SCHEME)); + final String installedPath = installer.getInstalledPath(TEST_SCHEME); + assertEquals("Installed path should match", execPath, installedPath); + + // Act - Uninstall + installer.uninstall(TEST_SCHEME); + + // Assert - Uninstalled + assertFalse("Scheme should be uninstalled", installer.isInstalled(TEST_SCHEME)); + assertNull("Installed path should be null after uninstall", installer.getInstalledPath(TEST_SCHEME)); + } + + @Test + public void testInstallTwiceWithSamePath() throws IOException { + // Arrange + final String execPath = "C:\\Program Files\\Test\\test.exe"; + + // Ensure scheme is not already installed + if (installer.isInstalled(TEST_SCHEME)) { + installer.uninstall(TEST_SCHEME); + } + + // Act - Install twice + installer.install(TEST_SCHEME, execPath); + installer.install(TEST_SCHEME, execPath); // Should not fail + + // Assert + assertTrue("Scheme should still be installed", installer.isInstalled(TEST_SCHEME)); + assertEquals("Path should match", execPath, installer.getInstalledPath(TEST_SCHEME)); + } + + @Test + public void testInstallWithDifferentPath() throws IOException { + // Arrange + final String execPath1 = "C:\\Program Files\\Test1\\test.exe"; + final String execPath2 = "C:\\Program Files\\Test2\\test.exe"; + + // Ensure scheme is not already installed + if (installer.isInstalled(TEST_SCHEME)) { + installer.uninstall(TEST_SCHEME); + } + + // Act - Install with first path + installer.install(TEST_SCHEME, execPath1); + assertEquals("First path should be installed", execPath1, installer.getInstalledPath(TEST_SCHEME)); + + // Act - Install with second path (should update) + installer.install(TEST_SCHEME, execPath2); + + // Assert + assertTrue("Scheme should still be installed", installer.isInstalled(TEST_SCHEME)); + assertEquals("Path should be updated", execPath2, installer.getInstalledPath(TEST_SCHEME)); + } + + @Test + public void testGetInstalledPathForNonExistentScheme() { + // Arrange - ensure scheme doesn't exist + if (installer.isInstalled(TEST_SCHEME)) { + try { + installer.uninstall(TEST_SCHEME); + } + catch (final IOException e) { + // Ignore + } + } + + // Act + final String path = installer.getInstalledPath(TEST_SCHEME); + + // Assert + assertNull("Path should be null for non-existent scheme", path); + } + + @Test + public void testUninstallNonExistentScheme() throws IOException { + // Arrange - ensure scheme doesn't exist + if (installer.isInstalled(TEST_SCHEME)) { + installer.uninstall(TEST_SCHEME); + } + + // Act - uninstall non-existent scheme (should not fail) + installer.uninstall(TEST_SCHEME); + + // Assert + assertFalse("Scheme should not be installed", installer.isInstalled(TEST_SCHEME)); + } + + @Test(expected = IllegalArgumentException.class) + public void testInstallWithNullScheme() throws IOException { + installer.install(null, "C:\\test.exe"); + } + + @Test(expected = IllegalArgumentException.class) + public void testInstallWithEmptyScheme() throws IOException { + installer.install("", "C:\\test.exe"); + } + + @Test(expected = IllegalArgumentException.class) + public void testInstallWithNullPath() throws IOException { + installer.install(TEST_SCHEME, null); + } + + @Test(expected = IllegalArgumentException.class) + public void testInstallWithEmptyPath() throws IOException { + installer.install(TEST_SCHEME, ""); + } + + @Test + public void testInstallWithPathContainingSpaces() throws IOException { + // Arrange + final String execPath = "C:\\Program Files\\My App\\app.exe"; + + // Ensure scheme is not already installed + if (installer.isInstalled(TEST_SCHEME)) { + installer.uninstall(TEST_SCHEME); + } + + // Act + installer.install(TEST_SCHEME, execPath); + + // Assert + assertTrue("Scheme should be installed", installer.isInstalled(TEST_SCHEME)); + assertEquals("Path with spaces should be handled correctly", execPath, installer.getInstalledPath(TEST_SCHEME)); + } +} From 735fafd2b5a4667e1925751f269df35d1a645fcd Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 25 Nov 2025 09:44:30 -0600 Subject: [PATCH 2/3] Always refer to "URI schemes", not "URL schemes" "URI scheme" is more technically correct. According to RFC 3986 (the URI specification), "scheme" is formally part of URI syntax, and the official IANA registry is called "Uniform Resource Identifier (URI) Schemes". The distinction: - URI = Broader term for identifying resources - URL = Specific type of URI that includes location/access information Custom schemes like myapp://open/file are technically URIs, and the myapp part is a "URI scheme". While "URL scheme" is used colloquially (especially in mobile app contexts), "URI scheme" is more precise. --- pom.xml | 2 +- src/main/java/org/scijava/links/AbstractLinkHandler.java | 2 +- src/main/java/org/scijava/links/DefaultLinkService.java | 2 +- src/main/java/org/scijava/links/LinkHandler.java | 2 +- src/main/java/org/scijava/links/LinkService.java | 2 +- src/main/java/org/scijava/links/Links.java | 2 +- src/main/java/org/scijava/links/console/LinkArgument.java | 2 +- .../java/org/scijava/links/installer/SchemeInstaller.java | 6 +++--- .../org/scijava/links/installer/WindowsSchemeInstaller.java | 6 +++--- src/test/java/org/scijava/links/LinksTest.java | 2 +- .../scijava/links/installer/WindowsSchemeInstallerTest.java | 6 +++--- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pom.xml b/pom.xml index f1bedb6..797be9f 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ 1.0.1-SNAPSHOT SciJava Links - URL scheme handlers for SciJava. + URI scheme handlers for SciJava. https://github.com/scijava/scijava-links 2023 diff --git a/src/main/java/org/scijava/links/AbstractLinkHandler.java b/src/main/java/org/scijava/links/AbstractLinkHandler.java index 567f4d1..5062500 100644 --- a/src/main/java/org/scijava/links/AbstractLinkHandler.java +++ b/src/main/java/org/scijava/links/AbstractLinkHandler.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/main/java/org/scijava/links/DefaultLinkService.java b/src/main/java/org/scijava/links/DefaultLinkService.java index cc1dd29..040347e 100644 --- a/src/main/java/org/scijava/links/DefaultLinkService.java +++ b/src/main/java/org/scijava/links/DefaultLinkService.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/main/java/org/scijava/links/LinkHandler.java b/src/main/java/org/scijava/links/LinkHandler.java index 6949179..c4d148d 100644 --- a/src/main/java/org/scijava/links/LinkHandler.java +++ b/src/main/java/org/scijava/links/LinkHandler.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/main/java/org/scijava/links/LinkService.java b/src/main/java/org/scijava/links/LinkService.java index f9dec83..bed4876 100644 --- a/src/main/java/org/scijava/links/LinkService.java +++ b/src/main/java/org/scijava/links/LinkService.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/main/java/org/scijava/links/Links.java b/src/main/java/org/scijava/links/Links.java index 76d2a50..72f3e23 100644 --- a/src/main/java/org/scijava/links/Links.java +++ b/src/main/java/org/scijava/links/Links.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/main/java/org/scijava/links/console/LinkArgument.java b/src/main/java/org/scijava/links/console/LinkArgument.java index 213a951..05f93b5 100644 --- a/src/main/java/org/scijava/links/console/LinkArgument.java +++ b/src/main/java/org/scijava/links/console/LinkArgument.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/main/java/org/scijava/links/installer/SchemeInstaller.java b/src/main/java/org/scijava/links/installer/SchemeInstaller.java index a95ee85..31ae779 100644 --- a/src/main/java/org/scijava/links/installer/SchemeInstaller.java +++ b/src/main/java/org/scijava/links/installer/SchemeInstaller.java @@ -1,18 +1,18 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java b/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java index 0ba3592..3fa64d7 100644 --- a/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java +++ b/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java @@ -1,18 +1,18 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE diff --git a/src/test/java/org/scijava/links/LinksTest.java b/src/test/java/org/scijava/links/LinksTest.java index 5ffc2f3..ef96baf 100644 --- a/src/test/java/org/scijava/links/LinksTest.java +++ b/src/test/java/org/scijava/links/LinksTest.java @@ -1,6 +1,6 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% diff --git a/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java b/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java index 7f2e4bc..4546597 100644 --- a/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java +++ b/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java @@ -1,18 +1,18 @@ /*- * #%L - * URL scheme handlers for SciJava. + * URI scheme handlers for SciJava. * %% * Copyright (C) 2023 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE From 28bd7290de648ea4d3063b02cadc0fc4182eb8aa Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 25 Nov 2025 17:14:08 -0600 Subject: [PATCH 3/3] Add Linux URI scheme registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends URI scheme registration to Linux via .desktop file manipulation. The LinuxSchemeInstaller modifies the MimeType field in the .desktop file to add x-scheme-handler entries, then registers them using xdg-mime. Key components: - DesktopFile: Simple parser/writer for .desktop files - LinuxSchemeInstaller: Adds URI schemes to existing .desktop files - Updates DefaultLinkService to support both Windows and Linux The .desktop file path is specified via the scijava.app.desktop-file system property. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../org/scijava/links/DefaultLinkService.java | 23 +- .../scijava/links/installer/DesktopFile.java | 231 ++++++++++++++++ .../links/installer/LinuxSchemeInstaller.java | 246 ++++++++++++++++++ .../installer/LinuxSchemeInstallerTest.java | 232 +++++++++++++++++ 4 files changed, 726 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/scijava/links/installer/DesktopFile.java create mode 100644 src/main/java/org/scijava/links/installer/LinuxSchemeInstaller.java create mode 100644 src/test/java/org/scijava/links/installer/LinuxSchemeInstallerTest.java diff --git a/src/main/java/org/scijava/links/DefaultLinkService.java b/src/main/java/org/scijava/links/DefaultLinkService.java index 040347e..2a4b3c9 100644 --- a/src/main/java/org/scijava/links/DefaultLinkService.java +++ b/src/main/java/org/scijava/links/DefaultLinkService.java @@ -30,6 +30,7 @@ import org.scijava.event.ContextCreatedEvent; import org.scijava.event.EventHandler; +import org.scijava.links.installer.LinuxSchemeInstaller; import org.scijava.links.installer.SchemeInstaller; import org.scijava.links.installer.WindowsSchemeInstaller; import org.scijava.log.LogService; @@ -64,7 +65,7 @@ private void onEvent(final ContextCreatedEvent evt) { } } - // Register URI schemes with the operating system (Windows only). + // Register URI schemes with the operating system. installSchemes(); } @@ -72,7 +73,7 @@ private void onEvent(final ContextCreatedEvent evt) { * Installs URI schemes with the operating system. *

* This method collects all schemes supported by registered {@link LinkHandler} - * plugins and registers them with the OS (currently Windows only). + * plugins and registers them with the OS (Windows and Linux supported). *

*/ private void installSchemes() { @@ -111,13 +112,23 @@ private void installSchemes() { /** * Creates the appropriate {@link SchemeInstaller} for the current platform. *

- * Currently only Windows is supported. macOS uses Info.plist in the .app bundle - * (configured at build time), and Linux .desktop file management belongs in - * scijava-plugins-platforms. + * Windows and Linux are supported via runtime registration. macOS uses Info.plist + * in the .app bundle (configured at build time, not at runtime). *

*/ private SchemeInstaller createInstaller() { - return new WindowsSchemeInstaller(log); + final String os = System.getProperty("os.name"); + if (os == null) return null; + + final String osLower = os.toLowerCase(); + if (osLower.contains("linux")) { + return new LinuxSchemeInstaller(log); + } + else if (osLower.contains("win")) { + return new WindowsSchemeInstaller(log); + } + + return null; // macOS or other unsupported platforms } /** diff --git a/src/main/java/org/scijava/links/installer/DesktopFile.java b/src/main/java/org/scijava/links/installer/DesktopFile.java new file mode 100644 index 0000000..4d39514 --- /dev/null +++ b/src/main/java/org/scijava/links/installer/DesktopFile.java @@ -0,0 +1,231 @@ +/*- + * #%L + * URI scheme handlers for SciJava. + * %% + * Copyright (C) 2023 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.links.installer; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Simple parser and writer for Linux .desktop files. + *

+ * Supports reading and writing key-value pairs within the [Desktop Entry] section. + * This implementation is minimal and focused on URI scheme registration needs. + *

+ * + * @author Curtis Rueden + */ +public class DesktopFile { + + private final Map entries; + private final List comments; + + public DesktopFile() { + this.entries = new LinkedHashMap<>(); + this.comments = new ArrayList<>(); + } + + /** + * Parses a .desktop file from disk. + * + * @param path Path to the .desktop file + * @return Parsed DesktopFile + * @throws IOException if reading fails + */ + public static DesktopFile parse(final Path path) throws IOException { + final DesktopFile df = new DesktopFile(); + boolean inDesktopEntry = false; + + try (final BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + final String trimmed = line.trim(); + + // Track section + if (trimmed.equals("[Desktop Entry]")) { + inDesktopEntry = true; + continue; + } + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + inDesktopEntry = false; + continue; + } + + // Only process [Desktop Entry] section + if (!inDesktopEntry) continue; + + // Skip empty lines and comments + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + df.comments.add(line); + continue; + } + + // Parse key=value + final int equals = line.indexOf('='); + if (equals > 0) { + final String key = line.substring(0, equals).trim(); + final String value = line.substring(equals + 1); + df.entries.put(key, value); + } + } + } + + return df; + } + + /** + * Writes the .desktop file to disk. + * + * @param path Path to write to + * @throws IOException if writing fails + */ + public void writeTo(final Path path) throws IOException { + // Ensure parent directory exists + final Path parent = path.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + try (final BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + writer.write("[Desktop Entry]"); + writer.newLine(); + + // Write key-value pairs + for (final Map.Entry entry : entries.entrySet()) { + writer.write(entry.getKey()); + writer.write('='); + writer.write(entry.getValue()); + writer.newLine(); + } + + // Write comments at the end + for (final String comment : comments) { + writer.write(comment); + writer.newLine(); + } + } + } + + /** + * Gets the value for a key. + * + * @param key The key + * @return The value, or null if not present + */ + public String get(final String key) { + return entries.get(key); + } + + /** + * Sets a key-value pair. + * + * @param key The key + * @param value The value + */ + public void set(final String key, final String value) { + entries.put(key, value); + } + + /** + * Checks if a MimeType entry contains a specific MIME type. + * + * @param mimeType The MIME type to check (e.g., "x-scheme-handler/fiji") + * @return true if the MimeType field contains this type + */ + public boolean hasMimeType(final String mimeType) { + final String mimeTypes = entries.get("MimeType"); + if (mimeTypes == null || mimeTypes.isEmpty()) return false; + + final String[] types = mimeTypes.split(";"); + for (final String type : types) { + if (type.trim().equals(mimeType)) { + return true; + } + } + return false; + } + + /** + * Adds a MIME type to the MimeType field. + *

+ * The MimeType field is a semicolon-separated list. This method appends + * the new type if it's not already present. + *

+ * + * @param mimeType The MIME type to add (e.g., "x-scheme-handler/fiji") + */ + public void addMimeType(final String mimeType) { + if (hasMimeType(mimeType)) return; // Already present + + String mimeTypes = entries.get("MimeType"); + if (mimeTypes == null || mimeTypes.isEmpty()) { + // Create new MimeType field + entries.put("MimeType", mimeType + ";"); + } + else { + // Append to existing + if (!mimeTypes.endsWith(";")) { + mimeTypes += ";"; + } + entries.put("MimeType", mimeTypes + mimeType + ";"); + } + } + + /** + * Removes a MIME type from the MimeType field. + * + * @param mimeType The MIME type to remove + */ + public void removeMimeType(final String mimeType) { + final String mimeTypes = entries.get("MimeType"); + if (mimeTypes == null || mimeTypes.isEmpty()) return; + + final List types = new ArrayList<>(); + for (final String type : mimeTypes.split(";")) { + final String trimmed = type.trim(); + if (!trimmed.isEmpty() && !trimmed.equals(mimeType)) { + types.add(trimmed); + } + } + + if (types.isEmpty()) { + entries.remove("MimeType"); + } + else { + entries.put("MimeType", String.join(";", types) + ";"); + } + } +} diff --git a/src/main/java/org/scijava/links/installer/LinuxSchemeInstaller.java b/src/main/java/org/scijava/links/installer/LinuxSchemeInstaller.java new file mode 100644 index 0000000..49600f5 --- /dev/null +++ b/src/main/java/org/scijava/links/installer/LinuxSchemeInstaller.java @@ -0,0 +1,246 @@ +/*- + * #%L + * URI scheme handlers for SciJava. + * %% + * Copyright (C) 2023 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.links.installer; + +import org.scijava.log.LogService; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; + +/** + * Linux implementation of {@link SchemeInstaller} using .desktop files. + *

+ * This implementation modifies the .desktop file specified by the + * {@code scijava.app.desktop-file} system property to add URI scheme + * handlers via the MimeType field, then registers them using xdg-mime. + *

+ * + * @author Curtis Rueden + */ +public class LinuxSchemeInstaller implements SchemeInstaller { + + private static final long COMMAND_TIMEOUT_SECONDS = 10; + + private final LogService log; + + public LinuxSchemeInstaller(final LogService log) { + this.log = log; + } + + @Override + public boolean isSupported() { + final String os = System.getProperty("os.name"); + return os != null && os.toLowerCase().contains("linux"); + } + + @Override + public void install(final String scheme, final String executablePath) throws IOException { + if (!isSupported()) { + throw new UnsupportedOperationException("Linux .desktop file installation not supported on: " + System.getProperty("os.name")); + } + + // Validate inputs + if (scheme == null || scheme.isEmpty()) { + throw new IllegalArgumentException("Scheme cannot be null or empty"); + } + + // Get desktop file path from system property + final String desktopFileProp = System.getProperty("scijava.app.desktop-file"); + if (desktopFileProp == null || desktopFileProp.isEmpty()) { + throw new IOException("scijava.app.desktop-file property not set"); + } + + final Path desktopFile = Paths.get(desktopFileProp); + if (!Files.exists(desktopFile)) { + throw new IOException("Desktop file does not exist: " + desktopFile); + } + + // Parse desktop file + final DesktopFile df = DesktopFile.parse(desktopFile); + + // Check if scheme already registered + final String mimeType = "x-scheme-handler/" + scheme; + if (df.hasMimeType(mimeType)) { + if (log != null) log.debug("Scheme '" + scheme + "' already registered in: " + desktopFile); + return; + } + + // Add MIME type + df.addMimeType(mimeType); + + // Write back to file + df.writeTo(desktopFile); + + // Register with xdg-mime + final String desktopFileName = desktopFile.getFileName().toString(); + if (!executeCommand(new String[]{"xdg-mime", "default", desktopFileName, mimeType})) { + throw new IOException("Failed to register scheme with xdg-mime: " + scheme); + } + + // Update desktop database + final Path applicationsDir = desktopFile.getParent(); + if (applicationsDir != null) { + executeCommand(new String[]{"update-desktop-database", applicationsDir.toString()}); + // Note: update-desktop-database may fail if not installed, but this is non-critical + } + + if (log != null) log.info("Registered URI scheme '" + scheme + "' in: " + desktopFile); + } + + @Override + public boolean isInstalled(final String scheme) { + if (!isSupported()) return false; + + final String desktopFileProp = System.getProperty("scijava.app.desktop-file"); + if (desktopFileProp == null) return false; + + final Path desktopFile = Paths.get(desktopFileProp); + if (!Files.exists(desktopFile)) return false; + + try { + final DesktopFile df = DesktopFile.parse(desktopFile); + return df.hasMimeType("x-scheme-handler/" + scheme); + } + catch (final IOException e) { + if (log != null) log.debug("Failed to parse desktop file: " + desktopFile, e); + return false; + } + } + + @Override + public String getInstalledPath(final String scheme) { + if (!isInstalled(scheme)) return null; + + final String desktopFileProp = System.getProperty("scijava.app.desktop-file"); + if (desktopFileProp == null) return null; + + final Path desktopFile = Paths.get(desktopFileProp); + + try { + final DesktopFile df = DesktopFile.parse(desktopFile); + final String exec = df.get("Exec"); + if (exec == null) return null; + + // Parse executable path from Exec line (format: "/path/to/app %U") + final String[] parts = exec.split("\\s+"); + if (parts.length > 0) { + return parts[0]; + } + } + catch (final IOException e) { + if (log != null) log.debug("Failed to parse desktop file: " + desktopFile, e); + } + + return null; + } + + @Override + public void uninstall(final String scheme) throws IOException { + if (!isSupported()) { + throw new UnsupportedOperationException("Linux .desktop file uninstallation not supported on: " + System.getProperty("os.name")); + } + + if (!isInstalled(scheme)) { + if (log != null) log.debug("Scheme '" + scheme + "' is not installed"); + return; + } + + final String desktopFileProp = System.getProperty("scijava.app.desktop-file"); + final Path desktopFile = Paths.get(desktopFileProp); + + // Parse and remove MIME type + final DesktopFile df = DesktopFile.parse(desktopFile); + df.removeMimeType("x-scheme-handler/" + scheme); + df.writeTo(desktopFile); + + // Update desktop database + final Path applicationsDir = desktopFile.getParent(); + if (applicationsDir != null) { + executeCommand(new String[]{"update-desktop-database", applicationsDir.toString()}); + } + + if (log != null) log.info("Uninstalled URI scheme: " + scheme); + } + + // -- Helper methods -- + + /** + * Executes a command and returns whether it succeeded. + */ + private boolean executeCommand(final String[] command) { + try { + final ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + final Process process = pb.start(); + + // Consume output to prevent blocking + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + final StringBuilder output = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + if (log != null && !output.toString().trim().isEmpty()) { + log.debug("Command output: " + output); + } + } + + // Wait for completion + final boolean finished = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + if (log != null) log.warn("Command timed out: " + String.join(" ", command)); + process.destroyForcibly(); + return false; + } + + final int exitCode = process.exitValue(); + if (exitCode != 0 && log != null) { + log.debug("Command failed with exit code " + exitCode + ": " + String.join(" ", command)); + } + + return exitCode == 0; + } + catch (final IOException e) { + if (log != null) log.error("Failed to execute command: " + String.join(" ", command), e); + return false; + } + catch (final InterruptedException e) { + if (log != null) log.error("Command interrupted: " + String.join(" ", command), e); + Thread.currentThread().interrupt(); + return false; + } + } +} diff --git a/src/test/java/org/scijava/links/installer/LinuxSchemeInstallerTest.java b/src/test/java/org/scijava/links/installer/LinuxSchemeInstallerTest.java new file mode 100644 index 0000000..dc27f08 --- /dev/null +++ b/src/test/java/org/scijava/links/installer/LinuxSchemeInstallerTest.java @@ -0,0 +1,232 @@ +/*- + * #%L + * URI scheme handlers for SciJava. + * %% + * Copyright (C) 2023 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.links.installer; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.*; + +/** + * Tests {@link LinuxSchemeInstaller}. + * + * @author Curtis Rueden + */ +public class LinuxSchemeInstallerTest { + + private static final String TEST_SCHEME = "scijava-test"; + private LinuxSchemeInstaller installer; + private Path tempDesktopFile; + private String originalProperty; + + @Before + public void setUp() throws IOException { + installer = new LinuxSchemeInstaller(null); + // Only run tests on Linux + Assume.assumeTrue("Tests only run on Linux", installer.isSupported()); + + // Create temporary desktop file + tempDesktopFile = Files.createTempFile("test-app", ".desktop"); + + // Write basic desktop file content + final DesktopFile df = new DesktopFile(); + df.set("Type", "Application"); + df.set("Name", "Test App"); + df.set("Exec", "/usr/bin/test-app %U"); + df.writeTo(tempDesktopFile); + + // Set system property + originalProperty = System.getProperty("scijava.app.desktop-file"); + System.setProperty("scijava.app.desktop-file", tempDesktopFile.toString()); + } + + @After + public void tearDown() throws IOException { + // Restore original property + if (originalProperty != null) { + System.setProperty("scijava.app.desktop-file", originalProperty); + } + else { + System.clearProperty("scijava.app.desktop-file"); + } + + // Clean up temp file + if (tempDesktopFile != null && Files.exists(tempDesktopFile)) { + Files.delete(tempDesktopFile); + } + } + + @Test + public void testIsSupported() { + final String os = System.getProperty("os.name"); + final boolean expectedSupport = os != null && os.toLowerCase().contains("linux"); + assertEquals(expectedSupport, installer.isSupported()); + } + + @Test + public void testInstallAndUninstall() throws IOException { + // Arrange + final String execPath = "/usr/bin/test-app"; + + // Ensure scheme is not already installed + if (installer.isInstalled(TEST_SCHEME)) { + installer.uninstall(TEST_SCHEME); + } + assertFalse("Test scheme should not be installed initially", installer.isInstalled(TEST_SCHEME)); + + // Act - Install (note: xdg-mime may fail in test environment, but file should be modified) + try { + installer.install(TEST_SCHEME, execPath); + } + catch (final IOException e) { + // xdg-mime might not be available in test environment + // Check if file was at least modified + } + + // Assert - Check desktop file was modified + final DesktopFile df = DesktopFile.parse(tempDesktopFile); + assertTrue("Desktop file should contain MIME type", df.hasMimeType("x-scheme-handler/" + TEST_SCHEME)); + + // Act - Uninstall + installer.uninstall(TEST_SCHEME); + + // Assert - Uninstalled + final DesktopFile df2 = DesktopFile.parse(tempDesktopFile); + assertFalse("Desktop file should not contain MIME type", df2.hasMimeType("x-scheme-handler/" + TEST_SCHEME)); + } + + @Test + public void testInstallTwice() throws IOException { + // Arrange + final String execPath = "/usr/bin/test-app"; + + // Act - Install twice + try { + installer.install(TEST_SCHEME, execPath); + installer.install(TEST_SCHEME, execPath); // Should not fail + } + catch (final IOException e) { + // xdg-mime might not be available + } + + // Assert - Check only one entry + final DesktopFile df = DesktopFile.parse(tempDesktopFile); + final String mimeType = df.get("MimeType"); + assertNotNull("MimeType should be set", mimeType); + + // Count occurrences of the test scheme + int count = 0; + for (final String part : mimeType.split(";")) { + if (part.trim().equals("x-scheme-handler/" + TEST_SCHEME)) { + count++; + } + } + assertEquals("Scheme should appear exactly once", 1, count); + } + + @Test + public void testIsInstalledReturnsFalseWhenFileDoesNotExist() { + // Arrange + System.setProperty("scijava.app.desktop-file", "/nonexistent/path/app.desktop"); + + // Act & Assert + assertFalse("Should return false when desktop file doesn't exist", + installer.isInstalled(TEST_SCHEME)); + } + + @Test + public void testGetInstalledPath() throws IOException { + // Arrange + final String execPath = "/usr/bin/test-app"; + + try { + installer.install(TEST_SCHEME, execPath); + } + catch (final IOException e) { + // xdg-mime might not be available + } + + // Act + final String installedPath = installer.getInstalledPath(TEST_SCHEME); + + // Assert + assertEquals("Should return exec path from desktop file", execPath, installedPath); + } + + @Test(expected = IllegalArgumentException.class) + public void testInstallWithNullScheme() throws IOException { + installer.install(null, "/usr/bin/test-app"); + } + + @Test(expected = IllegalArgumentException.class) + public void testInstallWithEmptyScheme() throws IOException { + installer.install("", "/usr/bin/test-app"); + } + + @Test(expected = IOException.class) + public void testInstallWithoutDesktopFileProperty() throws IOException { + // Arrange + System.clearProperty("scijava.app.desktop-file"); + + // Act + installer.install(TEST_SCHEME, "/usr/bin/test-app"); + } + + @Test + public void testMultipleSchemes() throws IOException { + // Arrange + final String scheme1 = "scijava-test1"; + final String scheme2 = "scijava-test2"; + + // Act - Install two schemes + try { + installer.install(scheme1, "/usr/bin/test-app"); + installer.install(scheme2, "/usr/bin/test-app"); + } + catch (final IOException e) { + // xdg-mime might not be available + } + + // Assert - Both should be in desktop file + final DesktopFile df = DesktopFile.parse(tempDesktopFile); + assertTrue("Should have first scheme", df.hasMimeType("x-scheme-handler/" + scheme1)); + assertTrue("Should have second scheme", df.hasMimeType("x-scheme-handler/" + scheme2)); + + // Cleanup + installer.uninstall(scheme1); + installer.uninstall(scheme2); + } +}