src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java
branchJDK-8200758-branch
changeset 58647 2c43b89b1679
child 58696 61c44899b4eb
equal deleted inserted replaced
58608:a561014c28d0 58647:2c43b89b1679
       
     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.jpackage.internal;
       
    26 
       
    27 import java.awt.image.BufferedImage;
       
    28 import java.io.*;
       
    29 import java.nio.file.Path;
       
    30 import java.util.*;
       
    31 import java.util.stream.Collectors;
       
    32 import java.util.stream.Stream;
       
    33 import javax.imageio.ImageIO;
       
    34 import javax.xml.stream.XMLOutputFactory;
       
    35 import javax.xml.stream.XMLStreamException;
       
    36 import javax.xml.stream.XMLStreamWriter;
       
    37 import static jdk.jpackage.internal.LinuxAppBundler.ICON_PNG;
       
    38 import static jdk.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON;
       
    39 import static jdk.jpackage.internal.OverridableResource.createResource;
       
    40 import static jdk.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 = FILE_ASSOCIATIONS.fetchFrom(params).stream().filter(
       
    55                 a -> {
       
    56                     if (a == null) {
       
    57                         return false;
       
    58                     }
       
    59                     List<String> mimes = FA_CONTENT_TYPE.fetchFrom(a);
       
    60                     return (mimes != null && !mimes.isEmpty());
       
    61                 }).collect(Collectors.toUnmodifiableList());
       
    62 
       
    63         launchers = ADD_LAUNCHERS.fetchFrom(params);
       
    64 
       
    65         this.thePackage = thePackage;
       
    66 
       
    67         final File customIconFile = ICON_PNG.fetchFrom(params);
       
    68 
       
    69         iconResource = createResource(DEFAULT_ICON, params)
       
    70                 .setCategory(I18N.getString("resource.menu-icon"))
       
    71                 .setExternal(customIconFile);
       
    72         desktopFileResource = createResource("template.desktop", params)
       
    73                 .setCategory(I18N.getString("resource.menu-shortcut-descriptor"));
       
    74 
       
    75         // XDG recommends to use vendor prefix in desktop file names as xdg
       
    76         // commands copy files to system directories.
       
    77         // Package name should be a good prefix.
       
    78         final String desktopFileName = String.format("%s-%s.desktop",
       
    79                     thePackage.name(), APP_NAME.fetchFrom(params));
       
    80         final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
       
    81                     thePackage.name(), APP_NAME.fetchFrom(params));
       
    82 
       
    83         mimeInfoFile = new DesktopFile(mimeInfoFileName);
       
    84 
       
    85         if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) {
       
    86             //
       
    87             // Create primary .desktop file if one of conditions is met:
       
    88             // - there are file associations configured
       
    89             // - user explicitely requested to create a shortcut
       
    90             // - custom icon specified
       
    91             //
       
    92             desktopFile = new DesktopFile(desktopFileName);
       
    93             iconFile = new DesktopFile(String.format("%s.png",
       
    94                     APP_NAME.fetchFrom(params)));
       
    95         } else {
       
    96             desktopFile = null;
       
    97             iconFile = null;
       
    98         }
       
    99 
       
   100         desktopFileData = Collections.unmodifiableMap(
       
   101                 createDataForDesktopFile(params));
       
   102 
       
   103         nestedIntegrations = launchers.stream().map(
       
   104                 launcherParams -> new DesktopIntegration(thePackage,
       
   105                         launcherParams)).collect(Collectors.toList());
       
   106     }
       
   107 
       
   108     List<String> requiredPackages() {
       
   109         return Stream.of(List.of(this), nestedIntegrations).flatMap(
       
   110                 List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
       
   111                 List::stream).distinct().collect(Collectors.toList());
       
   112     }
       
   113 
       
   114     Map<String, String> create() throws IOException {
       
   115         if (iconFile != null) {
       
   116             // Create application icon file.
       
   117             iconResource.saveToFile(iconFile.srcPath());
       
   118         }
       
   119 
       
   120         Map<String, String> data = new HashMap<>(desktopFileData);
       
   121 
       
   122         final ShellCommands shellCommands;
       
   123         if (desktopFile != null) {
       
   124             // Create application desktop description file.
       
   125             createDesktopFile(data);
       
   126 
       
   127             // Shell commands will be created only if desktop file
       
   128             // should be installed.
       
   129             shellCommands = new ShellCommands();
       
   130         } else {
       
   131             shellCommands = null;
       
   132         }
       
   133 
       
   134         if (!associations.isEmpty()) {
       
   135             // Create XML file with mime types corresponding to file associations.
       
   136             createFileAssociationsMimeInfoFile();
       
   137 
       
   138             shellCommands.setFileAssociations();
       
   139 
       
   140             // Create icon files corresponding to file associations
       
   141             Map<String, Path> mimeTypeWithIconFile = createFileAssociationIconFiles();
       
   142             mimeTypeWithIconFile.forEach((k, v) -> {
       
   143                 shellCommands.addIcon(k, v);
       
   144             });
       
   145         }
       
   146 
       
   147         // Create shell commands to install/uninstall integration with desktop of the app.
       
   148         if (shellCommands != null) {
       
   149             shellCommands.applyTo(data);
       
   150         }
       
   151 
       
   152         boolean needCleanupScripts = !associations.isEmpty();
       
   153 
       
   154         // Take care of additional launchers if there are any.
       
   155         // Process every additional launcher as the main application launcher.
       
   156         // Collect shell commands to install/uninstall integration with desktop
       
   157         // of the additional launchers and append them to the corresponding
       
   158         // commands of the main launcher.
       
   159         List<String> installShellCmds = new ArrayList<>(Arrays.asList(
       
   160                 data.get(DESKTOP_COMMANDS_INSTALL)));
       
   161         List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
       
   162                 data.get(DESKTOP_COMMANDS_UNINSTALL)));
       
   163         for (var integration: nestedIntegrations) {
       
   164             if (!integration.associations.isEmpty()) {
       
   165                 needCleanupScripts = true;
       
   166             }
       
   167 
       
   168             Map<String, String> launcherData = integration.create();
       
   169 
       
   170             installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL));
       
   171             uninstallShellCmds.add(launcherData.get(
       
   172                     DESKTOP_COMMANDS_UNINSTALL));
       
   173         }
       
   174 
       
   175         data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(
       
   176                 installShellCmds));
       
   177         data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(
       
   178                 uninstallShellCmds));
       
   179 
       
   180         if (needCleanupScripts) {
       
   181             // Pull in utils.sh scrips library.
       
   182             try (InputStream is = OverridableResource.readDefault("utils.sh");
       
   183                     InputStreamReader isr = new InputStreamReader(is);
       
   184                     BufferedReader reader = new BufferedReader(isr)) {
       
   185                 data.put(UTILITY_SCRIPTS, reader.lines().collect(
       
   186                         Collectors.joining(System.lineSeparator())));
       
   187             }
       
   188         } else {
       
   189             data.put(UTILITY_SCRIPTS, "");
       
   190         }
       
   191 
       
   192         return data;
       
   193     }
       
   194 
       
   195     private List<String> requiredPackagesSelf() {
       
   196         if (desktopFile != null) {
       
   197             return List.of("xdg-utils");
       
   198         }
       
   199         return Collections.emptyList();
       
   200     }
       
   201 
       
   202     private Map<String, String> createDataForDesktopFile(
       
   203             Map<String, ? super Object> params) {
       
   204         Map<String, String> data = new HashMap<>();
       
   205         data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params));
       
   206         data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
       
   207         data.put("APPLICATION_ICON",
       
   208                 iconFile != null ? iconFile.installPath().toString() : null);
       
   209         data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params));
       
   210         data.put("APPLICATION_LAUNCHER",
       
   211                 thePackage.installedApplicationLayout().launchersDirectory().resolve(
       
   212                         LinuxAppImageBuilder.getLauncherName(params)).toString());
       
   213 
       
   214         return data;
       
   215     }
       
   216 
       
   217     /**
       
   218      * Shell commands to integrate something with desktop.
       
   219      */
       
   220     private class ShellCommands {
       
   221 
       
   222         ShellCommands() {
       
   223             registerIconCmds = new ArrayList<>();
       
   224             unregisterIconCmds = new ArrayList<>();
       
   225 
       
   226             registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
       
   227                     "install", desktopFile.installPath().toString());
       
   228             unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
       
   229                     "uninstall", desktopFile.installPath().toString());
       
   230         }
       
   231 
       
   232         void setFileAssociations() {
       
   233             registerFileAssociationsCmd = String.join(" ", "xdg-mime",
       
   234                     "install",
       
   235                     mimeInfoFile.installPath().toString());
       
   236             unregisterFileAssociationsCmd = String.join(" ", "xdg-mime",
       
   237                     "uninstall", mimeInfoFile.installPath().toString());
       
   238 
       
   239             //
       
   240             // Add manual cleanup of system files to get rid of
       
   241             // the default mime type handlers.
       
   242             //
       
   243             // Even after mime type is unregisterd with `xdg-mime uninstall`
       
   244             // command and desktop file deleted with `xdg-desktop-menu uninstall`
       
   245             // command, records in
       
   246             // `/usr/share/applications/defaults.list` (Ubuntu 16) or
       
   247             // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
       
   248             // files remain referencing deleted mime time and deleted
       
   249             // desktop file which makes `xdg-mime query default` output name
       
   250             // of non-existing desktop file.
       
   251             //
       
   252             String cleanUpCommand = String.join(" ",
       
   253                     "uninstall_default_mime_handler",
       
   254                     desktopFile.installPath().getFileName().toString(),
       
   255                     String.join(" ", getMimeTypeNamesFromFileAssociations()));
       
   256 
       
   257             unregisterFileAssociationsCmd = stringifyShellCommands(
       
   258                     unregisterFileAssociationsCmd, cleanUpCommand);
       
   259         }
       
   260 
       
   261         void addIcon(String mimeType, Path iconFile) {
       
   262             final int imgSize = getSquareSizeOfImage(iconFile.toFile());
       
   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));
       
   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             Map<String, ? super Object> assoc) throws XMLStreamException {
       
   328 
       
   329         xml.writeStartElement("mime-type");
       
   330         final String thisMime = FA_CONTENT_TYPE.fetchFrom(assoc).get(0);
       
   331         xml.writeAttribute("type", thisMime);
       
   332 
       
   333         final String description = FA_DESCRIPTION.fetchFrom(assoc);
       
   334         if (description != null && !description.isEmpty()) {
       
   335             xml.writeStartElement("comment");
       
   336             xml.writeCharacters(description);
       
   337             xml.writeEndElement();
       
   338         }
       
   339 
       
   340         final List<String> extensions = FA_EXTENSIONS.fetchFrom(assoc);
       
   341         if (extensions == null) {
       
   342             Log.error(I18N.getString(
       
   343                     "message.creating-association-with-null-extension"));
       
   344         } else {
       
   345             for (String ext : extensions) {
       
   346                 xml.writeStartElement("glob");
       
   347                 xml.writeAttribute("pattern", "*." + ext);
       
   348                 xml.writeEndElement();
       
   349             }
       
   350         }
       
   351 
       
   352         xml.writeEndElement();
       
   353     }
       
   354 
       
   355     private void createFileAssociationsMimeInfoFile() throws IOException {
       
   356         XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance();
       
   357 
       
   358         try (Writer w = new BufferedWriter(new FileWriter(
       
   359                 mimeInfoFile.srcPath().toFile()))) {
       
   360             XMLStreamWriter xml = xmlFactory.createXMLStreamWriter(w);
       
   361 
       
   362             xml.writeStartDocument();
       
   363             xml.writeStartElement("mime-info");
       
   364             xml.writeNamespace("xmlns",
       
   365                     "http://www.freedesktop.org/standards/shared-mime-info");
       
   366 
       
   367             for (var assoc : associations) {
       
   368                 appendFileAssociation(xml, assoc);
       
   369             }
       
   370 
       
   371             xml.writeEndElement();
       
   372             xml.writeEndDocument();
       
   373             xml.flush();
       
   374             xml.close();
       
   375 
       
   376         } catch (XMLStreamException ex) {
       
   377             Log.verbose(ex);
       
   378             throw new IOException(ex);
       
   379         }
       
   380     }
       
   381 
       
   382     private Map<String, Path> createFileAssociationIconFiles() throws
       
   383             IOException {
       
   384         Map<String, Path> mimeTypeWithIconFile = new HashMap<>();
       
   385         for (var assoc : associations) {
       
   386             File customFaIcon = FA_ICON.fetchFrom(assoc);
       
   387             if (customFaIcon == null || !customFaIcon.exists() || getSquareSizeOfImage(
       
   388                     customFaIcon) == 0) {
       
   389                 continue;
       
   390             }
       
   391 
       
   392             String fname = iconFile.srcPath().getFileName().toString();
       
   393             if (fname.indexOf(".") > 0) {
       
   394                 fname = fname.substring(0, fname.lastIndexOf("."));
       
   395             }
       
   396 
       
   397             DesktopFile faIconFile = new DesktopFile(
       
   398                     fname + "_fa_" + customFaIcon.getName());
       
   399 
       
   400             IOUtils.copyFile(customFaIcon, faIconFile.srcPath().toFile());
       
   401 
       
   402             mimeTypeWithIconFile.put(FA_CONTENT_TYPE.fetchFrom(assoc).get(0),
       
   403                     faIconFile.installPath());
       
   404         }
       
   405         return mimeTypeWithIconFile;
       
   406     }
       
   407 
       
   408     private void createDesktopFile(Map<String, String> data) throws IOException {
       
   409         List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
       
   410         data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
       
   411 
       
   412         // prepare desktop shortcut
       
   413         desktopFileResource
       
   414                 .setSubstitutionData(data)
       
   415                 .saveToFile(desktopFile.srcPath());
       
   416     }
       
   417 
       
   418     private List<String> getMimeTypeNamesFromFileAssociations() {
       
   419         return associations.stream().map(
       
   420                 a -> FA_CONTENT_TYPE.fetchFrom(a).get(0)).collect(
       
   421                         Collectors.toUnmodifiableList());
       
   422     }
       
   423 
       
   424     private static int getSquareSizeOfImage(File f) {
       
   425         try {
       
   426             BufferedImage bi = ImageIO.read(f);
       
   427             if (bi.getWidth() == bi.getHeight()) {
       
   428                 return bi.getWidth();
       
   429             }
       
   430         } catch (IOException e) {
       
   431             Log.verbose(e);
       
   432         }
       
   433         return 0;
       
   434     }
       
   435 
       
   436     private static String stringifyShellCommands(String... commands) {
       
   437         return stringifyShellCommands(Arrays.asList(commands));
       
   438     }
       
   439 
       
   440     private static String stringifyShellCommands(List<String> commands) {
       
   441         return String.join(System.lineSeparator(), commands.stream().filter(
       
   442                 s -> s != null && !s.isEmpty()).collect(Collectors.toList()));
       
   443     }
       
   444 
       
   445     private final PlatformPackage thePackage;
       
   446 
       
   447     private final List<Map<String, ? super Object>> associations;
       
   448 
       
   449     private final List<Map<String, ? super Object>> launchers;
       
   450 
       
   451     private final OverridableResource iconResource;
       
   452     private final OverridableResource desktopFileResource;
       
   453 
       
   454     private final DesktopFile mimeInfoFile;
       
   455     private final DesktopFile desktopFile;
       
   456     private final DesktopFile iconFile;
       
   457 
       
   458     private final List<DesktopIntegration> nestedIntegrations;
       
   459 
       
   460     private final Map<String, String> desktopFileData;
       
   461 
       
   462     private static final BundlerParamInfo<String> MENU_GROUP =
       
   463         new StandardBundlerParam<>(
       
   464                 Arguments.CLIOptions.LINUX_MENU_GROUP.getId(),
       
   465                 String.class,
       
   466                 params -> I18N.getString("param.menu-group.default"),
       
   467                 (s, p) -> s
       
   468         );
       
   469 
       
   470     private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
       
   471         new StandardBundlerParam<>(
       
   472                 Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(),
       
   473                 Boolean.class,
       
   474                 params -> false,
       
   475                 (s, p) -> (s == null || "null".equalsIgnoreCase(s))
       
   476                         ? false : Boolean.valueOf(s)
       
   477         );
       
   478 }