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 }