Skip to content

Commit 9b3caeb

Browse files
ctruedenclaude
andcommitted
Begin work on file extension registration
1. Extend DesktopIntegrationProvider Interface Added three new methods to DesktopIntegrationProvider: - boolean isFileExtensionsEnabled() - Check registered file associations - boolean isFileExtensionsToggleable() - Check if platform can toggle - void setFileExtensionsEnabled(boolean) - Toggle file associations 2. macOS Implementation (Read-Only) - Returns true for isFileExtensionsEnabled() (declared in Info.plist) - Returns false for isFileExtensionsToggleable() (immutable bundle) - setFileExtensionsEnabled() is a no-op with documentation 3. Windows Implementation (SupportedTypes Approach) - Register: HKCU\Software\Classes\Applications\<app>.exe\SupportedTypes - Safety: Create/delete our own registry tree (no over-deletion risk) - Extension Collection: Add collectFileExtensions() method stub - Helper Methods: getExecutableName(), execRegistryCommand() - Makes app appear in "Open With" for all registered extensions 4. Linux Implementation (MIME Types + .desktop file) - Standard formats: Use existing MIME types (e.g. image/png) - Custom formats: Create ~/.local/share/mime/packages/<app>.xml for PFFs - Run update-mime-database to register custom MIME types - Add MIME types to .desktop file's MimeType= field - Preserve URI scheme handlers when unregistering file extensions 5. Update OptionsDesktop UI - Added "Enable file type associations" checkbox - Integrated into load/run/validate flow - Automatically grays out if not toggleable on current platform Key Design Decisions: 1. Single checkbox enables/disables ALL file extensions 2. Windows safety: Uses Applications\SupportedTypes (separate tree) 3. Linux custom MIME types: Register formats that lack standard types 4. Extension source: Currently placeholder - will use IOPlugin later Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 272f411 commit 9b3caeb

File tree

5 files changed

+401
-0
lines changed

5 files changed

+401
-0
lines changed

src/main/java/org/scijava/desktop/DesktopIntegrationProvider.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,29 @@ public interface DesktopIntegrationProvider {
8282
*/
8383
void setDesktopIconPresent(final boolean install) throws IOException;
8484

85+
boolean isFileExtensionsEnabled();
86+
87+
boolean isFileExtensionsToggleable();
88+
89+
/**
90+
* Enables or disables file extension associations (e.g., {@code .tif}, {@code .png}).
91+
* <p>
92+
* This operation only works if {@link #isFileExtensionsToggleable()}
93+
* returns true. Otherwise, calling this method may throw
94+
* {@link UnsupportedOperationException}.
95+
* </p>
96+
* <p>
97+
* When enabled, the application will be registered as a handler for all
98+
* supported file extensions. The application appears in "Open With" menus,
99+
* allowing users to choose it for specific file types.
100+
* </p>
101+
*
102+
* @param enable whether to enable or disable file extension associations
103+
* @throws IOException if the operation fails
104+
* @throws UnsupportedOperationException if not supported on this platform
105+
*/
106+
void setFileExtensionsEnabled(final boolean enable) throws IOException;
107+
85108
/**
86109
* Creates a SchemeInstaller for this platform.
87110
*

src/main/java/org/scijava/desktop/options/OptionsDesktop.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,22 @@ public class OptionsDesktop extends OptionsPlugin {
7171
description = "Install application icon in the system menu")
7272
private boolean desktopIconPresent;
7373

74+
@Parameter(label = "Enable file type associations", persist = false, validater = "validateFileExtensions", //
75+
description = "Register supported file extensions with the operating system")
76+
private boolean fileExtensionsEnabled;
77+
7478
@Override
7579
public void load() {
7680
webLinksEnabled = true;
7781
desktopIconPresent = true;
82+
fileExtensionsEnabled = true;
7883
for (final Platform platform : platformService.getTargetPlatforms()) {
7984
if (!(platform instanceof DesktopIntegrationProvider)) continue;
8085
final DesktopIntegrationProvider dip = (DesktopIntegrationProvider) platform;
8186
// If any toggleable platform setting is off, uncheck that box.
8287
if (dip.isDesktopIconToggleable() && !dip.isDesktopIconPresent()) desktopIconPresent = false;
8388
if (dip.isWebLinksToggleable() && !dip.isWebLinksEnabled()) webLinksEnabled = false;
89+
if (dip.isFileExtensionsToggleable() && !dip.isFileExtensionsEnabled()) fileExtensionsEnabled = false;
8490
}
8591
}
8692

@@ -92,6 +98,7 @@ public void run() {
9298
try {
9399
dip.setWebLinksEnabled(webLinksEnabled);
94100
dip.setDesktopIconPresent(desktopIconPresent);
101+
dip.setFileExtensionsEnabled(fileExtensionsEnabled);
95102
}
96103
catch (final IOException e) {
97104
if (log != null) {
@@ -120,6 +127,14 @@ public void validateDesktopIcon() {
120127
"Desktop icon presence");
121128
}
122129

130+
public void validateFileExtensions() {
131+
validateSetting(
132+
DesktopIntegrationProvider::isFileExtensionsToggleable,
133+
DesktopIntegrationProvider::isFileExtensionsEnabled,
134+
fileExtensionsEnabled,
135+
"File extensions setting");
136+
}
137+
123138
// -- Helper methods --
124139

125140
private String name(Platform platform) {

src/main/java/org/scijava/desktop/platform/linux/LinuxPlatform.java

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,18 @@
4242
import org.scijava.plugin.Plugin;
4343

4444
import java.io.IOException;
45+
import java.io.InputStream;
4546
import java.net.URL;
4647
import java.nio.file.Files;
4748
import java.nio.file.Path;
4849
import java.nio.file.Paths;
50+
import java.nio.file.StandardOpenOption;
51+
import java.util.HashMap;
4952
import java.util.HashSet;
53+
import java.util.LinkedHashMap;
5054
import java.util.List;
55+
import java.util.Map;
56+
import java.util.Properties;
5157
import java.util.Set;
5258

5359
/**
@@ -79,6 +85,9 @@ public class LinuxPlatform extends AbstractPlatform
7985
@Parameter(required = false)
8086
private LogService log;
8187

88+
/** Cached MIME type mapping */
89+
private static Map<String, String> extensionToMime = null;
90+
8291
// -- Platform methods --
8392

8493
@Override
@@ -211,6 +220,77 @@ public void setDesktopIconPresent(final boolean install) throws IOException {
211220
}
212221
}
213222

223+
@Override
224+
public boolean isFileExtensionsEnabled() {
225+
try {
226+
final DesktopFile df = getOrCreateDesktopFile();
227+
final Map<String, String> mimeMapping = loadMimeTypeMapping();
228+
229+
// Check if any file extension MIME types are in the .desktop file
230+
for (final String mimeType : mimeMapping.values()) {
231+
if (df.hasMimeType(mimeType)) return true;
232+
}
233+
return false;
234+
} catch (final IOException e) {
235+
if (log != null) {
236+
log.debug("Failed to check file extensions status", e);
237+
}
238+
return false;
239+
}
240+
}
241+
242+
@Override
243+
public boolean isFileExtensionsToggleable() {
244+
return true;
245+
}
246+
247+
@Override
248+
public void setFileExtensionsEnabled(final boolean enable) throws IOException {
249+
final Map<String, String> mimeMapping = loadMimeTypeMapping();
250+
if (mimeMapping.isEmpty()) {
251+
if (log != null) {
252+
log.warn("No file extensions to register");
253+
}
254+
return;
255+
}
256+
257+
if (enable) {
258+
// Register custom MIME types for formats without standard types
259+
registerCustomMimeTypes(mimeMapping);
260+
261+
// Add MIME types to .desktop file
262+
final DesktopFile df = getOrCreateDesktopFile();
263+
for (final String mimeType : mimeMapping.values()) {
264+
df.addMimeType(mimeType);
265+
}
266+
df.save();
267+
268+
if (log != null) {
269+
log.info("Registered " + mimeMapping.size() + " file extension MIME types");
270+
}
271+
} else {
272+
// Remove file extension MIME types from .desktop file
273+
// Keep URI scheme handlers (x-scheme-handler/...)
274+
final DesktopFile df = getOrCreateDesktopFile();
275+
final Set<String> uriSchemes = collectSchemes();
276+
277+
for (final String mimeType : mimeMapping.values()) {
278+
df.removeMimeType(mimeType);
279+
}
280+
281+
// Re-add URI scheme handlers
282+
for (final String scheme : uriSchemes) {
283+
df.addMimeType("x-scheme-handler/" + scheme);
284+
}
285+
286+
df.save();
287+
288+
if (log != null) {
289+
log.info("Unregistered file extension MIME types");
290+
}
291+
}
292+
}
293+
214294
@Override
215295
public SchemeInstaller getSchemeInstaller() {
216296
return new LinuxSchemeInstaller(log);
@@ -335,4 +415,127 @@ private Set<String> collectSchemes() {
335415
}
336416
return schemes;
337417
}
418+
419+
/**
420+
* Loads the file extension to MIME type mapping.
421+
*/
422+
private synchronized Map<String, String> loadMimeTypeMapping() throws IOException {
423+
if (extensionToMime != null) return extensionToMime;
424+
425+
extensionToMime = new LinkedHashMap<>();
426+
427+
// TODO: Query IOService for formats
428+
429+
return extensionToMime;
430+
}
431+
432+
/**
433+
* Registers custom MIME types for formats that don't have standard types.
434+
* Creates ~/.local/share/mime/packages/[appName].xml and runs update-mime-database.
435+
*/
436+
private void registerCustomMimeTypes(final Map<String, String> mimeMapping) throws IOException {
437+
// Separate standard from custom MIME types
438+
final Map<String, String> customTypes = new LinkedHashMap<>();
439+
for (final Map.Entry<String, String> entry : mimeMapping.entrySet()) {
440+
final String mimeType = entry.getValue();
441+
// Custom types use application/x- prefix
442+
if (mimeType.startsWith("application/x-")) {
443+
customTypes.put(entry.getKey(), mimeType);
444+
}
445+
}
446+
447+
if (customTypes.isEmpty()) {
448+
// No custom types to register
449+
return;
450+
}
451+
452+
// Generate MIME types XML
453+
final String appName = System.getProperty("scijava.app.name", "SciJava");
454+
final String mimeXml = generateMimeTypesXml(customTypes, appName);
455+
456+
// Write to ~/.local/share/mime/packages/<app>.xml
457+
final Path mimeDir = Paths.get(System.getProperty("user.home"),
458+
".local/share/mime/packages");
459+
Files.createDirectories(mimeDir);
460+
461+
final Path mimeFile = mimeDir.resolve(sanitizeFileName(appName) + ".xml");
462+
Files.writeString(mimeFile, mimeXml, StandardOpenOption.CREATE,
463+
StandardOpenOption.TRUNCATE_EXISTING);
464+
465+
// Update MIME database
466+
try {
467+
final ProcessBuilder pb = new ProcessBuilder(
468+
"update-mime-database",
469+
Paths.get(System.getProperty("user.home"), ".local/share/mime").toString()
470+
);
471+
final Process process = pb.start();
472+
final int exitCode = process.waitFor();
473+
474+
if (exitCode != 0) {
475+
if (log != null) {
476+
log.warn("update-mime-database exited with code " + exitCode);
477+
}
478+
} else if (log != null) {
479+
log.info("Registered " + customTypes.size() + " custom MIME types");
480+
}
481+
} catch (final Exception e) {
482+
if (log != null) {
483+
log.error("Failed to run update-mime-database", e);
484+
}
485+
}
486+
}
487+
488+
/**
489+
* Generates MIME types XML for custom file formats.
490+
*/
491+
private String generateMimeTypesXml(final Map<String, String> customTypes,
492+
final String appName)
493+
{
494+
final StringBuilder xml = new StringBuilder();
495+
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
496+
xml.append("<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n");
497+
498+
for (final Map.Entry<String, String> entry : customTypes.entrySet()) {
499+
final String extension = entry.getKey();
500+
final String mimeType = entry.getValue();
501+
502+
// Generate human-readable comment from MIME type
503+
final String comment = generateMimeTypeComment(mimeType);
504+
505+
xml.append(" <mime-type type=\"").append(mimeType).append("\">\n");
506+
xml.append(" <comment>").append(comment).append("</comment>\n");
507+
xml.append(" <glob pattern=\"*.").append(extension).append("\"/>\n");
508+
xml.append(" </mime-type>\n");
509+
}
510+
511+
xml.append("</mime-info>\n");
512+
return xml.toString();
513+
}
514+
515+
/**
516+
* Generates a human-readable comment from a MIME type.
517+
* For example, "application/x-zeiss-czi" becomes "Zeiss CZI File".
518+
*/
519+
private String generateMimeTypeComment(final String mimeType) {
520+
// Extract the format part (e.g., "zeiss-czi" from "application/x-zeiss-czi")
521+
final String format = mimeType.substring(mimeType.lastIndexOf('/') + 1);
522+
523+
// Remove "x-" prefix if present
524+
final String cleanFormat = format.startsWith("x-") ?
525+
format.substring(2) : format;
526+
527+
// Convert to title case
528+
final String[] parts = cleanFormat.split("-");
529+
final StringBuilder comment = new StringBuilder();
530+
for (final String part : parts) {
531+
if (comment.length() > 0) comment.append(' ');
532+
comment.append(Character.toUpperCase(part.charAt(0)));
533+
if (part.length() > 1) {
534+
comment.append(part.substring(1));
535+
}
536+
}
537+
comment.append(" File");
538+
539+
return comment.toString();
540+
}
338541
}

src/main/java/org/scijava/desktop/platform/macos/MacOSPlatform.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,24 @@ public void setDesktopIconPresent(final boolean install) {
159159
// Desktop icon installation is not supported on macOS (use Dock pinning instead).
160160
}
161161

162+
@Override
163+
public boolean isFileExtensionsEnabled() {
164+
// File extensions are declared in Info.plist, which is immutable.
165+
return true;
166+
}
167+
168+
@Override
169+
public boolean isFileExtensionsToggleable() {
170+
// File extensions are declared in Info.plist, which is immutable.
171+
return false;
172+
}
173+
174+
@Override
175+
public void setFileExtensionsEnabled(final boolean enable) {
176+
// Note: Operation has no effect here.
177+
// File extension registration is immutable on macOS (configured in .app bundle).
178+
}
179+
162180
@Override
163181
public SchemeInstaller getSchemeInstaller() {
164182
// macOS uses Info.plist for URI scheme registration (build-time only)

0 commit comments

Comments
 (0)