diff --git a/.gitignore b/.gitignore index 2d513a0..2e16032 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +/.classpath /.idea/ +/.project +/.settings/ /target/ diff --git a/LICENSE.txt b/LICENSE.txt index 1feb1f4..a9c6e00 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2023 - 2025, SciJava developers. +Copyright (c) 2010 - 2026, SciJava developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/NEXT.md b/NEXT.md new file mode 100644 index 0000000..ccf62ac --- /dev/null +++ b/NEXT.md @@ -0,0 +1,294 @@ +# Next Steps: SciJava Desktop Integration + +This document outlines the remaining work for the scijava-desktop component. + +## Overview + +scijava-desktop provides unified desktop integration for SciJava applications, managing: +1. **URI link scheme registration & handling** (Linux, Windows, macOS) +2. **Desktop icon generation** (Linux .desktop file, Windows Start Menu - planned) +3. **File extension registration** (planned) + +The component uses a plugin system where Platform implementations (LinuxPlatform, WindowsPlatform, MacOSPlatform) handle platform-specific integration. + +## Current Status + +### ✅ Implemented +- URI link handling system (LinkService, LinkHandler interface) +- Platform-specific URI scheme registration (Windows Registry, Linux .desktop files) +- Desktop icon management for Linux +- DesktopIntegrationProvider interface for platform capabilities +- OptionsDesktop plugin for user preferences (Edit > Options > Desktop...) +- Platform plugins for Linux, Windows, and macOS + +### ⚠️ Partially Implemented (Needs Fixes) +- **Hardcoded scheme names**: WindowsPlatform:86,102 and LinuxPlatform:112,129 hardcode "fiji" scheme +- **Hardcoded OS checks**: DefaultLinkService:119-132 directly checks OS name strings and instantiates platform-specific installers + +### ❌ Not Yet Implemented +- File extension registration +- Windows Start Menu icon generation +- First launch dialog for desktop integration opt-in + +## Implementation Phases + +### Phase 1: Fix Hardcoded Elements (High Priority) +These fixes are required for scijava-desktop to work for applications other than Fiji. + +### Phase 2: File Extension Registration (High Priority) +Core functionality for Fiji and other scientific applications. + +### Phase 3: Polish (Medium Priority) +First launch dialog, Windows desktop icon, etc. + +--- + +## Priority Work Items + +### 1. Remove Hardcoded Scheme Names + +**Problem**: Both Windows and Linux platforms hardcode the "fiji" scheme instead of querying registered LinkHandlers. + +**Files to modify**: +- `src/main/java/org/scijava/desktop/platform/windows/WindowsPlatform.java` (lines 86, 102) +- `src/main/java/org/scijava/desktop/platform/linux/LinuxPlatform.java` (lines 112, 129) + +**Solution**: Query LinkService for all registered schemes from LinkHandler plugins. + +**Example** (WindowsPlatform.java): +```java +@Override +public boolean isWebLinksEnabled() { + final WindowsSchemeInstaller installer = new WindowsSchemeInstaller(log); + final Set schemes = collectSchemes(); + if (schemes.isEmpty()) return false; + + // Check if any scheme is installed + for (String scheme : schemes) { + if (installer.isInstalled(scheme)) return true; + } + return false; +} + +@Override +public void setWebLinksEnabled(final boolean enable) throws IOException { + final WindowsSchemeInstaller installer = new WindowsSchemeInstaller(log); + final String executablePath = System.getProperty("scijava.app.executable"); + if (executablePath == null) { + throw new IOException("No executable path set (scijava.app.executable property)"); + } + + final Set schemes = collectSchemes(); + for (String scheme : schemes) { + if (enable) { + installer.install(scheme, executablePath); + } else { + installer.uninstall(scheme); + } + } +} + +private Set collectSchemes() { + final Set schemes = new HashSet<>(); + if (linkService == null) return schemes; + + for (final LinkHandler handler : linkService.getInstances()) { + schemes.addAll(handler.getSchemes()); + } + return schemes; +} +``` + +Similar changes needed for LinuxPlatform. + +### 2. Add getSchemeInstaller method + +**Goal**: Allow platforms to provide SchemeInstaller instances without hardcoding in DefaultLinkService. + +**New method**: `DesktopIntegrationProvider#getSchemeInstaller()` + +**Files to modify**: +- WindowsPlatform.java - implement getSchemeInstaller +- LinuxPlatform.java - implement getSchemeInstaller +- MacOSPlatform.java - implement getSchemeInstaller (return null) + +### 3. Refactor DefaultLinkService#createInstaller() + +**Problem**: Hardcoded OS checks violate the plugin architecture. + +**Current code** (lines 119-132): +```java +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; +} +``` + +**Refactored code**: +```java +@Parameter(required = false) +private PlatformService platformService; + +private SchemeInstaller createInstaller() { + if (platformService == null) return null; + + final Platform platform = platformService.platform(); + if (platform instanceof DesktopIntegrationProvider) { + return ((DesktopIntegrationProvider) platform).getSchemeInstaller(); + } + + return null; +} +``` + +### 4. First Launch Dialog (Optional) + +**Goal**: Prompt user on first launch to enable desktop integration. + +**Implementation approach**: +- Add `DesktopIntegrationService` to track first launch +- Publish `DesktopIntegrationPromptEvent` on first run +- Application (Fiji) can listen and show dialog + +**Dialog content**: +``` +Would you like to integrate [App Name] into your desktop? + +If you say Yes, I will: +- Add a [App Name] desktop icon +- Add [App Name] as a handler for supported file types +- Allow [App Name] to handle [scheme]:// web links + +Either way: "To change these settings in the future, use Edit > Options > Desktop..." +``` + +**Implementation notes**: +- Use `appService.getApp().getTitle()` for `[App Name]` to keep it generic +- Collect schemes from all `LinkHandler` plugins to populate `[scheme]://` +- Show this dialog only on first launch (track via preferences) +- Provide "Yes" and "No" options +- If user selects "Yes", enable all available desktop integration features +- If user selects "No", do nothing +- Store user preference by writing to a local configuration file -- avoids showing dialog again + +### 5. File Extension Registration (High Priority) + +**Scope**: Extend DesktopIntegrationProvider to support file type associations. + +**New methods**: +```java +boolean isFileExtensionsEnabled(); +boolean isFileExtensionsToggleable(); +void setFileExtensionsEnabled(boolean enable) throws IOException; +``` + +**Platform implementations**: + +#### Linux (Complex - Custom MIME Types Required) + +**Problem**: Microscopy formats (.sdt, .czi, .nd2, .lif, etc.) lack standard MIME types. + +**Solution**: Register custom MIME types in `~/.local/share/mime/packages/fiji.xml` + +**Steps**: +1. Create extension → MIME type mapping + - Standard formats: use existing types (`image/tiff`, `image/png`) + - Microscopy formats: define custom types (`application/x-zeiss-czi`, `application/x-nikon-nd2`) +2. Generate `fiji.xml` with MIME type definitions +3. Install to `~/.local/share/mime/packages/` +4. Run `update-mime-database ~/.local/share/mime` +5. Add MIME types to .desktop file's `MimeType=` field + +**MIME type naming convention**: +- Use vendor-specific: `application/x-{vendor}-{format}` +- Examples: `application/x-zeiss-czi`, `application/x-becker-hickl-sdt` + +**Unregistration**: +- Remove MIME types from .desktop file (preserve URI schemes) +- Optionally delete `~/.local/share/mime/packages/fiji.xml` +- Run `update-mime-database` again + +#### Windows (Simple - SupportedTypes Only) + +**Solution**: Use `Applications\fiji.exe\SupportedTypes` registry approach + +**Steps**: +1. Create `HKCU\Software\Classes\Applications\fiji.exe\SupportedTypes` +2. Add each extension as a value: `.tif = ""` +3. All ~150-200 extensions in one registry location + +**Safety**: Deletion is safe - only removes our own `Applications\fiji.exe` tree + +**Unregistration**: +- Delete entire `HKCU\Software\Classes\Applications\fiji.exe` key + +#### macOS (Build-Time Only) + +**Solution**: Declare all extensions in Info.plist at build time + +**Format**: `CFBundleDocumentTypes` array with extension lists + +**No runtime action needed** - this is a packaging/build concern + +### 6. Windows Start Menu Icon + +**Goal**: Add desktop icon support for Windows. + +**Implementation**: +- Create Start Menu shortcut (.lnk file) in `%APPDATA%\Microsoft\Windows\Start Menu\Programs` +- Use JNA or native executable to create shortcuts +- Update WindowsPlatform.isDesktopIconToggleable() to return true + +## Testing Checklist + +### Phase 1 (Hardcoded Elements) +- [ ] Test URI scheme registration on Windows (registry manipulation) +- [ ] Test URI scheme registration on Linux (.desktop file updates) +- [ ] Test desktop icon installation on Linux +- [ ] Verify macOS reports correct read-only status +- [ ] Test OptionsDesktop UI with multiple schemes +- [ ] Verify scheme collection from LinkHandler plugins +- [ ] Test toggling features on/off via UI +- [ ] Verify no hardcoded "fiji" references remain + +### Phase 2 (File Extensions) +- [ ] Test Linux MIME type generation and installation +- [ ] Verify `update-mime-database` runs successfully +- [ ] Test file extension associations appear in file managers (Nautilus, Dolphin) +- [ ] Test Windows SupportedTypes registration +- [ ] Verify Fiji appears in "Open With" for all extensions +- [ ] Test unregistration (verify complete cleanup) +- [ ] Test with ~150-200 actual file extensions +- [ ] Verify no `application/octet-stream` claims + +## System Properties Reference + +- `scijava.app.executable` - Path to application executable (required for all platforms) +- `scijava.app.name` - Application name (defaults to "SciJava Application") +- `scijava.app.icon` - Icon path (Linux only) +- `scijava.app.directory` - Working directory (Linux only) +- `scijava.app.desktop-file` - Override .desktop file path (Linux only) + +## Documentation Updates Needed + +- [ ] Update README.md with current feature status +- [ ] Update doc/WINDOWS.md to reflect generic scheme handling +- [ ] Create doc/LINUX.md documenting .desktop file integration +- [ ] Create doc/MACOS.md explaining Info.plist configuration +- [ ] Add examples of LinkHandler implementation + +## Questions to Resolve + +1. How to handle partial scheme installation failures? +2. Should first launch dialog be mandatory or optional? +3. What file extensions should be supported by default? diff --git a/README.md b/README.md index e03d6cc..b328ad3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,267 @@ -[![Build Status](https://github.com/scijava/scijava-links/actions/workflows/build.yml/badge.svg)](https://github.com/scijava/scijava-links/actions/workflows/build.yml) +# scijava-desktop -This package provides a subsystem for SciJava applications -that enables handling of URI-based links via plugins. +[![Build Status](https://github.com/scijava/scijava-desktop/actions/workflows/build.yml/badge.svg)](https://github.com/scijava/scijava-desktop/actions/workflows/build.yml) -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. +Unified desktop integration for SciJava applications. + +## Features + +The scijava-desktop component provides three kinds of desktop integration: + +1. **URI Link Scheme Registration & Handling** + - Register custom URI schemes (e.g., `myapp://`) with the operating system + - Handle URI clicks from web browsers and other applications + - Automatic scheme registration on application startup + +2. **Desktop Icon Generation** + - Linux: `.desktop` file creation in application menus + - Windows: Start Menu shortcuts (planned) + - macOS: Application bundle support + +3. **File Extension Registration** (planned) + - Associate file types with your application + - Platform-specific MIME type handling + +## Platform Support + +- **Linux**: Full support for URI schemes and desktop icons via `.desktop` files +- **Windows**: URI scheme registration via Windows Registry (desktop icons planned) +- **macOS**: Read-only support (configuration via Info.plist at build time) + +## Requirements + +- Java 11 or later (due to use of `java.awt.Desktop` features) +- Platform-specific tools: + - Linux: `xdg-utils` (for `xdg-mime` and `update-desktop-database`) + - Windows: `reg` command (built-in) + - macOS: No runtime dependencies + +## Quick Start + +### 1. Add Dependency + +```xml + + org.scijava + scijava-desktop + + +``` + +### 2. Configure System Properties + +Set these properties when launching your application: + +```bash +java -Dscijava.app.executable="/path/to/myapp" \ + -Dscijava.app.name="My Application" \ + -Dscijava.app.icon="/path/to/icon.png" \ + -jar myapp.jar +``` + +### 3. Create a LinkHandler Plugin + +```java +package com.example; + +import org.scijava.desktop.links.AbstractLinkHandler; +import org.scijava.desktop.links.LinkHandler; +import org.scijava.plugin.Plugin; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +@Plugin(type = LinkHandler.class) +public class MyAppLinkHandler extends AbstractLinkHandler { + + @Override + public boolean supports(final URI uri) { + return "myapp".equals(uri.getScheme()); + } + + @Override + public void handle(final URI uri) { + // Handle the URI (e.g., open a file, navigate to a view) + System.out.println("Handling: " + uri); + } + + @Override + public List getSchemes() { + // Schemes to register with the OS + return Arrays.asList("myapp"); + } +} +``` + +### 4. Launch and Test + +1. Start your application +2. The `myapp://` scheme is automatically registered with your OS +3. Click a `myapp://action?param=value` link in your browser +4. Your application launches and handles the URI + +## Architecture + +### Link Handling System + +- **LinkService**: Service for routing URIs to appropriate handlers +- **LinkHandler**: Plugin interface for implementing URI handlers +- **LinkArgument**: Console argument plugin for command-line URI handling +- **SchemeInstaller**: Platform-specific OS registration + +### Platform Integration + +- **Platform Plugins**: LinuxPlatform, WindowsPlatform, MacOSPlatform +- **DesktopIntegrationProvider**: Interface for querying/toggling desktop features +- **OptionsDesktop**: User-facing options plugin (Edit > Options > Desktop...) + +### State Management + +Desktop integration state is queried directly from the OS (not saved to preferences): +- On load: Query OS for current registration state +- On save: Apply changes directly to OS (e.g. registry, .desktop files) +- Keeps UI in sync with actual OS state + +## System Properties + +| Property | Description | Platforms | Required | +|----------------------------|--------------------------------|-----------|-----------------------------------------------------------| +| `scijava.app.executable` | Path to application executable | All | Yes (for URI schemes) | +| `scijava.app.name` | Application name | All | No (default: "SciJava") | +| `scijava.app.icon` | Icon path | All | No | +| `scijava.app.directory` | Application directory | All | No | +| `scijava.app.desktop-file` | Override .desktop file path | Linux | No (default: `~/.local/share/applications/.desktop`) | + +## User Interface + +Users can manage desktop integration via **Edit > Options > Desktop...** in your application: + +- **Enable web links**: Register/unregister URI schemes +- **Add desktop icon**: Install/remove application launcher + +The UI automatically grays out features that are not available on the current platform. + +## Documentation + +- [doc/WINDOWS.md](doc/WINDOWS.md) - Windows Registry-based URI scheme registration + +## Examples + +### Parse URI Components + +```java +import org.scijava.desktop.links.Links; + +URI uri = new URI("myapp://view/document?id=123&mode=edit"); + +String operation = Links.operation(uri); // "view" +List pathFragments = Links.pathFragments(uri); // ["view", "document"] +Map query = Links.query(uri); // {"id": "123", "mode": "edit"} +``` + +### Multiple Schemes + +```java +@Plugin(type = LinkHandler.class) +public class MultiSchemeLinkHandler extends AbstractLinkHandler { + + @Override + public boolean supports(final URI uri) { + String scheme = uri.getScheme(); + return "myapp".equals(scheme) || "myapp-dev".equals(scheme); + } + + @Override + public void handle(final URI uri) { + if ("myapp-dev".equals(uri.getScheme())) { + // Handle development scheme + } else { + // Handle production scheme + } + } + + @Override + public List getSchemes() { + return Arrays.asList("myapp", "myapp-dev"); + } +} +``` + +## Platform-Specific Details + +### Linux + +URI schemes are registered by: +1. Creating a `.desktop` file in `~/.local/share/applications/` +2. Adding `x-scheme-handler/` to the MimeType field +3. Registering with `xdg-mime default .desktop x-scheme-handler/` +4. Updating the desktop database with `update-desktop-database` + +Desktop icons are created by installing the `.desktop` file with appropriate fields (Name, Exec, Icon, Categories). + +### macOS + +URI schemes are declared in the application's `Info.plist` file within the `.app` bundle. This is configured at build time (bundle is code-signed and immutable), so runtime registration is not supported. + +The MacOSPlatform correctly reports read-only status for all desktop integration features. + +### Windows + +URI schemes are registered in the Windows Registry under `HKEY_CURRENT_USER\Software\Classes\`. No administrator privileges are required. + +The registry structure: +``` +HKCU\Software\Classes\myapp + (Default) = "URL:myapp" + URL Protocol = "" + shell\open\command\ + (Default) = "C:\Path\To\App.exe" "%1" +``` + +## Known Issues + +### Hardcoded Elements (Needs Fixes) + +1. **Hardcoded "fiji" scheme**: WindowsPlatform:86,102 and LinuxPlatform:112,129 hardcode the "fiji" scheme instead of querying LinkHandler plugins. + - Impact: Only works for Fiji application + - Fix: See NEXT.md Work Item #1 + +2. **Hardcoded OS checks**: DefaultLinkService:119-132 directly checks OS name instead of using PlatformService. + - Impact: Violates plugin architecture + - Fix: See NEXT.md Work Items #2 and #3 + +### Missing Features + +- File extension registration +- Windows desktop icon (Start Menu shortcut) +- First launch dialog for opt-in + +See [NEXT.md](NEXT.md) for details on planned improvements. + +## Manual Testing + +**Windows**: +```bash +# Check registry after running your app +regedit +# Navigate to HKCU\Software\Classes\myapp +``` + +**Linux**: +```bash +# Check .desktop file +cat ~/.local/share/applications/myapp.desktop + +# Check MIME associations +xdg-mime query default x-scheme-handler/myapp +``` + +**Test URI handling**: +```bash +# Linux/macOS +xdg-open "myapp://test" + +# Windows +start "myapp://test" +``` diff --git a/doc/WINDOWS.md b/doc/WINDOWS.md new file mode 100644 index 0000000..1ae38dd --- /dev/null +++ b/doc/WINDOWS.md @@ -0,0 +1,285 @@ +# Windows Desktop Integration + +This document describes how desktop integration works on Windows in scijava-desktop. + +## URI Scheme Registration + +### Overview + +The scijava-desktop component automatically registers URI schemes with Windows when a SciJava application starts. The registration is done via the Windows Registry and requires no administrator privileges. + +### How It Works + +When the application starts: + +1. `DefaultLinkService` initializes and listens for `ContextCreatedEvent` +2. Collects all URI schemes from registered `LinkHandler` plugins via `getSchemes()` +3. Reads the executable path from the `scijava.app.executable` system property +4. Creates a `WindowsSchemeInstaller` (currently hardcoded, should use platform plugin) +5. Registers each scheme in the Windows Registry + +### Registry Structure + +For a scheme named `myapp`, the following registry structure is created under `HKEY_CURRENT_USER`: + +``` +HKEY_CURRENT_USER\Software\Classes\myapp + (Default) = "URL:myapp" + URL Protocol = "" + shell\ + open\ + command\ + (Default) = "C:\Path\To\App.exe" "%1" +``` + +### Registry Location + +**Key**: `HKEY_CURRENT_USER\Software\Classes\` + +**Reason**: Using `HKEY_CURRENT_USER` (HKCU) instead of `HKEY_LOCAL_MACHINE` (HKLM) means: +- No administrator rights required +- Registration is per-user (not system-wide) +- Safe for non-privileged users + +### Implementation Details + +#### WindowsSchemeInstaller + +The `WindowsSchemeInstaller` class provides the platform-specific implementation: + +**Methods**: +- `isSupported()` - Returns true on Windows platforms +- `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 + +**Implementation Approach**: +- Uses the `reg` command-line tool (built into Windows) +- No JNA dependency required +- Executed via `ProcessBuilder` with proper timeout (10 seconds) +- Robust error handling and validation + +#### Example Commands + +**Add a scheme**: +```cmd +reg add "HKCU\Software\Classes\myapp" /ve /d "URL:myapp" /f +reg add "HKCU\Software\Classes\myapp" /v "URL Protocol" /d "" /f +reg add "HKCU\Software\Classes\myapp\shell\open\command" /ve /d "\"C:\Path\To\App.exe\" \"%1\"" /f +``` + +**Check if a scheme exists**: +```cmd +reg query "HKCU\Software\Classes\myapp\shell\open\command" /ve +``` + +**Delete a scheme**: +```cmd +reg delete "HKCU\Software\Classes\myapp" /f +``` + +### Configuration + +#### System Properties + +Applications must set the `scijava.app.executable` system property for URI scheme registration to work: + +```bash +java -Dscijava.app.executable="C:\Program Files\MyApp\MyApp.exe" -jar myapp.jar +``` + +**Important**: The path should point to the actual executable (`.exe` file) that Windows should launch when a URI is clicked. + +#### Launcher Configuration + +For Windows applications with a native launcher: + +```batch +@echo off +set JAVA_HOME=C:\Program Files\Java\jdk-11 +set APP_HOME=%~dp0 + +"%JAVA_HOME%\bin\java.exe" ^ + -Dscijava.app.executable="%APP_HOME%\MyApp.exe" ^ + -jar "%APP_HOME%\lib\myapp.jar" +``` + +### Creating a LinkHandler + +To register custom URI schemes, create a `LinkHandler` plugin: + +```java +package com.example; + +import org.scijava.desktop.links.AbstractLinkHandler; +import org.scijava.desktop.links.LinkHandler; +import org.scijava.desktop.links.Links; +import org.scijava.plugin.Plugin; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +@Plugin(type = LinkHandler.class) +public class MyAppLinkHandler extends AbstractLinkHandler { + + @Override + public boolean supports(final URI uri) { + return "myapp".equals(uri.getScheme()); + } + + @Override + public void handle(final URI uri) { + // Parse the URI + String operation = Links.operation(uri); + Map params = Links.query(uri); + + // Your business logic here + System.out.println("Operation: " + operation); + System.out.println("Parameters: " + params); + } + + @Override + public List getSchemes() { + // This tells DefaultLinkService to register "myapp://" with Windows + return Arrays.asList("myapp"); + } +} +``` + +### User Experience + +1. User installs and launches the application +2. Application automatically registers URI schemes with Windows +3. User clicks a `myapp://action?param=value` link in their browser +4. Windows launches the application with the URI as a command-line argument +5. `LinkArgument` plugin parses the URI and passes it to `LinkService` +6. `LinkService` routes the URI to the appropriate `LinkHandler` +7. Handler processes the request + +### Desktop Integration Options + +Users can manage URI scheme registration via **Edit > Options > Desktop...**: + +- **Enable web links**: Toggleable checkbox to register/unregister URI schemes +- State is queried from the actual Windows Registry (not saved to preferences) +- Changes apply immediately to the registry + +### Desktop Icon (Start Menu) + +**Status**: Not yet implemented + +**Planned Implementation**: +- Create Start Menu shortcut (.lnk file) +- Location: `%APPDATA%\Microsoft\Windows\Start Menu\Programs\.lnk` +- Use JNA or native executable for .lnk creation +- Toggle via OptionsDesktop UI + +## Testing + +### Manual Testing + +**Check Registry**: +1. Launch your application +2. Open `regedit` +3. Navigate to `HKEY_CURRENT_USER\Software\Classes\myapp` +4. Verify the structure matches the expected format + +**Test URI Handling**: +1. Create an HTML file with a link: `Test Link` +2. Open the HTML file in a browser +3. Click the link +4. Verify your application launches and handles the URI + +**Test from Command Line**: +```cmd +start myapp://test?param=value +``` + +### Automated Tests + +Run Windows-specific tests: +```bash +mvn test -Dtest=WindowsSchemeInstallerTest +``` + +Tests automatically skip on non-Windows platforms using JUnit's `Assume.assumeTrue()`. + +## Known Issues + +### Hardcoded Scheme Name + +**Issue**: `WindowsPlatform` (lines 86, 102) currently hardcodes the "fiji" scheme instead of querying registered `LinkHandler` plugins. + +**Impact**: Only works for Fiji; breaks for other applications. + +**Workaround**: None; requires code fix. + +**Fix**: See NEXT.md Work Item #1 for the solution. + +### Hardcoded OS Checks + +**Issue**: `DefaultLinkService.createInstaller()` (lines 119-132) hardcodes OS name checks instead of using `PlatformService`. + +**Impact**: Violates plugin architecture. + +**Workaround**: None; requires code fix. + +**Fix**: See NEXT.md Work Items #2 and #3 for the solution. + +## Platform Comparison + +### Windows vs. Linux vs. macOS + +| Feature | Windows | Linux | macOS | +|---------|---------|-------|-------| +| **URI Scheme Registration** | Runtime (Registry) | Runtime (.desktop file) | Build-time (Info.plist) | +| **Admin Rights Required** | No (HKCU) | No (user .desktop file) | N/A (bundle) | +| **Toggleable at Runtime** | Yes | Yes | No (read-only) | +| **Desktop Icon** | Planned (Start Menu) | Yes (.desktop file) | No (user pins to Dock) | +| **File Extensions** | Planned (Registry) | Planned (.desktop MIME types) | Build-time (Info.plist) | + +### Why Runtime Registration on Windows? + +Unlike macOS (where the `.app` bundle is code-signed and immutable), Windows allows runtime modification of the registry without special privileges. This makes it practical to register URI schemes when the application first runs, similar to how many Windows applications work. + +## Security Considerations + +### Registry Safety + +- Only writes to `HKEY_CURRENT_USER` (per-user settings) +- Does not modify system-wide settings in `HKEY_LOCAL_MACHINE` +- Only registers URI schemes declared by `LinkHandler` plugins +- Does not expose arbitrary command execution + +### Command-Line Injection + +The `%1` parameter in the registry command is properly quoted: +``` +"C:\Path\To\App.exe" "%1" +``` + +This prevents command-line injection attacks via malicious URIs. + +## Future Enhancements + +1. **Start Menu Shortcut**: Implement desktop icon creation +2. **File Extension Registration**: Register file types in the registry +3. **Scheme Validation**: Validate scheme names against RFC 3986 +4. **User Prompts**: Optional confirmation before registering schemes +5. **Uninstallation**: Automatic cleanup on application uninstall +6. **Icon Support**: Associate icons with schemes and file types + +## Resources + +- [Microsoft URI Scheme Documentation](https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85)) +- [Windows Registry Reference](https://docs.microsoft.com/en-us/windows/win32/sysinfo/registry) +- [RFC 3986 - URI Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986) + +## See Also + +- [NEXT.md](../NEXT.md) - Planned improvements +- [spec/DESKTOP_INTEGRATION_PLAN.md](../spec/DESKTOP_INTEGRATION_PLAN.md) - Architecture overview +- [spec/IMPLEMENTATION_SUMMARY.md](../spec/IMPLEMENTATION_SUMMARY.md) - Implementation details diff --git a/pom.xml b/pom.xml index f1bedb6..9e45ff4 100644 --- a/pom.xml +++ b/pom.xml @@ -5,23 +5,22 @@ org.scijava pom-scijava - 40.0.0 + 43.0.0 org.scijava - scijava-links - 1.0.1-SNAPSHOT + scijava-desktop + 1.0.0-SNAPSHOT - SciJava Links - URL scheme handlers for SciJava. - https://github.com/scijava/scijava-links - 2023 + SciJava Desktop + Desktop integration for SciJava. + https://github.com/scijava/scijava-desktop + 2010 SciJava https://scijava.org/ - Simplified BSD License @@ -37,6 +36,7 @@ founder lead + developer debugger reviewer support @@ -45,6 +45,16 @@ + + Mark Hiner + https://imagej.net/people/hinerm + hinerm + + + Johannes Schindelin + https://imagej.net/people/dscho + dscho + Marwan Zouinkhi https://imagej.net/people/mzouink @@ -58,37 +68,54 @@ Image.sc Forum https://forum.image.sc/tag/scijava + + SciJava + https://groups.google.com/group/scijava + https://groups.google.com/group/scijava + scijava@googlegroups.com + https://groups.google.com/group/scijava + - scm:git:https://github.com/scijava/scijava-links - scm:git:git@github.com:scijava/scijava-links + scm:git:https://github.com/scijava/scijava-desktop + scm:git:git@github.com:scijava/scijava-desktop HEAD - https://github.com/scijava/scijava-links + https://github.com/scijava/scijava-desktop GitHub Issues - https://github.com/scijava/scijava-links/issues + https://github.com/scijava/scijava-desktop/issues GitHub Actions - https://github.com/scijava/scijava-links/actions + https://github.com/scijava/scijava-desktop/actions - org.scijava.links + org.scijava.desktop 11 bsd_2 SciJava developers. + org.scijava scijava-common + + + + com.yuvimasory + orange-extensions + 1.3.0 + provided + + junit junit diff --git a/spec/DESKTOP_INTEGRATION_PLAN.md b/spec/DESKTOP_INTEGRATION_PLAN.md new file mode 100644 index 0000000..8095d70 --- /dev/null +++ b/spec/DESKTOP_INTEGRATION_PLAN.md @@ -0,0 +1,321 @@ +# Desktop Integration Architecture + +## Overview + +The scijava-desktop component provides unified desktop integration for SciJava applications, managing three kinds of OS integration: + +1. **URI link scheme registration & handling** - Register custom URI schemes (e.g., `fiji://`) with the operating system +2. **Desktop icon generation** - Create application launchers in system menus +3. **File extension registration** - Associate file types with the application (planned) + +This component comes with support for Linux, macOS, and Windows out of the box, and uses a plugin system for platform-specific implementations. + +## Architecture + +### Core Components + +#### 1. LinkService & LinkHandler System + +**LinkService** (`org.scijava.desktop.links.LinkService`) +- Service for routing URIs to appropriate handlers +- Automatically registers URI schemes with the OS on application startup +- Delegates URI handling to LinkHandler plugins + +**LinkHandler** (`org.scijava.desktop.links.LinkHandler`) +- Plugin interface for handling specific URI schemes +- Implementations declare which schemes they support via `getSchemes()` +- Handle method processes the URI when clicked/opened + +**LinkArgument** (`org.scijava.desktop.links.LinkArgument`) +- Console argument plugin that recognizes URIs on the command line +- Passes URIs to LinkService for handling + +**SchemeInstaller** (`org.scijava.desktop.links.SchemeInstaller`) +- Platform-independent interface for OS registration +- Methods: `install()`, `uninstall()`, `isInstalled()`, `isSupported()` +- Implementations: WindowsSchemeInstaller, LinuxSchemeInstaller + +#### 2. Platform Integration System + +**DesktopIntegrationProvider** (`org.scijava.desktop.DesktopIntegrationProvider`) +- Interface implemented by Platform plugins +- Provides methods to query and toggle desktop integration features +- Methods: + - `boolean isWebLinksEnabled()` / `setWebLinksEnabled(boolean)` + - `boolean isWebLinksToggleable()` + - `boolean isDesktopIconPresent()` / `setDesktopIconPresent(boolean)` + - `boolean isDesktopIconToggleable()` + +**Platform Implementations**: +- `WindowsPlatform` - Registry-based URI scheme registration +- `LinuxPlatform` - .desktop file management +- `MacOSPlatform` - Read-only status (uses Info.plist at build time) + +**OptionsDesktop** (`org.scijava.desktop.options.OptionsDesktop`) +- User-facing options plugin (Edit > Options > Desktop...) +- Queries platform capabilities and displays toggleable features +- Applies changes directly to OS (no preference persistence) + +#### 3. Linux Desktop File Management + +**DesktopFile** (`org.scijava.desktop.platform.linux.DesktopFile`) +- Instance-based API for reading/writing Linux .desktop files +- Handles standard fields: Type, Name, Exec, Icon, Path, Categories, etc. +- MIME type management: `hasMimeType()`, `addMimeType()`, `removeMimeType()` +- Methods: `load()`, `save()`, `delete()`, `exists()` + +**LinuxSchemeInstaller** (`org.scijava.desktop.platform.linux.LinuxSchemeInstaller`) +- Uses DesktopFile to add/remove URI scheme handlers +- Registers schemes via `xdg-mime default` +- Updates desktop database with `update-desktop-database` + +## Platform-Specific Behavior + +### Linux + +**URI Schemes**: +- Adds `x-scheme-handler/` to .desktop file MimeType field +- Registers with `xdg-mime default .desktop x-scheme-handler/` +- Updates desktop database for system-wide recognition + +**Desktop Icon**: +- Creates .desktop file in `~/.local/share/applications/` +- Defaults to `~/.local/share/applications/.desktop` +- Override via `scijava.app.desktop-file` system property +- File includes: Name, Exec, Icon, Categories, MimeType + +**File Extensions** (planned): +- Add MIME types to .desktop file (e.g., `image/tiff`, `application/x-imagej-macro`) +- Register with `xdg-mime default` + +**Toggleability**: Both web links and desktop icon are toggleable + +### Windows + +**URI Schemes**: +- Registers schemes in `HKEY_CURRENT_USER\Software\Classes\` +- Uses `reg` command-line tool (no admin rights required) +- Creates registry structure: scheme → URL Protocol → shell → open → command + +**Desktop Icon**: +- Not yet implemented +- Plan: Create Start Menu shortcut (.lnk file) +- Location: `%APPDATA%\Microsoft\Windows\Start Menu\Programs` + +**File Extensions** (planned): +- Register in `HKCU\Software\Classes\.` + +**Toggleability**: Web links are toggleable; desktop icon not yet supported + +### macOS + +**URI Schemes**: +- Declared in Info.plist within the .app bundle +- Configured at build time (bundle is code-signed and immutable) +- No runtime registration needed + +**Desktop Icon**: +- Application bundle in /Applications (installed by user) +- User can pin to Dock manually + +**File Extensions**: +- Declared in Info.plist at build time + +**Toggleability**: All features are read-only (not toggleable at runtime) + +## State Management + +**No Preference Persistence**: Desktop integration state is NOT saved to preferences. Instead: +- On load: Query platform to get actual OS state +- On save: Write directly to OS (registry, files) +- Keeps settings UI in sync with reality +- Prevents sync issues if user manually modifies (e.g., deletes .desktop file) + +**Query Flow**: +``` +OptionsDesktop.load() + → PlatformService.platform() + → Platform instanceof DesktopIntegrationProvider + → isWebLinksEnabled() / isDesktopIconPresent() + → Query OS (registry, .desktop file, etc.) +``` + +**Apply Flow**: +``` +OptionsDesktop.save() + → setWebLinksEnabled(true) / setDesktopIconPresent(true) + → Platform modifies OS directly + → Windows: Update registry + → Linux: Create/modify .desktop file + → macOS: No-op (read-only) +``` + +## System Properties + +Applications configure desktop integration via system properties: + +| Property | Description | Platforms | Required | +|----------|-------------|-----------|----------| +| `scijava.app.executable` | Path to application executable | All | Yes (for URI schemes) | +| `scijava.app.name` | Application name | Linux | No (defaults to "SciJava Application") | +| `scijava.app.icon` | Icon path | Linux | No | +| `scijava.app.directory` | Working directory | Linux | No | +| `scijava.app.desktop-file` | Override .desktop file path | Linux | No (defaults to `~/.local/share/applications/.desktop`) | + +## Current Limitations & Known Issues + +### Hardcoded Elements + +1. **Scheme Names**: WindowsPlatform:86,102 and LinuxPlatform:112,129 hardcode "fiji" scheme + - Should query LinkService for registered schemes instead + - See NEXT.md Work Item #1 + +2. **OS Checks**: DefaultLinkService:119-132 hardcodes OS name checks + - Should use PlatformService to get active platform + - Should add getSchemeInstaller method + - See NEXT.md Work Items #2 and #3 + +### Missing Features + +1. **File Extension Registration**: Mentioned but not implemented +2. **Windows Desktop Icon**: Not yet implemented (Start Menu shortcut) +3. **First Launch Dialog**: No opt-in prompt on first run +4. **Multiple Schemes**: Platforms assume single scheme per application + +## Usage Example + +### Creating a LinkHandler + +```java +package com.example; + +import org.scijava.desktop.links.AbstractLinkHandler; +import org.scijava.desktop.links.LinkHandler; +import org.scijava.plugin.Plugin; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +@Plugin(type = LinkHandler.class) +public class MyAppLinkHandler extends AbstractLinkHandler { + + @Override + public boolean supports(final URI uri) { + return "myapp".equals(uri.getScheme()); + } + + @Override + public void handle(final URI uri) { + // Handle the URI (e.g., open a file, navigate to a view) + String path = uri.getPath(); + // ... your logic here ... + } + + @Override + public List getSchemes() { + // Schemes to register with the OS + return Arrays.asList("myapp"); + } +} +``` + +### Configuring the Application + +Set system properties in your launcher script: + +```bash +java -Dscijava.app.executable="/path/to/myapp" \ + -Dscijava.app.name="My Application" \ + -Dscijava.app.icon="/path/to/icon.png" \ + -jar myapp.jar +``` + +### User Experience + +1. **Initial Setup**: Application can show first-launch dialog (planned) +2. **Options Dialog**: Users access Edit > Options > Desktop... +3. **Toggle Features**: Check/uncheck "Enable web links" and "Add desktop icon" +4. **OS Integration**: Changes apply immediately to registry/.desktop files + +## Implementation Status + +### ✅ Completed + +- LinkService & LinkHandler system +- SchemeInstaller interface & implementations +- Platform plugins (Linux, Windows, macOS) +- DesktopIntegrationProvider interface +- OptionsDesktop plugin +- DesktopFile utility (Linux) +- URI scheme registration (Windows Registry) +- URI scheme registration (Linux .desktop files) +- Desktop icon installation (Linux) + +### ⚠️ Needs Fixes + +- Remove hardcoded "fiji" scheme references +- Remove hardcoded OS checks in DefaultLinkService +- Add getSchemeInstaller method + +### ❌ Not Implemented + +- File extension registration +- Windows desktop icon (Start Menu) +- First launch dialog +- Multiple scheme support in platforms +- Scheme validation (RFC 3986) + +## Testing Strategy + +### Unit Tests + +- SchemeInstaller implementations (mock registry/file I/O) +- Platform DesktopIntegrationProvider implementations +- DesktopFile parsing/writing + +### Integration Tests + +- OptionsDesktop lifecycle (load → modify → save) +- Platform-specific workflows (enable/disable on each OS) +- LinkHandler discovery and routing + +### Manual Testing + +- Windows: Check registry entries with `regedit` +- Linux: Verify .desktop file contents and xdg-mime associations +- macOS: Verify read-only status reported correctly +- All platforms: Click URI links in browsers and verify app launches + +## Design Decisions + +### Why No Preference Persistence? + +OS state is the source of truth. Users can manually modify registrations (delete .desktop files, edit registry), so we query the OS directly rather than trusting stale preferences. + +### Why Platform Plugins? + +The plugin system allows: +- No hardcoded OS checks +- Easy addition of new platforms +- Platform-specific behavior without coupling +- Runtime platform detection by SciJava's Platform system + +### Why Separate SchemeInstaller? + +LinkService needs to install schemes at startup, before UI is available. SchemeInstaller provides a non-UI API for OS registration, separate from the UI-facing DesktopIntegrationProvider. + +### Why Linux Creates .desktop on Platform.configure()? + +LinuxPlatform.configure() runs when the platform is activated, ensuring the .desktop file exists early for proper desktop integration. The file is then updated by DesktopIntegrationProvider methods when users toggle features. + +## Future Enhancements + +1. **Multi-scheme Support**: Allow platforms to handle multiple schemes independently +2. **Scheme Validation**: Validate scheme names against RFC 3986 +3. **Repair Functionality**: "Recheck Registration" button to verify/repair installations +4. **Better Error Reporting**: User-friendly messages for permission errors +5. **Event System**: Publish events when registration state changes +6. **File Extension Support**: Full implementation of file type associations +7. **Windows Desktop Icon**: Start Menu shortcut creation +8. **macOS App Store**: Handle sandboxed app limitations diff --git a/spec/IMPLEMENTATION_SUMMARY.md b/spec/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..f070154 --- /dev/null +++ b/spec/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,373 @@ +# scijava-desktop Implementation Summary + +## Component Purpose + +The **scijava-desktop** component is a unified desktop integration library for SciJava applications, providing: + +1. **URI Link Scheme Registration & Handling** - Register and handle custom URI schemes (e.g., `myapp://action`) +2. **Desktop Icon Generation** - Create application launchers in system menus (Linux .desktop files, Windows Start Menu planned) +3. **File Extension Registration** - Associate file types with the application (planned) + +This component merges functionality from the former `scijava-links` and `scijava-plugins-platforms` repositories into a single, cohesive system. + +## Architecture Overview + +### Plugin-Based Platform System + +The component uses SciJava's plugin architecture to avoid hardcoded OS checks: + +- **Platform Plugins**: LinuxPlatform, WindowsPlatform, MacOSPlatform +- **Platform Detection**: Handled automatically by `PlatformService` from scijava-common +- **Platform Capabilities**: Each platform implements `DesktopIntegrationProvider` to expose capabilities + +### Link Handling System + +- **LinkService**: Routes URIs to registered handlers +- **LinkHandler**: Plugin interface for URI scheme implementations +- **SchemeInstaller**: Platform-specific OS registration (Windows Registry, Linux .desktop files) +- **LinkArgument**: Console argument plugin for CLI URI handling + +### Desktop Integration UI + +- **OptionsDesktop**: User-facing options plugin (Edit > Options > Desktop...) +- **DesktopIntegrationProvider**: Interface for querying/toggling integration features +- **No Preference Persistence**: State is always queried from OS, not saved to preferences + +## Implementation Details + +### Files Created + +| File | Purpose | +|------|---------| +| `DesktopIntegrationProvider.java` | Interface for platform integration capabilities | +| `OptionsDesktop.java` | User options plugin for managing desktop integration | +| `DesktopFile.java` | Linux .desktop file parser/writer | +| `LinkService.java` | Service interface for URI handling | +| `DefaultLinkService.java` | LinkService implementation with OS scheme registration | +| `LinkHandler.java` | Plugin interface for URI handlers | +| `AbstractLinkHandler.java` | Base class for LinkHandler implementations | +| `LinkArgument.java` | Console argument plugin for CLI URIs | +| `Links.java` | Utility class for URI parsing | +| `SchemeInstaller.java` | Interface for OS scheme registration | +| `WindowsSchemeInstaller.java` | Windows Registry-based registration | +| `LinuxSchemeInstaller.java` | Linux .desktop file-based registration | + +### Files Modified + +| File | Changes | +|------|---------| +| `WindowsPlatform.java` | Implements DesktopIntegrationProvider; URI scheme registration | +| `LinuxPlatform.java` | Implements DesktopIntegrationProvider; .desktop file management | +| `MacOSPlatform.java` | Implements DesktopIntegrationProvider (read-only status) | +| `pom.xml` | Updated dependencies and build configuration | + +## Feature Status + +### ✅ Fully Implemented + +#### URI Link Handling +- LinkService automatically registers schemes on application startup +- LinkHandler plugins declare schemes via `getSchemes()` +- LinkArgument processes URIs from command line +- Platform-specific registration (Windows Registry, Linux .desktop files) +- Runtime URI routing to appropriate handlers + +#### Desktop Icon (Linux) +- Automatic .desktop file creation on LinuxPlatform.configure() +- Toggleable via OptionsDesktop UI +- Configurable via system properties: + - `scijava.app.name` - Application name + - `scijava.app.executable` - Executable path + - `scijava.app.icon` - Icon path + - `scijava.app.directory` - Working directory + - `scijava.app.desktop-file` - Override .desktop file path + +#### Platform Capabilities +- Windows: URI schemes toggleable (registry) +- Linux: URI schemes and desktop icon toggleable (.desktop file) +- macOS: Read-only status (Info.plist at build time) + +#### Options UI +- Edit > Options > Desktop... menu entry +- Queries OS state on load +- Applies changes directly to OS on save +- Grays out non-toggleable features + +### ⚠️ Partially Implemented (Needs Fixes) + +#### Hardcoded Scheme Names +**Issue**: WindowsPlatform (lines 86, 102) and LinuxPlatform (lines 112, 129) hardcode "fiji" scheme instead of querying LinkService. + +**Impact**: Only works for applications named "fiji"; breaks for other SciJava applications. + +**Fix Required**: Query LinkService.getInstances() to collect schemes from all registered LinkHandler plugins. + +#### Hardcoded OS Checks +**Issue**: DefaultLinkService.createInstaller() (lines 119-132) directly checks `os.name` system property and instantiates platform-specific installers. + +**Impact**: Violates plugin architecture; makes adding new platforms difficult. + +**Fix Required**: +1. Add getSchemeInstaller method +2. Platforms implement getSchemeInstaller +3. DefaultLinkService queries PlatformService.platform() instead of checking OS name + +### ❌ Not Yet Implemented + +#### File Extension Registration +**Status**: Architecture planned, not implemented + +**Planned Approach**: +- Extend DesktopIntegrationProvider with file extension methods +- Linux: Add MIME types to .desktop file +- Windows: Register in `HKCU\Software\Classes\.` +- macOS: Declared in Info.plist (build-time, read-only) + +#### Windows Desktop Icon +**Status**: Not implemented + +**Planned Approach**: +- Create Start Menu shortcut (.lnk file) +- Location: `%APPDATA%\Microsoft\Windows\Start Menu\Programs` +- Use JNA or native executable for .lnk creation +- Update WindowsPlatform.isDesktopIconToggleable() to return true + +#### First Launch Dialog +**Status**: Not implemented + +**Planned Approach**: +- Add DesktopIntegrationService to track first launch +- Publish DesktopIntegrationPromptEvent on first run +- Application (Fiji) listens and shows opt-in dialog +- Dialog text from TODO.md (generic, using appService.getApp().getTitle()) + +#### macOS URI Scheme Registration +**Status**: Documentation only; no runtime registration + +**Current State**: macOS platforms declare URI schemes in Info.plist at build time. The MacOSPlatform correctly reports read-only status, but there's no code to verify or assist with Info.plist configuration. + +## Platform-Specific Implementation Details + +### Linux Platform + +**URI Scheme Registration**: +``` +1. LinuxSchemeInstaller reads/writes .desktop file +2. Adds x-scheme-handler/ to MimeType field +3. Registers with: xdg-mime default .desktop x-scheme-handler/ +4. Updates desktop database: update-desktop-database ~/.local/share/applications +``` + +**Desktop Icon**: +``` +1. LinuxPlatform.configure() creates .desktop file if missing +2. Default location: ~/.local/share/applications/.desktop +3. File includes: Type, Name, Exec, Icon, Path, Terminal, Categories, MimeType +4. User can toggle via OptionsDesktop (creates/deletes file) +``` + +**Files**: +- `LinuxPlatform.java` (Platform plugin) +- `LinuxSchemeInstaller.java` (SchemeInstaller implementation) +- `DesktopFile.java` (Utility for .desktop file I/O) + +### Windows Platform + +**URI Scheme Registration**: +``` +1. WindowsSchemeInstaller uses 'reg' command-line tool +2. Registers in HKEY_CURRENT_USER\Software\Classes\ +3. Creates: (Default)="URL:", URL Protocol="", shell\open\command\(Default)=" %1" +4. No admin rights required (HKCU, not HKLM) +``` + +**Desktop Icon**: +- Not yet implemented +- WindowsPlatform.isDesktopIconToggleable() returns false + +**Files**: +- `WindowsPlatform.java` (Platform plugin) +- `WindowsSchemeInstaller.java` (SchemeInstaller implementation) + +### macOS Platform + +**URI Scheme Registration**: +- Declared in Info.plist at build time +- MacOSPlatform reports read-only status (not toggleable) +- No runtime registration code + +**Desktop Icon**: +- Application bundle installed by user (not programmatic) +- MacOSPlatform.isDesktopIconToggleable() returns false + +**Additional Features**: +- MacOSAppEventDispatcher handles desktop events (open file, open URI, quit, about, preferences) +- Screen menu bar support + +**Files**: +- `MacOSPlatform.java` (Platform plugin) +- `MacOSAppEventDispatcher.java` (Event handling) + +## System Properties + +| Property | Purpose | Platforms | Default | +|----------|---------|-----------|---------| +| `scijava.app.executable` | Executable path for URI scheme registration | All | None (required) | +| `scijava.app.name` | Application name for .desktop file | Linux | "SciJava Application" | +| `scijava.app.icon` | Icon path for .desktop file | Linux | None (optional) | +| `scijava.app.directory` | Working directory for .desktop file | Linux | None (optional) | +| `scijava.app.desktop-file` | Override .desktop file path | Linux | `~/.local/share/applications/.desktop` | + +## Usage Example + +### Application Configuration + +```bash +java -Dscijava.app.executable="/usr/local/bin/myapp" \ + -Dscijava.app.name="My Application" \ + -Dscijava.app.icon="/usr/share/icons/myapp.png" \ + -Dscijava.app.directory="/usr/local/share/myapp" \ + -jar myapp.jar +``` + +### LinkHandler Implementation + +```java +@Plugin(type = LinkHandler.class) +public class MyAppLinkHandler extends AbstractLinkHandler { + + @Override + public boolean supports(final URI uri) { + return "myapp".equals(uri.getScheme()); + } + + @Override + public void handle(final URI uri) { + String operation = Links.operation(uri); + Map params = Links.query(uri); + // ... handle URI ... + } + + @Override + public List getSchemes() { + return Arrays.asList("myapp"); + } +} +``` + +### User Workflow + +1. Launch application +2. LinkService automatically registers "myapp" scheme with OS +3. User clicks `myapp://action?param=value` in browser +4. OS launches application with URI as argument +5. LinkArgument parses URI and passes to LinkService +6. LinkService routes to MyAppLinkHandler +7. Handler processes the request + +## Design Rationale + +### Why No Preference Persistence? + +The OS is the source of truth for desktop integration state. Users can manually modify registrations (delete .desktop files, edit registry), so we always query the OS directly rather than trusting potentially stale preferences. + +### Why Platform Plugins? + +The plugin system provides: +- Automatic platform detection by PlatformService +- No hardcoded OS checks +- Easy addition of new platforms +- Platform-specific behavior without code coupling + +### Why Separate SchemeInstaller? + +LinkService needs to register schemes on application startup, before any UI is available. SchemeInstaller provides a non-UI API for OS registration, separate from the UI-facing DesktopIntegrationProvider used by OptionsDesktop. + +### Why DesktopFile Utility? + +Linux .desktop files are used by both LinuxPlatform (for desktop icon) and LinuxSchemeInstaller (for URI schemes). The DesktopFile utility avoids code duplication and provides a clean, instance-based API for .desktop file manipulation. + +## Testing + +### Current Test Coverage + +- Compilation with Java 11: ✅ +- Existing unit tests: ✅ (all pass) + +### Manual Testing Needed + +- [ ] Windows: Registry entries created correctly +- [ ] Windows: URI links launch application +- [ ] Linux: .desktop file created with correct content +- [ ] Linux: xdg-mime associations registered +- [ ] Linux: URI links launch application +- [ ] Linux: Desktop icon appears in application menu +- [ ] macOS: Read-only status reported correctly +- [ ] All platforms: OptionsDesktop UI displays correct state +- [ ] All platforms: Toggling features works as expected + +### Test Scenarios + +1. **Fresh Installation**: No existing .desktop file or registry entries +2. **Existing Registration**: Application already registered +3. **Manual Modification**: User deletes .desktop file or registry entry manually +4. **Multiple Schemes**: LinkHandler declares multiple schemes +5. **Permission Errors**: Insufficient permissions to write registry/files + +## Known Issues + +### Critical Issues + +1. **Hardcoded "fiji" scheme**: Prevents use by other applications + - Workaround: None; requires code fix + - Priority: High + +2. **Hardcoded OS checks**: Violates plugin architecture + - Workaround: None; requires code fix + - Priority: Medium + +### Minor Issues + +1. **No first launch dialog**: Users must discover OptionsDesktop manually + - Workaround: Application can show custom dialog + - Priority: Low + +2. **Single scheme assumption**: Platforms assume one scheme per app + - Workaround: Modify platform code to iterate schemes + - Priority: Medium + +3. **No file extension support**: Cannot register file types + - Workaround: None; feature not implemented + - Priority: Low + +## Next Steps + +See NEXT.md for detailed implementation plan, including: + +1. Remove hardcoded scheme names (Priority 1) +2. Add getSchemeInstaller method (Priority 2) +3. Refactor DefaultLinkService#createInstaller() (Priority 3) +4. Implement first launch dialog (Optional) +5. Add file extension registration (Future) +6. Implement Windows desktop icon (Future) + +## Dependencies + +- **scijava-common**: Provides PlatformService, PluginService, AppService +- **Java 11+**: Required for java.awt.Desktop features +- **Platform-specific tools**: + - Windows: `reg` command (built-in) + - Linux: `xdg-mime`, `update-desktop-database` (part of xdg-utils) + - macOS: Info.plist (build-time configuration) + +## Backward Compatibility + +- **No API breaks**: All additions are new interfaces and implementations +- **Opt-in behavior**: Applications without LinkHandlers are unaffected +- **No scijava-common changes**: All platform extensions are in scijava-desktop + +## Summary + +The scijava-desktop component provides a solid foundation for desktop integration, with working implementations for URI scheme registration and desktop icon management on Linux and Windows. The main remaining work is to remove hardcoded elements (scheme names, OS checks) and implement planned features (file extensions, Windows icon, first launch dialog). + +The architecture is clean, plugin-based, and ready for production use once the hardcoded elements are addressed. diff --git a/src/main/java/org/scijava/desktop/DesktopIntegrationProvider.java b/src/main/java/org/scijava/desktop/DesktopIntegrationProvider.java new file mode 100644 index 0000000..19cf36a --- /dev/null +++ b/src/main/java/org/scijava/desktop/DesktopIntegrationProvider.java @@ -0,0 +1,114 @@ +/* + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop; + +import java.io.IOException; + +import org.scijava.desktop.links.SchemeInstaller; + +/** + * Marker interface for platform implementations that provide desktop + * integration features. + *

+ * Platforms implementing this interface can be queried for desktop integration + * capabilities via {@link #getDesktopIntegration()}. + *

+ * + * @author Curtis Rueden + */ +public interface DesktopIntegrationProvider { + + boolean isWebLinksEnabled(); + + boolean isWebLinksToggleable(); + + /** + * Enables or disables URI scheme registration (e.g., {@code myapp://} links). + *

+ * This operation only works if {@link #isWebLinksToggleable()} + * returns true. Otherwise, calling this method may throw + * {@link UnsupportedOperationException}. + *

+ * + * @param enable whether to enable or disable web links + * @throws IOException if the operation fails + * @throws UnsupportedOperationException if not supported on this platform + */ + void setWebLinksEnabled(final boolean enable) throws IOException; + + boolean isDesktopIconPresent(); + + boolean isDesktopIconToggleable(); + + /** + * Installs or removes the desktop icon (application launcher, menu entry). + *

+ * This operation only works if + * {@link #isDesktopIconToggleable()} returns true. + * Otherwise, calling this method may throw + * {@link UnsupportedOperationException}. + *

+ * + * @param install whether to install or remove the desktop icon + * @throws IOException if the operation fails + * @throws UnsupportedOperationException if not supported on this platform + */ + void setDesktopIconPresent(final boolean install) throws IOException; + + boolean isFileExtensionsEnabled(); + + boolean isFileExtensionsToggleable(); + + /** + * Enables or disables file extension associations (e.g., {@code .tif}, {@code .png}). + *

+ * This operation only works if {@link #isFileExtensionsToggleable()} + * returns true. Otherwise, calling this method may throw + * {@link UnsupportedOperationException}. + *

+ *

+ * When enabled, the application will be registered as a handler for all + * supported file extensions. The application appears in "Open With" menus, + * allowing users to choose it for specific file types. + *

+ * + * @param enable whether to enable or disable file extension associations + * @throws IOException if the operation fails + * @throws UnsupportedOperationException if not supported on this platform + */ + void setFileExtensionsEnabled(final boolean enable) throws IOException; + + /** + * Creates a SchemeInstaller for this platform. + * + * @return a SchemeInstaller, or null if not supported on this platform + */ + SchemeInstaller getSchemeInstaller(); +} diff --git a/src/main/java/org/scijava/links/AbstractLinkHandler.java b/src/main/java/org/scijava/desktop/links/AbstractLinkHandler.java similarity index 92% rename from src/main/java/org/scijava/links/AbstractLinkHandler.java rename to src/main/java/org/scijava/desktop/links/AbstractLinkHandler.java index 567f4d1..bfbb680 100644 --- a/src/main/java/org/scijava/links/AbstractLinkHandler.java +++ b/src/main/java/org/scijava/desktop/links/AbstractLinkHandler.java @@ -1,8 +1,8 @@ /*- * #%L - * URL scheme handlers for SciJava. + * Desktop integration for SciJava. * %% - * Copyright (C) 2023 - 2025 SciJava developers. + * Copyright (C) 2010 - 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.links; +package org.scijava.desktop.links; import org.scijava.plugin.AbstractHandlerPlugin; diff --git a/src/main/java/org/scijava/desktop/links/DefaultLinkService.java b/src/main/java/org/scijava/desktop/links/DefaultLinkService.java new file mode 100644 index 0000000..51c6605 --- /dev/null +++ b/src/main/java/org/scijava/desktop/links/DefaultLinkService.java @@ -0,0 +1,149 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.links; + +import org.scijava.event.ContextCreatedEvent; +import org.scijava.event.EventHandler; +import org.scijava.desktop.DesktopIntegrationProvider; +import org.scijava.desktop.links.SchemeInstaller; +import org.scijava.log.LogService; +import org.scijava.platform.Platform; +import org.scijava.platform.PlatformService; +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}. + * + * @author Curtis Rueden + */ +@Plugin(type = Service.class) +public class DefaultLinkService extends AbstractHandlerService implements LinkService { + + @Parameter(required = false) + private LogService log; + + @Parameter(required = false) + private PlatformService platformService; + + @EventHandler + private void onEvent(final ContextCreatedEvent evt) { + // Register URI handler with the desktop system, if possible. + 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. + *

+ * Uses the platform plugin system to obtain the installer, avoiding + * hardcoded OS checks. Windows and Linux platforms provide runtime registration. + * macOS uses Info.plist in the .app bundle (configured at build time). + *

+ */ + private SchemeInstaller createInstaller() { + if (platformService == null) return null; + + // Find a platform that provides a SchemeInstaller + for (final Platform platform : platformService.getTargetPlatforms()) { + if (platform instanceof DesktopIntegrationProvider) { + final SchemeInstaller installer = ((DesktopIntegrationProvider) platform).getSchemeInstaller(); + if (installer != null) return installer; + } + } + + return null; + } + + /** + * 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/desktop/links/LinkHandler.java similarity index 71% rename from src/main/java/org/scijava/links/LinkHandler.java rename to src/main/java/org/scijava/desktop/links/LinkHandler.java index 5dfded8..b717771 100644 --- a/src/main/java/org/scijava/links/LinkHandler.java +++ b/src/main/java/org/scijava/desktop/links/LinkHandler.java @@ -1,8 +1,8 @@ /*- * #%L - * URL scheme handlers for SciJava. + * Desktop integration for SciJava. * %% - * Copyright (C) 2023 - 2025 SciJava developers. + * Copyright (C) 2010 - 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,11 +26,13 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.links; +package org.scijava.desktop.links; 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., "myapp") + * 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/desktop/links/LinkService.java similarity index 95% rename from src/main/java/org/scijava/links/LinkService.java rename to src/main/java/org/scijava/desktop/links/LinkService.java index f9dec83..ed8cd36 100644 --- a/src/main/java/org/scijava/links/LinkService.java +++ b/src/main/java/org/scijava/desktop/links/LinkService.java @@ -1,8 +1,8 @@ /*- * #%L - * URL scheme handlers for SciJava. + * Desktop integration for SciJava. * %% - * Copyright (C) 2023 - 2025 SciJava developers. + * Copyright (C) 2010 - 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.links; +package org.scijava.desktop.links; import org.scijava.log.Logger; import org.scijava.plugin.HandlerService; diff --git a/src/main/java/org/scijava/links/Links.java b/src/main/java/org/scijava/desktop/links/Links.java similarity index 96% rename from src/main/java/org/scijava/links/Links.java rename to src/main/java/org/scijava/desktop/links/Links.java index 76d2a50..3f1fd06 100644 --- a/src/main/java/org/scijava/links/Links.java +++ b/src/main/java/org/scijava/desktop/links/Links.java @@ -1,8 +1,8 @@ /*- * #%L - * URL scheme handlers for SciJava. + * Desktop integration for SciJava. * %% - * Copyright (C) 2023 - 2025 SciJava developers. + * Copyright (C) 2010 - 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.links; +package org.scijava.desktop.links; import java.net.URI; import java.util.LinkedHashMap; diff --git a/src/main/java/org/scijava/desktop/links/SchemeInstaller.java b/src/main/java/org/scijava/desktop/links/SchemeInstaller.java new file mode 100644 index 0000000..00d20b1 --- /dev/null +++ b/src/main/java/org/scijava/desktop/links/SchemeInstaller.java @@ -0,0 +1,84 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.links; + +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., "myapp") + * @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/console/LinkArgument.java b/src/main/java/org/scijava/desktop/links/console/LinkArgument.java similarity index 94% rename from src/main/java/org/scijava/links/console/LinkArgument.java rename to src/main/java/org/scijava/desktop/links/console/LinkArgument.java index 213a951..206a840 100644 --- a/src/main/java/org/scijava/links/console/LinkArgument.java +++ b/src/main/java/org/scijava/desktop/links/console/LinkArgument.java @@ -1,8 +1,8 @@ /*- * #%L - * URL scheme handlers for SciJava. + * Desktop integration for SciJava. * %% - * Copyright (C) 2023 - 2025 SciJava developers. + * Copyright (C) 2010 - 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,12 +26,12 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.links.console; +package org.scijava.desktop.links.console; import org.scijava.Priority; import org.scijava.console.AbstractConsoleArgument; import org.scijava.console.ConsoleArgument; -import org.scijava.links.LinkService; +import org.scijava.desktop.links.LinkService; import org.scijava.log.Logger; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; diff --git a/src/main/java/org/scijava/desktop/options/OptionsDesktop.java b/src/main/java/org/scijava/desktop/options/OptionsDesktop.java new file mode 100644 index 0000000..0a3fd84 --- /dev/null +++ b/src/main/java/org/scijava/desktop/options/OptionsDesktop.java @@ -0,0 +1,166 @@ +/* + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.options; + +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.scijava.desktop.DesktopIntegrationProvider; +import org.scijava.log.LogService; +import org.scijava.options.OptionsPlugin; +import org.scijava.platform.Platform; +import org.scijava.platform.PlatformService; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; +import org.scijava.plugin.PluginInfo; +import org.scijava.plugin.SciJavaPlugin; + +/** + * Options plugin for managing desktop integration features. + *

+ * Provides a UI for enabling/disabling web links (URI schemes) and + * desktop icons. Settings are applied directly to the OS (not persisted + * to preferences), keeping the UI in sync with actual system state. + *

+ * + * @author Curtis Rueden + */ +@Plugin(type = OptionsPlugin.class, menuPath = "Edit > Options > Desktop...") +public class OptionsDesktop extends OptionsPlugin { + + @Parameter + private PlatformService platformService; + + @Parameter(required = false) + private LogService log; + + @Parameter(label = "Enable web links", persist = false, validater = "validateWebLinks", // + description = "Allow handling of URI link schemes from web browsers") + private boolean webLinksEnabled; + + @Parameter(label = "Add desktop icon", persist = false, validater = "validateDesktopIcon", // + description = "Install application icon in the system menu") + private boolean desktopIconPresent; + + @Parameter(label = "Enable file type associations", persist = false, validater = "validateFileExtensions", // + description = "Register supported file extensions with the operating system") + private boolean fileExtensionsEnabled; + + @Override + public void load() { + webLinksEnabled = true; + desktopIconPresent = true; + fileExtensionsEnabled = true; + for (final Platform platform : platformService.getTargetPlatforms()) { + if (!(platform instanceof DesktopIntegrationProvider)) continue; + final DesktopIntegrationProvider dip = (DesktopIntegrationProvider) platform; + // If any toggleable platform setting is off, uncheck that box. + if (dip.isDesktopIconToggleable() && !dip.isDesktopIconPresent()) desktopIconPresent = false; + if (dip.isWebLinksToggleable() && !dip.isWebLinksEnabled()) webLinksEnabled = false; + if (dip.isFileExtensionsToggleable() && !dip.isFileExtensionsEnabled()) fileExtensionsEnabled = false; + } + } + + @Override + public void run() { + for (final Platform platform : platformService.getTargetPlatforms()) { + if (!(platform instanceof DesktopIntegrationProvider)) continue; + final DesktopIntegrationProvider dip = (DesktopIntegrationProvider) platform; + try { + dip.setWebLinksEnabled(webLinksEnabled); + dip.setDesktopIconPresent(desktopIconPresent); + dip.setFileExtensionsEnabled(fileExtensionsEnabled); + } + catch (final IOException e) { + if (log != null) { + log.error("Error applying desktop integration settings", e); + } + } + } + super.run(); + } + + // -- Validators -- + + public void validateWebLinks() { + validateSetting( + DesktopIntegrationProvider::isWebLinksToggleable, + DesktopIntegrationProvider::isWebLinksEnabled, + webLinksEnabled, + "Web links setting"); + } + + public void validateDesktopIcon() { + validateSetting( + DesktopIntegrationProvider::isDesktopIconToggleable, + DesktopIntegrationProvider::isDesktopIconPresent, + desktopIconPresent, + "Desktop icon presence"); + } + + public void validateFileExtensions() { + validateSetting( + DesktopIntegrationProvider::isFileExtensionsToggleable, + DesktopIntegrationProvider::isFileExtensionsEnabled, + fileExtensionsEnabled, + "File extensions setting"); + } + + // -- Helper methods -- + + private String name(Platform platform) { + final List> infos = + pluginService.getPluginsOfClass(platform.getClass()); + return infos.isEmpty() ? null : infos.get(0).getName(); + } + + private void validateSetting( + Function mutable, + Function getter, + boolean value, String settingDescription) + { + boolean toggleable = false; + boolean enabled = false; + Platform strictPlatform = null; + for (final Platform platform : platformService.getTargetPlatforms()) { + if (!(platform instanceof DesktopIntegrationProvider)) continue; + final DesktopIntegrationProvider dip = (DesktopIntegrationProvider) platform; + if (mutable.apply(dip)) toggleable = true; + else if (strictPlatform == null) strictPlatform = platform; + if (getter.apply(dip)) enabled = true; + } + if (!toggleable && enabled != value) { + final String platformName = strictPlatform == null ? "this platform" : name(strictPlatform); + throw new IllegalArgumentException(settingDescription + " cannot be changed on " + platformName + "."); + } + } +} diff --git a/src/main/java/org/scijava/desktop/platform/linux/DesktopFile.java b/src/main/java/org/scijava/desktop/platform/linux/DesktopFile.java new file mode 100644 index 0000000..9c6006f --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/linux/DesktopFile.java @@ -0,0 +1,407 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.linux; + +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; + +/** + * Parser and writer for Linux .desktop files. + *

+ * Instance-based API for reading and writing .desktop files. Supports all + * standard [Desktop Entry] section fields plus custom keys. This class + * provides a convenient way to manage desktop files for both application + * launching and URI scheme registration. + *

+ *

+ * Note: This class will eventually move to {@code org.scijava.util.DesktopFile} + * in scijava-common once the design is finalized. + *

+ * + * @author Curtis Rueden + */ +public class DesktopFile { + + private final Path path; + private final Map entries; + private final List comments; + + /** + * Creates a DesktopFile instance for the given path. + *

+ * If the file exists, it will be loaded when {@link #load()} is called. + * If it doesn't exist, the entries map will be empty until populated + * programmatically or {@link #load()} is called. + *

+ * + * @param path the path to the .desktop file + */ + public DesktopFile(final Path path) { + this.path = path; + this.entries = new LinkedHashMap<>(); + this.comments = new ArrayList<>(); + } + + /** + * Gets the file path for this desktop file. + * + * @return the path + */ + public Path path() { + return path; + } + + /** + * Checks if the file exists on disk. + * + * @return true if the file exists + */ + public boolean exists() { + return Files.exists(path); + } + + /** + * Loads the .desktop file from disk. + *

+ * Clears any existing entries and comments, then reads from the file. + * If the file doesn't exist, entries will be empty after this call. + *

+ * + * @throws IOException if reading fails + */ + public void load() throws IOException { + entries.clear(); + comments.clear(); + + if (!exists()) { + return; + } + + 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("#")) { + 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); + entries.put(key, value); + } + } + } + } + + /** + * Saves the .desktop file to disk. + *

+ * Creates parent directories if needed. Overwrites any existing file. + *

+ * + * @throws IOException if writing fails + */ + public void save() 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(); + } + } + } + + /** + * Deletes the file from disk. + * + * @throws IOException if deletion fails + */ + public void delete() throws IOException { + Files.deleteIfExists(path); + } + + /** + * Parses a .desktop file from disk (static convenience method). + * + * @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(path); + df.load(); + return df; + } + + /** + * Writes the .desktop file to disk (for backward compatibility). + * + * @param path Path to write to + * @throws IOException if writing fails + */ + public void writeTo(final Path path) throws IOException { + final Path oldPath = this.path; + // Temporarily change path, save, then restore + try { + // Create a temporary instance with the new path + final DesktopFile temp = new DesktopFile(path); + temp.entries.putAll(this.entries); + temp.comments.addAll(this.comments); + temp.save(); + } catch (final Exception e) { + throw new IOException("Failed to write to " + path, e); + } + } + + /** + * 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) { + if (value == null) { + entries.remove(key); + } else { + entries.put(key, value); + } + } + + // -- Standard field accessors -- + + public String getType() { + return get("Type"); + } + + public void setType(final String type) { + set("Type", type); + } + + public String getVersion() { + return get("Version"); + } + + public void setVersion(final String version) { + set("Version", version); + } + + public String getName() { + return get("Name"); + } + + public void setName(final String name) { + set("Name", name); + } + + public String getGenericName() { + return get("GenericName"); + } + + public void setGenericName(final String genericName) { + set("GenericName", genericName); + } + + public String getComment() { + return get("Comment"); + } + + public void setComment(final String comment) { + set("Comment", comment); + } + + public String getExec() { + return get("Exec"); + } + + public void setExec(final String exec) { + set("Exec", exec); + } + + public String getIcon() { + return get("Icon"); + } + + public void setIcon(final String icon) { + set("Icon", icon); + } + + public String getPath() { + return get("Path"); + } + + public void setPath(final String path) { + set("Path", path); + } + + public boolean getTerminal() { + final String value = get("Terminal"); + return "true".equalsIgnoreCase(value); + } + + public void setTerminal(final boolean terminal) { + set("Terminal", terminal ? "true" : "false"); + } + + public String getCategories() { + return get("Categories"); + } + + public void setCategories(final String categories) { + set("Categories", categories); + } + + // -- MimeType handling -- + + /** + * Checks if a MimeType entry contains a specific MIME type. + * + * @param mimeType The MIME type to check (e.g., "x-scheme-handler/myapp") + * @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/myapp") + */ + 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/desktop/platform/linux/LinuxPlatform.java b/src/main/java/org/scijava/desktop/platform/linux/LinuxPlatform.java new file mode 100644 index 0000000..77b7e0c --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/linux/LinuxPlatform.java @@ -0,0 +1,541 @@ +/* + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.linux; + +import org.scijava.app.AppService; +import org.scijava.desktop.DesktopIntegrationProvider; +import org.scijava.desktop.links.LinkHandler; +import org.scijava.desktop.links.LinkService; +import org.scijava.desktop.links.SchemeInstaller; +import org.scijava.log.LogService; +import org.scijava.platform.AbstractPlatform; +import org.scijava.platform.Platform; +import org.scijava.platform.PlatformService; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +/** + * A platform implementation for handling Linux platform issues. + *

+ * This implementation creates and maintains a .desktop file for the application, + * enabling proper desktop integration including: + *

+ *
    + *
  • Application launcher in menus
  • + *
  • Application icon
  • + *
  • File associations
  • + *
  • URI scheme handling
  • + *
+ * + * @author Curtis Rueden + */ +@Plugin(type = Platform.class, name = "Linux") +public class LinuxPlatform extends AbstractPlatform + implements DesktopIntegrationProvider +{ + + @Parameter + private LinkService linkService; + + @Parameter + private AppService appService; + + @Parameter(required = false) + private LogService log; + + /** Cached MIME type mapping */ + private static Map extensionToMime = null; + + // -- Platform methods -- + + @Override + public String osName() { + return "Linux"; + } + + @Override + public void configure(final PlatformService service) { + super.configure(service); + + // Create or update .desktop file for desktop integration + try { + installDesktopFile(); + } + catch (final IOException e) { + if (log != null) { + log.error("Failed to install .desktop file", e); + } + } + } + + @Override + public void open(final URL url) throws IOException { + if (getPlatformService().exec("xdg-open", url.toString()) != 0) { + throw new IOException("Could not open " + url); + } + } + + // -- DesktopIntegrationProvider methods -- + + @Override + public boolean isWebLinksEnabled() { + try { + final DesktopFile df = getOrCreateDesktopFile(); + final Set schemes = collectSchemes(); + if (schemes.isEmpty()) return false; + + // Check if any scheme is registered + for (final String scheme : schemes) { + if (df.hasMimeType("x-scheme-handler/" + scheme)) return true; + } + return false; + } catch (final IOException e) { + if (log != null) { + log.debug("Failed to check web links status", e); + } + return false; + } + } + + @Override + public boolean isWebLinksToggleable() { return true; } + + @Override + public void setWebLinksEnabled(final boolean enable) throws IOException { + final DesktopFile df = getOrCreateDesktopFile(); + + final Set schemes = collectSchemes(); + for (final String scheme : schemes) { + final String mimeType = "x-scheme-handler/" + scheme; + if (enable) { + df.addMimeType(mimeType); + } else { + df.removeMimeType(mimeType); + } + } + + df.save(); + } + + @Override + public boolean isDesktopIconPresent() { + final Path desktopFilePath = getDesktopFilePath(); + return Files.exists(desktopFilePath); + } + + @Override + public boolean isDesktopIconToggleable() { return true; } + + @Override + public void setDesktopIconPresent(final boolean install) throws IOException { + final DesktopFile df = getOrCreateDesktopFile(); + + if (install) { + // Ensure .desktop file has all required fields + if (df.getName() == null) { + final String appName = System.getProperty("scijava.app.name", "SciJava Application"); + df.setName(appName); + } + if (df.getType() == null) { + df.setType("Application"); + } + if (df.getVersion() == null) { + df.setVersion("1.0"); + } + if (df.getExec() == null) { + final String appExec = System.getProperty("scijava.app.executable"); + if (appExec == null) { + throw new IOException("No executable path set (scijava.app.executable property)"); + } + df.setExec(appExec + " %U"); + } + if (df.getGenericName() == null) { + final String appName = System.getProperty("scijava.app.name", "SciJava Application"); + df.setGenericName(appName); + } + + // Set optional fields if provided + final String appIcon = System.getProperty("scijava.app.icon"); + if (appIcon != null && df.getIcon() == null) { + df.setIcon(appIcon); + } + + final String appDir = System.getProperty("scijava.app.directory"); + if (appDir != null && df.getPath() == null) { + df.setPath(appDir); + } + + if (df.getCategories() == null) { + df.setCategories("Science;Education;"); + } + + df.setTerminal(false); + + df.save(); + } + else { + df.delete(); + } + } + + @Override + public boolean isFileExtensionsEnabled() { + try { + final DesktopFile df = getOrCreateDesktopFile(); + final Map mimeMapping = loadMimeTypeMapping(); + + // Check if any file extension MIME types are in the .desktop file + for (final String mimeType : mimeMapping.values()) { + if (df.hasMimeType(mimeType)) return true; + } + return false; + } catch (final IOException e) { + if (log != null) { + log.debug("Failed to check file extensions status", e); + } + return false; + } + } + + @Override + public boolean isFileExtensionsToggleable() { + return true; + } + + @Override + public void setFileExtensionsEnabled(final boolean enable) throws IOException { + final Map mimeMapping = loadMimeTypeMapping(); + if (mimeMapping.isEmpty()) { + if (log != null) { + log.warn("No file extensions to register"); + } + return; + } + + if (enable) { + // Register custom MIME types for formats without standard types + registerCustomMimeTypes(mimeMapping); + + // Add MIME types to .desktop file + final DesktopFile df = getOrCreateDesktopFile(); + for (final String mimeType : mimeMapping.values()) { + df.addMimeType(mimeType); + } + df.save(); + + if (log != null) { + log.info("Registered " + mimeMapping.size() + " file extension MIME types"); + } + } else { + // Remove file extension MIME types from .desktop file + // Keep URI scheme handlers (x-scheme-handler/...) + final DesktopFile df = getOrCreateDesktopFile(); + final Set uriSchemes = collectSchemes(); + + for (final String mimeType : mimeMapping.values()) { + df.removeMimeType(mimeType); + } + + // Re-add URI scheme handlers + for (final String scheme : uriSchemes) { + df.addMimeType("x-scheme-handler/" + scheme); + } + + df.save(); + + if (log != null) { + log.info("Unregistered file extension MIME types"); + } + } + } + + @Override + public SchemeInstaller getSchemeInstaller() { + return new LinuxSchemeInstaller(log); + } + + // -- Helper methods -- + + /** + * Gets or creates a DesktopFile instance, loading it if it exists. + */ + private DesktopFile getOrCreateDesktopFile() throws IOException { + final Path path = getDesktopFilePath(); + final DesktopFile df = new DesktopFile(path); + + if (df.exists()) { + df.load(); + } + + return df; + } + + /** + * Creates or updates the .desktop file for this application. + *

+ * The .desktop file path is determined by the {@code scijava.app.desktop-file} + * system property. If not set, defaults to {@code ~/.local/share/applications/.desktop}. + *

+ */ + private void installDesktopFile() throws IOException { + final Path desktopFilePath = getDesktopFilePath(); + + // Check if file already exists and is up-to-date + if (Files.exists(desktopFilePath) && isDesktopFileUpToDate(desktopFilePath)) { + if (log != null) { + log.debug("Desktop file is up-to-date: " + desktopFilePath); + } + return; + } + + // Get application properties + final String appName = System.getProperty("scijava.app.name", "SciJava Application"); + final String appExec = System.getProperty("scijava.app.executable"); + final String appIcon = System.getProperty("scijava.app.icon"); + final String appDir = System.getProperty("scijava.app.directory"); + + if (appExec == null) { + if (log != null) { + log.debug("No executable path set (scijava.app.executable property), skipping .desktop file creation"); + } + return; + } + + // Use DesktopFile to create and save + final DesktopFile df = new DesktopFile(desktopFilePath); + df.setType("Application"); + df.setVersion("1.0"); + df.setName(appName); + df.setGenericName(appName); + df.setExec(appExec + " %U"); + df.setTerminal(false); + df.setCategories("Science;Education;"); + + if (appIcon != null) { + df.setIcon(appIcon); + } + + if (appDir != null) { + df.setPath(appDir); + } + + // MimeType field intentionally left empty + // scijava-links will add URI scheme handlers (x-scheme-handler/...) + + df.save(); + + if (log != null) { + log.info("Created desktop file: " + desktopFilePath); + } + } + + private Path getDesktopFilePath() { + String desktopFilePath = System.getProperty("scijava.app.desktop-file"); + if (desktopFilePath == null) { + final String appName = System.getProperty("scijava.app.name", "scijava-app"); + final String home = System.getProperty("user.home"); + desktopFilePath = home + "/.local/share/applications/" + sanitizeFileName(appName) + ".desktop"; + System.setProperty("scijava.app.desktop-file", desktopFilePath); + } + return Paths.get(desktopFilePath); + } + + /** + * Checks if the desktop file is up-to-date with current system properties. + */ + private boolean isDesktopFileUpToDate(final Path desktopFile) { + // For now, simple existence check + // Future: could parse and compare with current properties + return Files.exists(desktopFile); + } + + /** + * Sanitizes a string for use as a file name. + */ + private String sanitizeFileName(final String name) { + return name.replaceAll("[^a-zA-Z0-9._-]", "-").toLowerCase(); + } + + // -- Helper methods -- + + /** + * Collects all URI schemes from registered LinkHandler plugins. + */ + private Set collectSchemes() { + final Set schemes = new HashSet<>(); + if (linkService == null) return schemes; + + for (final LinkHandler handler : linkService.getInstances()) { + final List handlerSchemes = handler.getSchemes(); + if (handlerSchemes != null) { + schemes.addAll(handlerSchemes); + } + } + return schemes; + } + + /** + * Loads the file extension to MIME type mapping. + */ + private synchronized Map loadMimeTypeMapping() throws IOException { + if (extensionToMime != null) return extensionToMime; + + extensionToMime = new LinkedHashMap<>(); + + // TODO: Query IOService for formats + + return extensionToMime; + } + + /** + * Registers custom MIME types for formats that don't have standard types. + * Creates ~/.local/share/mime/packages/[appName].xml and runs update-mime-database. + */ + private void registerCustomMimeTypes(final Map mimeMapping) throws IOException { + // Separate standard from custom MIME types + final Map customTypes = new LinkedHashMap<>(); + for (final Map.Entry entry : mimeMapping.entrySet()) { + final String mimeType = entry.getValue(); + // Custom types use application/x- prefix + if (mimeType.startsWith("application/x-")) { + customTypes.put(entry.getKey(), mimeType); + } + } + + if (customTypes.isEmpty()) { + // No custom types to register + return; + } + + // Generate MIME types XML + final String appName = System.getProperty("scijava.app.name", "SciJava"); + final String mimeXml = generateMimeTypesXml(customTypes, appName); + + // Write to ~/.local/share/mime/packages/.xml + final Path mimeDir = Paths.get(System.getProperty("user.home"), + ".local/share/mime/packages"); + Files.createDirectories(mimeDir); + + final Path mimeFile = mimeDir.resolve(sanitizeFileName(appName) + ".xml"); + Files.writeString(mimeFile, mimeXml, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + + // Update MIME database + try { + final ProcessBuilder pb = new ProcessBuilder( + "update-mime-database", + Paths.get(System.getProperty("user.home"), ".local/share/mime").toString() + ); + final Process process = pb.start(); + final int exitCode = process.waitFor(); + + if (exitCode != 0) { + if (log != null) { + log.warn("update-mime-database exited with code " + exitCode); + } + } else if (log != null) { + log.info("Registered " + customTypes.size() + " custom MIME types"); + } + } catch (final Exception e) { + if (log != null) { + log.error("Failed to run update-mime-database", e); + } + } + } + + /** + * Generates MIME types XML for custom file formats. + */ + private String generateMimeTypesXml(final Map customTypes, + final String appName) + { + final StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append("\n"); + + for (final Map.Entry entry : customTypes.entrySet()) { + final String extension = entry.getKey(); + final String mimeType = entry.getValue(); + + // Generate human-readable comment from MIME type + final String comment = generateMimeTypeComment(mimeType); + + xml.append(" \n"); + xml.append(" ").append(comment).append("\n"); + xml.append(" \n"); + xml.append(" \n"); + } + + xml.append("\n"); + return xml.toString(); + } + + /** + * Generates a human-readable comment from a MIME type. + * For example, "application/x-zeiss-czi" becomes "Zeiss CZI File". + */ + private String generateMimeTypeComment(final String mimeType) { + // Extract the format part (e.g., "zeiss-czi" from "application/x-zeiss-czi") + final String format = mimeType.substring(mimeType.lastIndexOf('/') + 1); + + // Remove "x-" prefix if present + final String cleanFormat = format.startsWith("x-") ? + format.substring(2) : format; + + // Convert to title case + final String[] parts = cleanFormat.split("-"); + final StringBuilder comment = new StringBuilder(); + for (final String part : parts) { + if (comment.length() > 0) comment.append(' '); + comment.append(Character.toUpperCase(part.charAt(0))); + if (part.length() > 1) { + comment.append(part.substring(1)); + } + } + comment.append(" File"); + + return comment.toString(); + } +} diff --git a/src/main/java/org/scijava/desktop/platform/linux/LinuxSchemeInstaller.java b/src/main/java/org/scijava/desktop/platform/linux/LinuxSchemeInstaller.java new file mode 100644 index 0000000..b1f30dc --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/linux/LinuxSchemeInstaller.java @@ -0,0 +1,248 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.linux; + +import org.scijava.desktop.links.SchemeInstaller; +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/desktop/platform/macos/MacOSAppEventDispatcher.java b/src/main/java/org/scijava/desktop/platform/macos/MacOSAppEventDispatcher.java new file mode 100644 index 0000000..e752c82 --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/macos/MacOSAppEventDispatcher.java @@ -0,0 +1,207 @@ +/* + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.macos; + +import com.apple.eawt.AboutHandler; +import com.apple.eawt.AppEvent.AboutEvent; +import com.apple.eawt.AppEvent.AppForegroundEvent; +import com.apple.eawt.AppEvent.AppHiddenEvent; +import com.apple.eawt.AppEvent.AppReOpenedEvent; +import com.apple.eawt.AppEvent.OpenFilesEvent; +import com.apple.eawt.AppEvent.PreferencesEvent; +import com.apple.eawt.AppEvent.PrintFilesEvent; +import com.apple.eawt.AppEvent.QuitEvent; +import com.apple.eawt.AppEvent.ScreenSleepEvent; +import com.apple.eawt.AppEvent.SystemSleepEvent; +import com.apple.eawt.AppEvent.UserSessionEvent; +import com.apple.eawt.AppForegroundListener; +import com.apple.eawt.AppHiddenListener; +import com.apple.eawt.AppReOpenedListener; +import com.apple.eawt.Application; +import com.apple.eawt.OpenFilesHandler; +import com.apple.eawt.PreferencesHandler; +import com.apple.eawt.PrintFilesHandler; +import com.apple.eawt.QuitHandler; +import com.apple.eawt.QuitResponse; +import com.apple.eawt.ScreenSleepListener; +import com.apple.eawt.SystemSleepListener; +import com.apple.eawt.UserSessionListener; + +import org.scijava.event.EventService; +import org.scijava.platform.event.AppAboutEvent; +import org.scijava.platform.event.AppFocusEvent; +import org.scijava.platform.event.AppOpenFilesEvent; +import org.scijava.platform.event.AppPreferencesEvent; +import org.scijava.platform.event.AppPrintEvent; +import org.scijava.platform.event.AppQuitEvent; +import org.scijava.platform.event.AppReOpenEvent; +import org.scijava.platform.event.AppScreenSleepEvent; +import org.scijava.platform.event.AppSystemSleepEvent; +import org.scijava.platform.event.AppUserSessionEvent; +import org.scijava.platform.event.AppVisibleEvent; + +/** + * Rebroadcasts macOS application events as SciJava events. + * + * @author Curtis Rueden + */ +public class MacOSAppEventDispatcher implements AboutHandler, + AppForegroundListener, AppHiddenListener, AppReOpenedListener, + PreferencesHandler, PrintFilesHandler, QuitHandler, ScreenSleepListener, + SystemSleepListener, UserSessionListener, OpenFilesHandler +{ + + private final EventService eventService; + + public MacOSAppEventDispatcher(final EventService eventService) { + this(Application.getApplication(), eventService); + } + + public MacOSAppEventDispatcher(final Application app, + final EventService eventService) + { + this.eventService = eventService; + app.setAboutHandler(this); + app.setPreferencesHandler(this); + app.setPrintFileHandler(this); + app.setQuitHandler(this); + app.addAppEventListener(this); + app.setOpenFileHandler(this); + } + + // -- AboutHandler methods -- + + @Override + public void handleAbout(final AboutEvent e) { + eventService.publish(new AppAboutEvent()); + } + + // -- PreferencesHandler methods -- + + @Override + public void handlePreferences(final PreferencesEvent e) { + eventService.publish(new AppPreferencesEvent()); + } + + // -- PrintFilesHandler -- + + @Override + public void printFiles(final PrintFilesEvent e) { + eventService.publish(new AppPrintEvent()); + } + + // -- QuitHandler methods -- + + @Override + public void handleQuitRequestWith(final QuitEvent e, final QuitResponse r) { + eventService.publish(new AppQuitEvent()); + r.cancelQuit(); + } + + // -- UserSessionListener methods -- + + @Override + public void userSessionActivated(final UserSessionEvent e) { + eventService.publish(new AppUserSessionEvent(true)); + } + + @Override + public void userSessionDeactivated(final UserSessionEvent e) { + eventService.publish(new AppUserSessionEvent(false)); + } + + // -- SystemSleepListener methods -- + + @Override + public void systemAboutToSleep(final SystemSleepEvent e) { + eventService.publish(new AppSystemSleepEvent(true)); + } + + //@Override + public void systemAwoke(final SystemSleepEvent e) { + eventService.publish(new AppSystemSleepEvent(false)); + } + + public void systemAweoke(final SystemSleepEvent e) { + // HACK: To make com.yuvimasory:orange-extensions:1.3 happy. + // See: https://github.com/ymasory/OrangeExtensions/pull/10 + } + + // -- ScreenSleepListener methods -- + + @Override + public void screenAboutToSleep(final ScreenSleepEvent e) { + eventService.publish(new AppScreenSleepEvent(true)); + } + + @Override + public void screenAwoke(final ScreenSleepEvent e) { + eventService.publish(new AppScreenSleepEvent(false)); + } + + // -- AppHiddenListener methods -- + + @Override + public void appHidden(final AppHiddenEvent e) { + eventService.publish(new AppVisibleEvent(false)); + } + + @Override + public void appUnhidden(final AppHiddenEvent e) { + eventService.publish(new AppVisibleEvent(true)); + } + + // -- AppForegroundListener methods -- + + @Override + public void appMovedToBackground(final AppForegroundEvent e) { + eventService.publish(new AppFocusEvent(false)); + } + + @Override + public void appRaisedToForeground(final AppForegroundEvent e) { + eventService.publish(new AppFocusEvent(true)); + } + + // -- AppReOpenedListener methods -- + + @Override + public void appReOpened(final AppReOpenedEvent e) { + eventService.publish(new AppReOpenEvent()); + } + + // -- OpenFilesHandler methods -- + + @Override + public void openFiles(final OpenFilesEvent event) { + eventService.publish(new AppOpenFilesEvent(event.getFiles())); + } + +} diff --git a/src/main/java/org/scijava/desktop/platform/macos/MacOSPlatform.java b/src/main/java/org/scijava/desktop/platform/macos/MacOSPlatform.java new file mode 100644 index 0000000..935d4d0 --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/macos/MacOSPlatform.java @@ -0,0 +1,225 @@ +/* + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.macos; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JFrame; +import javax.swing.JMenuBar; + +import org.scijava.command.CommandInfo; +import org.scijava.command.CommandService; +import org.scijava.desktop.DesktopIntegrationProvider; +import org.scijava.desktop.links.SchemeInstaller; +import org.scijava.display.event.window.WinActivatedEvent; +import org.scijava.event.EventHandler; +import org.scijava.event.EventService; +import org.scijava.event.EventSubscriber; +import org.scijava.module.ModuleInfo; +import org.scijava.module.event.ModulesUpdatedEvent; +import org.scijava.platform.AbstractPlatform; +import org.scijava.platform.Platform; +import org.scijava.platform.PlatformService; +import org.scijava.plugin.Plugin; + +/** + * A platform implementation for handling Apple macOS platform issues: + *
    + *
  • Application events are rebroadcast as SciJava events.
  • + *
  • macOS screen menu bar is enabled.
  • + *
  • Special screen menu bar menu items are handled.
  • + *
+ * + * @author Curtis Rueden + */ +@Plugin(type = Platform.class, name = "macOS") +public class MacOSPlatform extends AbstractPlatform + implements DesktopIntegrationProvider +{ + + /** Debugging flag to allow easy toggling of Mac screen menu bar behavior. */ + private static final boolean SCREEN_MENU = true; + + @SuppressWarnings("unused") + private Object appEventDispatcher; + + private JMenuBar menuBar; + + private List> subscribers; + + // -- Platform methods -- + + @Override + public String osName() { + // NB: The value of the os.name system property for activation purposes; + // see org.scijava.platform.Platform#isTarget(). + return "Mac OS X"; + } + + @Override + public void configure(final PlatformService service) { + super.configure(service); + + // use macOS screen menu bar + if (SCREEN_MENU) System.setProperty("apple.laf.useScreenMenuBar", "true"); + + // remove app commands from menu structure + if (SCREEN_MENU) removeAppCommandsFromMenu(); + + // translate macOS application events into SciJava events + final EventService eventService = getPlatformService().eventService(); + try { + appEventDispatcher = new MacOSAppEventDispatcher(eventService); + } + catch (final NoClassDefFoundError e) { + // the interfaces implemented by MacOSAppEventDispatcher might not be + // available: + // - on MacOSX Tiger without recent Java Updates + // - on earlier OS versions + } + + // subscribe to relevant window-related events + subscribers = eventService.subscribe(this); + } + + @Override + public void open(final URL url) throws IOException { + if (getPlatformService().exec("open", url.toString()) != 0) { + throw new IOException("Could not open " + url); + } + } + + @Override + public boolean registerAppMenus(final Object menus) { + if (SCREEN_MENU && menus instanceof JMenuBar) { + menuBar = (JMenuBar) menus; + } + return false; + } + + // -- DesktopIntegrationProvider methods -- + + @Override + public boolean isWebLinksEnabled() { + // URI schemes are declared in Info.plist, which is immutable. + return true; + } + + @Override + public boolean isWebLinksToggleable() { + // URI schemes are declared in Info.plist, which is immutable. + return false; + } + + @Override + public void setWebLinksEnabled(final boolean enable) { + // Note: Operation has no effect here. + // URI scheme registration is immutable on macOS (configured in .app bundle). + } + + @Override + public boolean isDesktopIconPresent() { return false; } + + @Override + public boolean isDesktopIconToggleable() { return false; } + + @Override + public void setDesktopIconPresent(final boolean install) { + // Note: Operation has no effect here. + // Desktop icon installation is not supported on macOS (use Dock pinning instead). + } + + @Override + public boolean isFileExtensionsEnabled() { + // File extensions are declared in Info.plist, which is immutable. + return true; + } + + @Override + public boolean isFileExtensionsToggleable() { + // File extensions are declared in Info.plist, which is immutable. + return false; + } + + @Override + public void setFileExtensionsEnabled(final boolean enable) { + // Note: Operation has no effect here. + // File extension registration is immutable on macOS (configured in .app bundle). + } + + @Override + public SchemeInstaller getSchemeInstaller() { + // macOS uses Info.plist for URI scheme registration (build-time only) + return null; + } + + // -- Disposable methods -- + + @Override + public void dispose() { + getPlatformService().eventService().unsubscribe(subscribers); + } + + // -- Event handlers -- + + @EventHandler + protected void onEvent(final WinActivatedEvent evt) { + if (!SCREEN_MENU || !isTarget()) return; + + final Object window = evt.getWindow(); + if (!(window instanceof JFrame)) return; + + // assign the singleton menu bar to newly activated window + ((JFrame) window).setJMenuBar(menuBar); + } + + // -- Helper methods -- + + private void removeAppCommandsFromMenu() { + final PlatformService platformService = getPlatformService(); + final EventService eventService = platformService.eventService(); + final CommandService commandService = platformService.commandService(); + + // NB: Search for commands being handled at the application level. + // We remove such commands from the main menu bar; + // the Mac application menu will trigger them instead. + final ArrayList infos = new ArrayList<>(); + for (final CommandInfo info : commandService.getCommands()) { + if (info.is("app-command")) { + info.setMenuPath(null); + infos.add(info); + } + } + eventService.publish(new ModulesUpdatedEvent(infos)); + } +} diff --git a/src/main/java/org/scijava/desktop/platform/windows/WindowsPlatform.java b/src/main/java/org/scijava/desktop/platform/windows/WindowsPlatform.java new file mode 100644 index 0000000..ca2416b --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/windows/WindowsPlatform.java @@ -0,0 +1,312 @@ +/* + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.windows; + +import java.io.IOException; +import java.net.URL; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.scijava.desktop.DesktopIntegrationProvider; +import org.scijava.desktop.links.LinkHandler; +import org.scijava.desktop.links.LinkService; +import org.scijava.desktop.links.SchemeInstaller; +import org.scijava.log.LogService; +import org.scijava.platform.AbstractPlatform; +import org.scijava.platform.Platform; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; + +/** + * A platform implementation for handling Windows platform issues. + * + * @author Johannes Schindelin + */ +@Plugin(type = Platform.class, name = "Windows") +public class WindowsPlatform extends AbstractPlatform + implements DesktopIntegrationProvider +{ + + @Parameter(required = false) + private LogService log; + + @Parameter(required = false) + private LinkService linkService; + + // -- Platform methods -- + + @Override + public String osName() { + return "Windows"; + } + + @Override + public void open(final URL url) throws IOException { + final String cmd; + final String arg; + // NB: the cmd and arg separate is necessary for Windows OS + // to open the default browser correctly. + if (System.getProperty("os.name").startsWith("Windows 2000")) { + cmd = "rundll32"; + arg = "shell32.dll,ShellExec_RunDLL"; + } + else { + cmd = "rundll32"; + arg = "url.dll,FileProtocolHandler"; + } + if (getPlatformService().exec(cmd, arg, url.toString()) != 0) { + throw new IOException("Could not open " + url); + } + } + + // -- DesktopIntegrationProvider methods -- + + @Override + public boolean isWebLinksEnabled() { + final WindowsSchemeInstaller installer = new WindowsSchemeInstaller(log); + final Set schemes = collectSchemes(); + if (schemes.isEmpty()) return false; + + // Check if any scheme is installed + for (final String scheme : schemes) { + if (installer.isInstalled(scheme)) return true; + } + return false; + } + + @Override + public boolean isWebLinksToggleable() { return true; } + + @Override + public void setWebLinksEnabled(final boolean enable) throws IOException { + final WindowsSchemeInstaller installer = new WindowsSchemeInstaller(log); + final String executablePath = System.getProperty("scijava.app.executable"); + + if (executablePath == null) { + throw new IOException("No executable path set (scijava.app.executable property)"); + } + + final Set schemes = collectSchemes(); + for (final String scheme : schemes) { + try { + if (enable) { + installer.install(scheme, executablePath); + } + else { + installer.uninstall(scheme); + } + } + catch (final Exception e) { + if (log != null) { + log.error("Failed to " + (enable ? "install" : "uninstall") + + " URI scheme: " + scheme, e); + } + } + } + } + + @Override + public boolean isDesktopIconPresent() { return false; } + + @Override + public boolean isDesktopIconToggleable() { return false; } + + @Override + public void setDesktopIconPresent(final boolean install) { + // Note: Operation has no effect here. + // Desktop icon installation is not supported on Windows (add to Start menu manually). + } + + @Override + public boolean isFileExtensionsEnabled() { + // Check if any extensions are registered + final Set extensions = collectFileExtensions(); + if (extensions.isEmpty()) return false; + + // For simplicity, check if the Applications key exists + // A more thorough check would verify each extension + try { + final String executableName = getExecutableName(); + if (executableName == null) return false; + + final ProcessBuilder pb = new ProcessBuilder( + "reg", "query", + "HKCU\\Software\\Classes\\Applications\\" + executableName, + "/v", "FriendlyAppName" + ); + final Process process = pb.start(); + final int exitCode = process.waitFor(); + return exitCode == 0; + } catch (final Exception e) { + if (log != null) { + log.debug("Failed to check file extensions status", e); + } + return false; + } + } + + @Override + public boolean isFileExtensionsToggleable() { + return true; + } + + @Override + public void setFileExtensionsEnabled(final boolean enable) throws IOException { + final String executablePath = System.getProperty("scijava.app.executable"); + if (executablePath == null) { + throw new IOException("No executable path set (scijava.app.executable property)"); + } + + final String executableName = getExecutableName(); + if (executableName == null) { + throw new IOException("Could not determine executable name"); + } + + final Set extensions = collectFileExtensions(); + if (extensions.isEmpty()) { + if (log != null) { + log.warn("No file extensions to register"); + } + return; + } + + if (enable) { + // Register using Applications\SupportedTypes approach + try { + // Create Applications key + execRegistryCommand("add", + "HKCU\\Software\\Classes\\Applications\\" + executableName, + "/f"); + + // Set friendly name + final String appName = System.getProperty("scijava.app.name", "SciJava Application"); + execRegistryCommand("add", + "HKCU\\Software\\Classes\\Applications\\" + executableName, + "/v", "FriendlyAppName", + "/d", appName, + "/f"); + + // Add each extension to SupportedTypes + for (final String ext : extensions) { + execRegistryCommand("add", + "HKCU\\Software\\Classes\\Applications\\" + executableName + "\\SupportedTypes", + "/v", "." + ext, + "/d", "", + "/f"); + } + + if (log != null) { + log.info("Registered " + extensions.size() + " file extensions for " + appName); + } + } catch (final Exception e) { + throw new IOException("Failed to register file extensions", e); + } + } else { + // Unregister by deleting the Applications key + try { + execRegistryCommand("delete", + "HKCU\\Software\\Classes\\Applications\\" + executableName, + "/f"); + + if (log != null) { + log.info("Unregistered file extensions"); + } + } catch (final Exception e) { + throw new IOException("Failed to unregister file extensions", e); + } + } + } + + @Override + public SchemeInstaller getSchemeInstaller() { + return new WindowsSchemeInstaller(log); + } + + // -- Helper methods -- + + /** + * Collects all URI schemes from registered LinkHandler plugins. + */ + private Set collectSchemes() { + final Set schemes = new HashSet<>(); + if (linkService == null) return schemes; + + for (final LinkHandler handler : linkService.getInstances()) { + final List handlerSchemes = handler.getSchemes(); + if (handlerSchemes != null) { + schemes.addAll(handlerSchemes); + } + } + return schemes; + } + + /** + * Collects all supported file extensions. + *

+ * TODO: This should query file format plugins (e.g., SCIFIO formats, ImageJ I/O plugins). + * For now, returns an empty set. + *

+ */ + private Set collectFileExtensions() { + final Set extensions = new HashSet<>(); + // TODO: Query IOService/SCIFIOService/FormatService for supported extensions + // For now, return empty set + return extensions; + } + + /** + * Extracts the executable file name from the full path. + * For example, "C:\Path\To\fiji.exe" returns "fiji.exe". + */ + private String getExecutableName() { + final String executablePath = System.getProperty("scijava.app.executable"); + if (executablePath == null) return null; + + final int lastSlash = Math.max( + executablePath.lastIndexOf('/'), + executablePath.lastIndexOf('\\') + ); + return lastSlash >= 0 ? executablePath.substring(lastSlash + 1) : executablePath; + } + + /** + * Executes a registry command. + */ + private void execRegistryCommand(final String... args) throws IOException, InterruptedException { + final ProcessBuilder pb = new ProcessBuilder(args); + pb.command().add(0, "reg"); + final Process process = pb.start(); + final int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Registry command failed with exit code " + exitCode); + } + } +} diff --git a/src/main/java/org/scijava/desktop/platform/windows/WindowsSchemeInstaller.java b/src/main/java/org/scijava/desktop/platform/windows/WindowsSchemeInstaller.java new file mode 100644 index 0000000..f88c11c --- /dev/null +++ b/src/main/java/org/scijava/desktop/platform/windows/WindowsSchemeInstaller.java @@ -0,0 +1,266 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.windows; + +import org.scijava.desktop.links.SchemeInstaller; +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/main/java/org/scijava/links/DefaultLinkService.java b/src/main/java/org/scijava/links/DefaultLinkService.java deleted file mode 100644 index b5c8750..0000000 --- a/src/main/java/org/scijava/links/DefaultLinkService.java +++ /dev/null @@ -1,57 +0,0 @@ -/*- - * #%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; - -import org.scijava.event.ContextCreatedEvent; -import org.scijava.event.EventHandler; -import org.scijava.plugin.AbstractHandlerService; -import org.scijava.plugin.Plugin; -import org.scijava.service.Service; - -import java.awt.Desktop; -import java.net.URI; - -/** - * Default implementation of {@link LinkService}. - * - * @author Curtis Rueden - */ -@Plugin(type = Service.class) -public class DefaultLinkService extends AbstractHandlerService implements LinkService { - - @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())); - } - -} diff --git a/src/test/java/org/scijava/links/LinksTest.java b/src/test/java/org/scijava/desktop/links/LinksTest.java similarity index 95% rename from src/test/java/org/scijava/links/LinksTest.java rename to src/test/java/org/scijava/desktop/links/LinksTest.java index 5ffc2f3..027ad00 100644 --- a/src/test/java/org/scijava/links/LinksTest.java +++ b/src/test/java/org/scijava/desktop/links/LinksTest.java @@ -1,8 +1,8 @@ /*- * #%L - * URL scheme handlers for SciJava. + * Desktop integration for SciJava. * %% - * Copyright (C) 2023 - 2025 SciJava developers. + * Copyright (C) 2010 - 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.links; +package org.scijava.desktop.links; import org.junit.Test; diff --git a/src/test/java/org/scijava/desktop/platform/linux/LinuxSchemeInstallerTest.java b/src/test/java/org/scijava/desktop/platform/linux/LinuxSchemeInstallerTest.java new file mode 100644 index 0000000..04bae62 --- /dev/null +++ b/src/test/java/org/scijava/desktop/platform/linux/LinuxSchemeInstallerTest.java @@ -0,0 +1,232 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.linux; + +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(tempDesktopFile); + df.set("Type", "Application"); + df.set("Name", "Test App"); + df.set("Exec", "/usr/bin/test-app %U"); + df.save(); + + // 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/desktop/platform/windows/WindowsSchemeInstallerTest.java b/src/test/java/org/scijava/desktop/platform/windows/WindowsSchemeInstallerTest.java new file mode 100644 index 0000000..028227c --- /dev/null +++ b/src/test/java/org/scijava/desktop/platform/windows/WindowsSchemeInstallerTest.java @@ -0,0 +1,217 @@ +/*- + * #%L + * Desktop integration for SciJava. + * %% + * Copyright (C) 2010 - 2026 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.desktop.platform.windows; + +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)); + } +}