src/jdk.incubator.jpackage/linux/classes/jdk/incubator/jpackage/internal/DesktopIntegration.java
branchJDK-8200758-branch
changeset 58994 b09ba68c6a19
parent 58885 d1602ae35212
equal deleted inserted replaced
58993:b5e1baa9d2c3 58994:b09ba68c6a19
       
     1 /*
       
     2  * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
       
     3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
       
     4  *
       
     5  * This code is free software; you can redistribute it and/or modify it
       
     6  * under the terms of the GNU General Public License version 2 only, as
       
     7  * published by the Free Software Foundation.  Oracle designates this
       
     8  * particular file as subject to the "Classpath" exception as provided
       
     9  * by Oracle in the LICENSE file that accompanied this code.
       
    10  *
       
    11  * This code is distributed in the hope that it will be useful, but WITHOUT
       
    12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
       
    13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
       
    14  * version 2 for more details (a copy is included in the LICENSE file that
       
    15  * accompanied this code).
       
    16  *
       
    17  * You should have received a copy of the GNU General Public License version
       
    18  * 2 along with this work; if not, write to the Free Software Foundation,
       
    19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
       
    20  *
       
    21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
       
    22  * or visit www.oracle.com if you need additional information or have any
       
    23  * questions.
       
    24  */
       
    25 package jdk.incubator.jpackage.internal;
       
    26 
       
    27 import java.awt.image.BufferedImage;
       
    28 import java.io.*;
       
    29 import java.nio.file.Files;
       
    30 import java.nio.file.Path;
       
    31 import java.util.*;
       
    32 import java.util.stream.Collectors;
       
    33 import java.util.stream.Stream;
       
    34 import javax.imageio.ImageIO;
       
    35 import javax.xml.stream.XMLStreamException;
       
    36 import javax.xml.stream.XMLStreamWriter;
       
    37 import static jdk.incubator.jpackage.internal.LinuxAppBundler.ICON_PNG;
       
    38 import static jdk.incubator.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON;
       
    39 import static jdk.incubator.jpackage.internal.OverridableResource.createResource;
       
    40 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*;
       
    41 
       
    42 /**
       
    43  * Helper to create files for desktop integration.
       
    44  */
       
    45 final class DesktopIntegration {
       
    46 
       
    47     static final String DESKTOP_COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL";
       
    48     static final String DESKTOP_COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL";
       
    49     static final String UTILITY_SCRIPTS = "UTILITY_SCRIPTS";
       
    50 
       
    51     DesktopIntegration(PlatformPackage thePackage,
       
    52             Map<String, ? super Object> params) {
       
    53 
       
    54         associations = FileAssociation.fetchFrom(params).stream()
       
    55                 .filter(fa -> !fa.mimeTypes.isEmpty())
       
    56                 .map(LinuxFileAssociation::new)
       
    57                 .collect(Collectors.toUnmodifiableList());
       
    58 
       
    59         launchers = ADD_LAUNCHERS.fetchFrom(params);
       
    60 
       
    61         this.thePackage = thePackage;
       
    62 
       
    63         final File customIconFile = ICON_PNG.fetchFrom(params);
       
    64 
       
    65         iconResource = createResource(DEFAULT_ICON, params)
       
    66                 .setCategory(I18N.getString("resource.menu-icon"))
       
    67                 .setExternal(customIconFile);
       
    68         desktopFileResource = createResource("template.desktop", params)
       
    69                 .setCategory(I18N.getString("resource.menu-shortcut-descriptor"))
       
    70                 .setPublicName(APP_NAME.fetchFrom(params) + ".desktop");
       
    71 
       
    72         // XDG recommends to use vendor prefix in desktop file names as xdg
       
    73         // commands copy files to system directories.
       
    74         // Package name should be a good prefix.
       
    75         final String desktopFileName = String.format("%s-%s.desktop",
       
    76                     thePackage.name(), APP_NAME.fetchFrom(params));
       
    77         final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
       
    78                     thePackage.name(), APP_NAME.fetchFrom(params));
       
    79 
       
    80         mimeInfoFile = new DesktopFile(mimeInfoFileName);
       
    81 
       
    82         if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) {
       
    83             //
       
    84             // Create primary .desktop file if one of conditions is met:
       
    85             // - there are file associations configured
       
    86             // - user explicitely requested to create a shortcut
       
    87             // - custom icon specified
       
    88             //
       
    89             desktopFile = new DesktopFile(desktopFileName);
       
    90             iconFile = new DesktopFile(APP_NAME.fetchFrom(params)
       
    91                     + IOUtils.getSuffix(Path.of(DEFAULT_ICON)));
       
    92         } else {
       
    93             desktopFile = null;
       
    94             iconFile = null;
       
    95         }
       
    96 
       
    97         desktopFileData = Collections.unmodifiableMap(
       
    98                 createDataForDesktopFile(params));
       
    99 
       
   100         nestedIntegrations = launchers.stream().map(
       
   101                 launcherParams -> new DesktopIntegration(thePackage,
       
   102                         launcherParams)).collect(Collectors.toList());
       
   103     }
       
   104 
       
   105     List<String> requiredPackages() {
       
   106         return Stream.of(List.of(this), nestedIntegrations).flatMap(
       
   107                 List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
       
   108                 List::stream).distinct().collect(Collectors.toList());
       
   109     }
       
   110 
       
   111     Map<String, String> create() throws IOException {
       
   112         associations.forEach(assoc -> assoc.data.verify());
       
   113 
       
   114         if (iconFile != null) {
       
   115             // Create application icon file.
       
   116             iconResource.saveToFile(iconFile.srcPath());
       
   117         }
       
   118 
       
   119         Map<String, String> data = new HashMap<>(desktopFileData);
       
   120 
       
   121         final ShellCommands shellCommands;
       
   122         if (desktopFile != null) {
       
   123             // Create application desktop description file.
       
   124             createDesktopFile(data);
       
   125 
       
   126             // Shell commands will be created only if desktop file
       
   127             // should be installed.
       
   128             shellCommands = new ShellCommands();
       
   129         } else {
       
   130             shellCommands = null;
       
   131         }
       
   132 
       
   133         if (!associations.isEmpty()) {
       
   134             // Create XML file with mime types corresponding to file associations.
       
   135             createFileAssociationsMimeInfoFile();
       
   136 
       
   137             shellCommands.setFileAssociations();
       
   138 
       
   139             // Create icon files corresponding to file associations
       
   140             addFileAssociationIconFiles(shellCommands);
       
   141         }
       
   142 
       
   143         // Create shell commands to install/uninstall integration with desktop of the app.
       
   144         if (shellCommands != null) {
       
   145             shellCommands.applyTo(data);
       
   146         }
       
   147 
       
   148         boolean needCleanupScripts = !associations.isEmpty();
       
   149 
       
   150         // Take care of additional launchers if there are any.
       
   151         // Process every additional launcher as the main application launcher.
       
   152         // Collect shell commands to install/uninstall integration with desktop
       
   153         // of the additional launchers and append them to the corresponding
       
   154         // commands of the main launcher.
       
   155         List<String> installShellCmds = new ArrayList<>(Arrays.asList(
       
   156                 data.get(DESKTOP_COMMANDS_INSTALL)));
       
   157         List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
       
   158                 data.get(DESKTOP_COMMANDS_UNINSTALL)));
       
   159         for (var integration: nestedIntegrations) {
       
   160             if (!integration.associations.isEmpty()) {
       
   161                 needCleanupScripts = true;
       
   162             }
       
   163 
       
   164             Map<String, String> launcherData = integration.create();
       
   165 
       
   166             installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL));
       
   167             uninstallShellCmds.add(launcherData.get(
       
   168                     DESKTOP_COMMANDS_UNINSTALL));
       
   169         }
       
   170 
       
   171         data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(
       
   172                 installShellCmds));
       
   173         data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(
       
   174                 uninstallShellCmds));
       
   175 
       
   176         if (needCleanupScripts) {
       
   177             // Pull in utils.sh scrips library.
       
   178             try (InputStream is = OverridableResource.readDefault("utils.sh");
       
   179                     InputStreamReader isr = new InputStreamReader(is);
       
   180                     BufferedReader reader = new BufferedReader(isr)) {
       
   181                 data.put(UTILITY_SCRIPTS, reader.lines().collect(
       
   182                         Collectors.joining(System.lineSeparator())));
       
   183             }
       
   184         } else {
       
   185             data.put(UTILITY_SCRIPTS, "");
       
   186         }
       
   187 
       
   188         return data;
       
   189     }
       
   190 
       
   191     private List<String> requiredPackagesSelf() {
       
   192         if (desktopFile != null) {
       
   193             return List.of("xdg-utils");
       
   194         }
       
   195         return Collections.emptyList();
       
   196     }
       
   197 
       
   198     private Map<String, String> createDataForDesktopFile(
       
   199             Map<String, ? super Object> params) {
       
   200         Map<String, String> data = new HashMap<>();
       
   201         data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params));
       
   202         data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
       
   203         data.put("APPLICATION_ICON",
       
   204                 iconFile != null ? iconFile.installPath().toString() : null);
       
   205         data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params));
       
   206         data.put("APPLICATION_LAUNCHER",
       
   207                 thePackage.installedApplicationLayout().launchersDirectory().resolve(
       
   208                         LinuxAppImageBuilder.getLauncherName(params)).toString());
       
   209 
       
   210         return data;
       
   211     }
       
   212 
       
   213     /**
       
   214      * Shell commands to integrate something with desktop.
       
   215      */
       
   216     private class ShellCommands {
       
   217 
       
   218         ShellCommands() {
       
   219             registerIconCmds = new ArrayList<>();
       
   220             unregisterIconCmds = new ArrayList<>();
       
   221 
       
   222             registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
       
   223                     "install", desktopFile.installPath().toString());
       
   224             unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
       
   225                     "uninstall", desktopFile.installPath().toString());
       
   226         }
       
   227 
       
   228         void setFileAssociations() {
       
   229             registerFileAssociationsCmd = String.join(" ", "xdg-mime",
       
   230                     "install",
       
   231                     mimeInfoFile.installPath().toString());
       
   232             unregisterFileAssociationsCmd = String.join(" ", "xdg-mime",
       
   233                     "uninstall", mimeInfoFile.installPath().toString());
       
   234 
       
   235             //
       
   236             // Add manual cleanup of system files to get rid of
       
   237             // the default mime type handlers.
       
   238             //
       
   239             // Even after mime type is unregisterd with `xdg-mime uninstall`
       
   240             // command and desktop file deleted with `xdg-desktop-menu uninstall`
       
   241             // command, records in
       
   242             // `/usr/share/applications/defaults.list` (Ubuntu 16) or
       
   243             // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
       
   244             // files remain referencing deleted mime time and deleted
       
   245             // desktop file which makes `xdg-mime query default` output name
       
   246             // of non-existing desktop file.
       
   247             //
       
   248             String cleanUpCommand = String.join(" ",
       
   249                     "uninstall_default_mime_handler",
       
   250                     desktopFile.installPath().getFileName().toString(),
       
   251                     String.join(" ", getMimeTypeNamesFromFileAssociations()));
       
   252 
       
   253             unregisterFileAssociationsCmd = stringifyShellCommands(
       
   254                     unregisterFileAssociationsCmd, cleanUpCommand);
       
   255         }
       
   256 
       
   257         void addIcon(String mimeType, Path iconFile) {
       
   258             addIcon(mimeType, iconFile, getSquareSizeOfImage(iconFile.toFile()));
       
   259         }
       
   260 
       
   261         void addIcon(String mimeType, Path iconFile, int imgSize) {
       
   262             imgSize = normalizeIconSize(imgSize);
       
   263             final String dashMime = mimeType.replace('/', '-');
       
   264             registerIconCmds.add(String.join(" ", "xdg-icon-resource",
       
   265                     "install", "--context", "mimetypes", "--size",
       
   266                     Integer.toString(imgSize), iconFile.toString(), dashMime));
       
   267             unregisterIconCmds.add(String.join(" ", "xdg-icon-resource",
       
   268                     "uninstall", dashMime, "--size", Integer.toString(imgSize)));
       
   269         }
       
   270 
       
   271         void applyTo(Map<String, String> data) {
       
   272             List<String> cmds = new ArrayList<>();
       
   273 
       
   274             cmds.add(registerDesktopFileCmd);
       
   275             cmds.add(registerFileAssociationsCmd);
       
   276             cmds.addAll(registerIconCmds);
       
   277             data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds));
       
   278 
       
   279             cmds.clear();
       
   280             cmds.add(unregisterDesktopFileCmd);
       
   281             cmds.add(unregisterFileAssociationsCmd);
       
   282             cmds.addAll(unregisterIconCmds);
       
   283             data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
       
   284         }
       
   285 
       
   286         private String registerDesktopFileCmd;
       
   287         private String unregisterDesktopFileCmd;
       
   288 
       
   289         private String registerFileAssociationsCmd;
       
   290         private String unregisterFileAssociationsCmd;
       
   291 
       
   292         private List<String> registerIconCmds;
       
   293         private List<String> unregisterIconCmds;
       
   294     }
       
   295 
       
   296     /**
       
   297      * Desktop integration file. xml, icon, etc.
       
   298      * Resides somewhere in application installation tree.
       
   299      * Has two paths:
       
   300      *  - path where it should be placed at package build time;
       
   301      *  - path where it should be installed by package manager;
       
   302      */
       
   303     private class DesktopFile {
       
   304 
       
   305         DesktopFile(String fileName) {
       
   306             installPath = thePackage
       
   307                     .installedApplicationLayout()
       
   308                     .destktopIntegrationDirectory().resolve(fileName);
       
   309             srcPath = thePackage
       
   310                     .sourceApplicationLayout()
       
   311                     .destktopIntegrationDirectory().resolve(fileName);
       
   312         }
       
   313 
       
   314         private final Path installPath;
       
   315         private final Path srcPath;
       
   316 
       
   317         Path installPath() {
       
   318             return installPath;
       
   319         }
       
   320 
       
   321         Path srcPath() {
       
   322             return srcPath;
       
   323         }
       
   324     }
       
   325 
       
   326     private void appendFileAssociation(XMLStreamWriter xml,
       
   327             FileAssociation assoc) throws XMLStreamException {
       
   328 
       
   329         for (var mimeType : assoc.mimeTypes) {
       
   330             xml.writeStartElement("mime-type");
       
   331             xml.writeAttribute("type", mimeType);
       
   332 
       
   333             final String description = assoc.description;
       
   334             if (description != null && !description.isEmpty()) {
       
   335                 xml.writeStartElement("comment");
       
   336                 xml.writeCharacters(description);
       
   337                 xml.writeEndElement();
       
   338             }
       
   339 
       
   340             for (String ext : assoc.extensions) {
       
   341                 xml.writeStartElement("glob");
       
   342                 xml.writeAttribute("pattern", "*." + ext);
       
   343                 xml.writeEndElement();
       
   344             }
       
   345 
       
   346             xml.writeEndElement();
       
   347         }
       
   348     }
       
   349 
       
   350     private void createFileAssociationsMimeInfoFile() throws IOException {
       
   351         IOUtils.createXml(mimeInfoFile.srcPath(), xml -> {
       
   352             xml.writeStartElement("mime-info");
       
   353             xml.writeDefaultNamespace(
       
   354                     "http://www.freedesktop.org/standards/shared-mime-info");
       
   355 
       
   356             for (var assoc : associations) {
       
   357                 appendFileAssociation(xml, assoc.data);
       
   358             }
       
   359 
       
   360             xml.writeEndElement();
       
   361         });
       
   362     }
       
   363 
       
   364     private void addFileAssociationIconFiles(ShellCommands shellCommands)
       
   365             throws IOException {
       
   366         Set<String> processedMimeTypes = new HashSet<>();
       
   367         for (var assoc : associations) {
       
   368             if (assoc.iconSize <= 0) {
       
   369                 // No icon.
       
   370                 continue;
       
   371             }
       
   372 
       
   373             for (var mimeType : assoc.data.mimeTypes) {
       
   374                 if (processedMimeTypes.contains(mimeType)) {
       
   375                     continue;
       
   376                 }
       
   377 
       
   378                 processedMimeTypes.add(mimeType);
       
   379 
       
   380                 // Create icon name for mime type from mime type.
       
   381                 DesktopFile faIconFile = new DesktopFile(mimeType.replace(
       
   382                         File.separatorChar, '-') + IOUtils.getSuffix(
       
   383                                 assoc.data.iconPath));
       
   384 
       
   385                 IOUtils.copyFile(assoc.data.iconPath.toFile(),
       
   386                         faIconFile.srcPath().toFile());
       
   387 
       
   388                 shellCommands.addIcon(mimeType, faIconFile.installPath(),
       
   389                         assoc.iconSize);
       
   390             }
       
   391         }
       
   392     }
       
   393 
       
   394     private void createDesktopFile(Map<String, String> data) throws IOException {
       
   395         List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
       
   396         data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
       
   397 
       
   398         // prepare desktop shortcut
       
   399         desktopFileResource
       
   400                 .setSubstitutionData(data)
       
   401                 .saveToFile(desktopFile.srcPath());
       
   402     }
       
   403 
       
   404     private List<String> getMimeTypeNamesFromFileAssociations() {
       
   405         return associations.stream()
       
   406                 .map(fa -> fa.data.mimeTypes)
       
   407                 .flatMap(List::stream)
       
   408                 .collect(Collectors.toUnmodifiableList());
       
   409     }
       
   410 
       
   411     private static int getSquareSizeOfImage(File f) {
       
   412         try {
       
   413             BufferedImage bi = ImageIO.read(f);
       
   414             return Math.max(bi.getWidth(), bi.getHeight());
       
   415         } catch (IOException e) {
       
   416             Log.verbose(e);
       
   417         }
       
   418         return 0;
       
   419     }
       
   420 
       
   421     private static int normalizeIconSize(int iconSize) {
       
   422         // If register icon with "uncommon" size, it will be ignored.
       
   423         // So find the best matching "common" size.
       
   424         List<Integer> commonIconSizes = List.of(16, 22, 32, 48, 64, 128);
       
   425 
       
   426         int idx = Collections.binarySearch(commonIconSizes, iconSize);
       
   427         if (idx < 0) {
       
   428             // Given icon size is greater than the largest common icon size.
       
   429             return commonIconSizes.get(commonIconSizes.size() - 1);
       
   430         }
       
   431 
       
   432         if (idx == 0) {
       
   433             // Given icon size is less or equal than the smallest common icon size.
       
   434             return commonIconSizes.get(idx);
       
   435         }
       
   436 
       
   437         int commonIconSize = commonIconSizes.get(idx);
       
   438         if (iconSize < commonIconSize) {
       
   439             // It is better to scale down original icon than to scale it up for
       
   440             // better visual quality.
       
   441             commonIconSize = commonIconSizes.get(idx - 1);
       
   442         }
       
   443 
       
   444         return commonIconSize;
       
   445     }
       
   446 
       
   447     private static String stringifyShellCommands(String... commands) {
       
   448         return stringifyShellCommands(Arrays.asList(commands));
       
   449     }
       
   450 
       
   451     private static String stringifyShellCommands(List<String> commands) {
       
   452         return String.join(System.lineSeparator(), commands.stream().filter(
       
   453                 s -> s != null && !s.isEmpty()).collect(Collectors.toList()));
       
   454     }
       
   455 
       
   456     private static class LinuxFileAssociation {
       
   457         LinuxFileAssociation(FileAssociation fa) {
       
   458             this.data = fa;
       
   459             if (fa.iconPath != null && Files.isReadable(fa.iconPath)) {
       
   460                 iconSize = getSquareSizeOfImage(fa.iconPath.toFile());
       
   461             } else {
       
   462                 iconSize = -1;
       
   463             }
       
   464         }
       
   465 
       
   466         final FileAssociation data;
       
   467         final int iconSize;
       
   468     }
       
   469 
       
   470     private final PlatformPackage thePackage;
       
   471 
       
   472     private final List<LinuxFileAssociation> associations;
       
   473 
       
   474     private final List<Map<String, ? super Object>> launchers;
       
   475 
       
   476     private final OverridableResource iconResource;
       
   477     private final OverridableResource desktopFileResource;
       
   478 
       
   479     private final DesktopFile mimeInfoFile;
       
   480     private final DesktopFile desktopFile;
       
   481     private final DesktopFile iconFile;
       
   482 
       
   483     private final List<DesktopIntegration> nestedIntegrations;
       
   484 
       
   485     private final Map<String, String> desktopFileData;
       
   486 
       
   487     private static final BundlerParamInfo<String> MENU_GROUP =
       
   488         new StandardBundlerParam<>(
       
   489                 Arguments.CLIOptions.LINUX_MENU_GROUP.getId(),
       
   490                 String.class,
       
   491                 params -> I18N.getString("param.menu-group.default"),
       
   492                 (s, p) -> s
       
   493         );
       
   494 
       
   495     private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
       
   496         new StandardBundlerParam<>(
       
   497                 Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(),
       
   498                 Boolean.class,
       
   499                 params -> false,
       
   500                 (s, p) -> (s == null || "null".equalsIgnoreCase(s))
       
   501                         ? false : Boolean.valueOf(s)
       
   502         );
       
   503 }