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/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 b5c8750..2a4b3c9 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. * %% @@ -30,12 +30,19 @@ 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; 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 +52,94 @@ @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. + 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 (Windows and Linux supported). + *

+ */ + 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. + *

+ * 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() { + 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 + } + + /** + * 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..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. * %% @@ -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/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/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/main/java/org/scijava/links/installer/SchemeInstaller.java b/src/main/java/org/scijava/links/installer/SchemeInstaller.java new file mode 100644 index 0000000..31ae779 --- /dev/null +++ b/src/main/java/org/scijava/links/installer/SchemeInstaller.java @@ -0,0 +1,83 @@ +/*- + * #%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.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..3fa64d7 --- /dev/null +++ b/src/main/java/org/scijava/links/installer/WindowsSchemeInstaller.java @@ -0,0 +1,265 @@ +/*- + * #%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.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/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/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); + } +} 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..4546597 --- /dev/null +++ b/src/test/java/org/scijava/links/installer/WindowsSchemeInstallerTest.java @@ -0,0 +1,217 @@ +/*- + * #%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 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)); + } +}