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     private DesktopIntegration(PlatformPackage thePackage,
  52             Map<String, ? super Object> params,
  53             Map<String, ? super Object> mainParams) throws IOException {
  54 
  55         associations = FileAssociation.fetchFrom(params).stream()
  56                 .filter(fa -> !fa.mimeTypes.isEmpty())
  57                 .map(LinuxFileAssociation::new)
  58                 .collect(Collectors.toUnmodifiableList());
  59 
  60         launchers = ADD_LAUNCHERS.fetchFrom(params);
  61 
  62         this.thePackage = thePackage;
  63 
  64         // Need desktop and icon files if one of conditions is met:
  65         //  - there are file associations configured
  66         //  - user explicitely requested to create a shortcut
  67         boolean withDesktopFile = !associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params);
  68 
  69         var curIconResource = LinuxAppImageBuilder.createIconResource(DEFAULT_ICON,
  70                 ICON_PNG, params, mainParams);
  71         if (curIconResource == null) {
  72             // This is additional launcher with explicit `no icon` configuration.
  73             withDesktopFile = false;
  74         } else {
  75             final Path nullPath = null;
  76             if (curIconResource.saveToFile(nullPath)
  77                     != OverridableResource.Source.DefaultResource) {
  78                 // This launcher has custom icon configured.
  79                 withDesktopFile = true;
  80             }
  81         }
  82 
  83         desktopFileResource = createResource("template.desktop", params)
  84                 .setCategory(I18N.getString("resource.menu-shortcut-descriptor"))
  85                 .setPublicName(APP_NAME.fetchFrom(params) + ".desktop");
  86 
  87         // XDG recommends to use vendor prefix in desktop file names as xdg
  88         // commands copy files to system directories.
  89         // Package name should be a good prefix.
  90         final String desktopFileName = String.format("%s-%s.desktop",
  91                     thePackage.name(), APP_NAME.fetchFrom(params));
  92         final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
  93                     thePackage.name(), APP_NAME.fetchFrom(params));
  94 
  95         mimeInfoFile = new DesktopFile(mimeInfoFileName);
  96 
  97         if (withDesktopFile) {
  98             desktopFile = new DesktopFile(desktopFileName);
  99             iconFile = new DesktopFile(APP_NAME.fetchFrom(params)
 100                     + IOUtils.getSuffix(Path.of(DEFAULT_ICON)));
 101 
 102             if (curIconResource == null) {
 103                 // Create default icon.
 104                 curIconResource = LinuxAppImageBuilder.createIconResource(
 105                         DEFAULT_ICON, ICON_PNG, mainParams, null);
 106             }
 107         } else {
 108             desktopFile = null;
 109             iconFile = null;
 110         }
 111 
 112         iconResource = curIconResource;
 113 
 114         desktopFileData = Collections.unmodifiableMap(
 115                 createDataForDesktopFile(params));
 116 
 117         nestedIntegrations = new ArrayList<>();
 118         for (var launcherParams : launchers) {
 119             launcherParams = AddLauncherArguments.merge(params, launcherParams,
 120                     ICON.getID(), ICON_PNG.getID(), ADD_LAUNCHERS.getID(),
 121                     FILE_ASSOCIATIONS.getID());
 122             nestedIntegrations.add(new DesktopIntegration(thePackage,
 123                     launcherParams, params));
 124         }
 125     }
 126 
 127     static DesktopIntegration create(PlatformPackage thePackage,
 128             Map<String, ? super Object> params) throws IOException {
 129         if (StandardBundlerParam.isRuntimeInstaller(params)) {
 130             return null;
 131         }
 132         return new DesktopIntegration(thePackage, params, null);
 133     }
 134 
 135     List<String> requiredPackages() {
 136         return Stream.of(List.of(this), nestedIntegrations).flatMap(
 137                 List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
 138                 List::stream).distinct().collect(Collectors.toList());
 139     }
 140 
 141     Map<String, String> create() throws IOException {
 142         associations.forEach(assoc -> assoc.data.verify());
 143 
 144         if (iconFile != null) {
 145             // Create application icon file.
 146             iconResource.saveToFile(iconFile.srcPath());
 147         }
 148 
 149         Map<String, String> data = new HashMap<>(desktopFileData);
 150 
 151         final ShellCommands shellCommands;
 152         if (desktopFile != null) {
 153             // Create application desktop description file.
 154             createDesktopFile(data);
 155 
 156             // Shell commands will be created only if desktop file
 157             // should be installed.
 158             shellCommands = new ShellCommands();
 159         } else {
 160             shellCommands = null;
 161         }
 162 
 163         if (!associations.isEmpty()) {
 164             // Create XML file with mime types corresponding to file associations.
 165             createFileAssociationsMimeInfoFile();
 166 
 167             shellCommands.setFileAssociations();
 168 
 169             // Create icon files corresponding to file associations
 170             addFileAssociationIconFiles(shellCommands);
 171         }
 172 
 173         // Create shell commands to install/uninstall integration with desktop of the app.
 174         if (shellCommands != null) {
 175             shellCommands.applyTo(data);
 176         }
 177 
 178         boolean needCleanupScripts = !associations.isEmpty();
 179 
 180         // Take care of additional launchers if there are any.
 181         // Process every additional launcher as the main application launcher.
 182         // Collect shell commands to install/uninstall integration with desktop
 183         // of the additional launchers and append them to the corresponding
 184         // commands of the main launcher.
 185         List<String> installShellCmds = new ArrayList<>(Arrays.asList(
 186                 data.get(DESKTOP_COMMANDS_INSTALL)));
 187         List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
 188                 data.get(DESKTOP_COMMANDS_UNINSTALL)));
 189         for (var integration: nestedIntegrations) {
 190             if (!integration.associations.isEmpty()) {
 191                 needCleanupScripts = true;
 192             }
 193 
 194             Map<String, String> launcherData = integration.create();
 195 
 196             installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL));
 197             uninstallShellCmds.add(launcherData.get(
 198                     DESKTOP_COMMANDS_UNINSTALL));
 199         }
 200 
 201         data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(
 202                 installShellCmds));
 203         data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(
 204                 uninstallShellCmds));
 205 
 206         if (needCleanupScripts) {
 207             // Pull in utils.sh scrips library.
 208             try (InputStream is = OverridableResource.readDefault("utils.sh");
 209                     InputStreamReader isr = new InputStreamReader(is);
 210                     BufferedReader reader = new BufferedReader(isr)) {
 211                 data.put(UTILITY_SCRIPTS, reader.lines().collect(
 212                         Collectors.joining(System.lineSeparator())));
 213             }
 214         } else {
 215             data.put(UTILITY_SCRIPTS, "");
 216         }
 217 
 218         return data;
 219     }
 220 
 221     private List<String> requiredPackagesSelf() {
 222         if (desktopFile != null) {
 223             return List.of("xdg-utils");
 224         }
 225         return Collections.emptyList();
 226     }
 227 
 228     private Map<String, String> createDataForDesktopFile(
 229             Map<String, ? super Object> params) {
 230         Map<String, String> data = new HashMap<>();
 231         data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params));
 232         data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
 233         data.put("APPLICATION_ICON",
 234                 iconFile != null ? iconFile.installPath().toString() : null);
 235         data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params));
 236         data.put("APPLICATION_LAUNCHER",
 237                 thePackage.installedApplicationLayout().launchersDirectory().resolve(
 238                         LinuxAppImageBuilder.getLauncherName(params)).toString());
 239 
 240         return data;
 241     }
 242 
 243     /**
 244      * Shell commands to integrate something with desktop.
 245      */
 246     private class ShellCommands {
 247 
 248         ShellCommands() {
 249             registerIconCmds = new ArrayList<>();
 250             unregisterIconCmds = new ArrayList<>();
 251 
 252             registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
 253                     "install", desktopFile.installPath().toString());
 254             unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
 255                     "uninstall", desktopFile.installPath().toString());
 256         }
 257 
 258         void setFileAssociations() {
 259             registerFileAssociationsCmd = String.join(" ", "xdg-mime",
 260                     "install",
 261                     mimeInfoFile.installPath().toString());
 262             unregisterFileAssociationsCmd = String.join(" ", "xdg-mime",
 263                     "uninstall", mimeInfoFile.installPath().toString());
 264 
 265             //
 266             // Add manual cleanup of system files to get rid of
 267             // the default mime type handlers.
 268             //
 269             // Even after mime type is unregisterd with `xdg-mime uninstall`
 270             // command and desktop file deleted with `xdg-desktop-menu uninstall`
 271             // command, records in
 272             // `/usr/share/applications/defaults.list` (Ubuntu 16) or
 273             // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
 274             // files remain referencing deleted mime time and deleted
 275             // desktop file which makes `xdg-mime query default` output name
 276             // of non-existing desktop file.
 277             //
 278             String cleanUpCommand = String.join(" ",
 279                     "uninstall_default_mime_handler",
 280                     desktopFile.installPath().getFileName().toString(),
 281                     String.join(" ", getMimeTypeNamesFromFileAssociations()));
 282 
 283             unregisterFileAssociationsCmd = stringifyShellCommands(
 284                     unregisterFileAssociationsCmd, cleanUpCommand);
 285         }
 286 
 287         void addIcon(String mimeType, Path iconFile) {
 288             addIcon(mimeType, iconFile, getSquareSizeOfImage(iconFile.toFile()));
 289         }
 290 
 291         void addIcon(String mimeType, Path iconFile, int imgSize) {
 292             imgSize = normalizeIconSize(imgSize);
 293             final String dashMime = mimeType.replace('/', '-');
 294             registerIconCmds.add(String.join(" ", "xdg-icon-resource",
 295                     "install", "--context", "mimetypes", "--size",
 296                     Integer.toString(imgSize), iconFile.toString(), dashMime));
 297             unregisterIconCmds.add(String.join(" ", "xdg-icon-resource",
 298                     "uninstall", dashMime, "--size", Integer.toString(imgSize)));
 299         }
 300 
 301         void applyTo(Map<String, String> data) {
 302             List<String> cmds = new ArrayList<>();
 303 
 304             cmds.add(registerDesktopFileCmd);
 305             cmds.add(registerFileAssociationsCmd);
 306             cmds.addAll(registerIconCmds);
 307             data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds));
 308 
 309             cmds.clear();
 310             cmds.add(unregisterDesktopFileCmd);
 311             cmds.add(unregisterFileAssociationsCmd);
 312             cmds.addAll(unregisterIconCmds);
 313             data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
 314         }
 315 
 316         private String registerDesktopFileCmd;
 317         private String unregisterDesktopFileCmd;
 318 
 319         private String registerFileAssociationsCmd;
 320         private String unregisterFileAssociationsCmd;
 321 
 322         private List<String> registerIconCmds;
 323         private List<String> unregisterIconCmds;
 324     }
 325 
 326     /**
 327      * Desktop integration file. xml, icon, etc.
 328      * Resides somewhere in application installation tree.
 329      * Has two paths:
 330      *  - path where it should be placed at package build time;
 331      *  - path where it should be installed by package manager;
 332      */
 333     private class DesktopFile {
 334 
 335         DesktopFile(String fileName) {
 336             installPath = thePackage
 337                     .installedApplicationLayout()
 338                     .destktopIntegrationDirectory().resolve(fileName);
 339             srcPath = thePackage
 340                     .sourceApplicationLayout()
 341                     .destktopIntegrationDirectory().resolve(fileName);
 342         }
 343 
 344         private final Path installPath;
 345         private final Path srcPath;
 346 
 347         Path installPath() {
 348             return installPath;
 349         }
 350 
 351         Path srcPath() {
 352             return srcPath;
 353         }
 354     }
 355 
 356     private void appendFileAssociation(XMLStreamWriter xml,
 357             FileAssociation assoc) throws XMLStreamException {
 358 
 359         for (var mimeType : assoc.mimeTypes) {
 360             xml.writeStartElement("mime-type");
 361             xml.writeAttribute("type", mimeType);
 362 
 363             final String description = assoc.description;
 364             if (description != null && !description.isEmpty()) {
 365                 xml.writeStartElement("comment");
 366                 xml.writeCharacters(description);
 367                 xml.writeEndElement();
 368             }
 369 
 370             for (String ext : assoc.extensions) {
 371                 xml.writeStartElement("glob");
 372                 xml.writeAttribute("pattern", "*." + ext);
 373                 xml.writeEndElement();
 374             }
 375 
 376             xml.writeEndElement();
 377         }
 378     }
 379 
 380     private void createFileAssociationsMimeInfoFile() throws IOException {
 381         IOUtils.createXml(mimeInfoFile.srcPath(), xml -> {
 382             xml.writeStartElement("mime-info");
 383             xml.writeDefaultNamespace(
 384                     "http://www.freedesktop.org/standards/shared-mime-info");
 385 
 386             for (var assoc : associations) {
 387                 appendFileAssociation(xml, assoc.data);
 388             }
 389 
 390             xml.writeEndElement();
 391         });
 392     }
 393 
 394     private void addFileAssociationIconFiles(ShellCommands shellCommands)
 395             throws IOException {
 396         Set<String> processedMimeTypes = new HashSet<>();
 397         for (var assoc : associations) {
 398             if (assoc.iconSize <= 0) {
 399                 // No icon.
 400                 continue;
 401             }
 402 
 403             for (var mimeType : assoc.data.mimeTypes) {
 404                 if (processedMimeTypes.contains(mimeType)) {
 405                     continue;
 406                 }
 407 
 408                 processedMimeTypes.add(mimeType);
 409 
 410                 // Create icon name for mime type from mime type.
 411                 DesktopFile faIconFile = new DesktopFile(mimeType.replace(
 412                         File.separatorChar, '-') + IOUtils.getSuffix(
 413                                 assoc.data.iconPath));
 414 
 415                 IOUtils.copyFile(assoc.data.iconPath.toFile(),
 416                         faIconFile.srcPath().toFile());
 417 
 418                 shellCommands.addIcon(mimeType, faIconFile.installPath(),
 419                         assoc.iconSize);
 420             }
 421         }
 422     }
 423 
 424     private void createDesktopFile(Map<String, String> data) throws IOException {
 425         List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
 426         data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
 427 
 428         // prepare desktop shortcut
 429         desktopFileResource
 430                 .setSubstitutionData(data)
 431                 .saveToFile(desktopFile.srcPath());
 432     }
 433 
 434     private List<String> getMimeTypeNamesFromFileAssociations() {
 435         return associations.stream()
 436                 .map(fa -> fa.data.mimeTypes)
 437                 .flatMap(List::stream)
 438                 .collect(Collectors.toUnmodifiableList());
 439     }
 440 
 441     private static int getSquareSizeOfImage(File f) {
 442         try {
 443             BufferedImage bi = ImageIO.read(f);
 444             return Math.max(bi.getWidth(), bi.getHeight());
 445         } catch (IOException e) {
 446             Log.verbose(e);
 447         }
 448         return 0;
 449     }
 450 
 451     private static int normalizeIconSize(int iconSize) {
 452         // If register icon with "uncommon" size, it will be ignored.
 453         // So find the best matching "common" size.
 454         List<Integer> commonIconSizes = List.of(16, 22, 32, 48, 64, 128);
 455 
 456         int idx = Collections.binarySearch(commonIconSizes, iconSize);
 457         if (idx < 0) {
 458             // Given icon size is greater than the largest common icon size.
 459             return commonIconSizes.get(commonIconSizes.size() - 1);
 460         }
 461 
 462         if (idx == 0) {
 463             // Given icon size is less or equal than the smallest common icon size.
 464             return commonIconSizes.get(idx);
 465         }
 466 
 467         int commonIconSize = commonIconSizes.get(idx);
 468         if (iconSize < commonIconSize) {
 469             // It is better to scale down original icon than to scale it up for
 470             // better visual quality.
 471             commonIconSize = commonIconSizes.get(idx - 1);
 472         }
 473 
 474         return commonIconSize;
 475     }
 476 
 477     private static String stringifyShellCommands(String... commands) {
 478         return stringifyShellCommands(Arrays.asList(commands));
 479     }
 480 
 481     private static String stringifyShellCommands(List<String> commands) {
 482         return String.join(System.lineSeparator(), commands.stream().filter(
 483                 s -> s != null && !s.isEmpty()).collect(Collectors.toList()));
 484     }
 485 
 486     private static class LinuxFileAssociation {
 487         LinuxFileAssociation(FileAssociation fa) {
 488             this.data = fa;
 489             if (fa.iconPath != null && Files.isReadable(fa.iconPath)) {
 490                 iconSize = getSquareSizeOfImage(fa.iconPath.toFile());
 491             } else {
 492                 iconSize = -1;
 493             }
 494         }
 495 
 496         final FileAssociation data;
 497         final int iconSize;
 498     }
 499 
 500     private final PlatformPackage thePackage;
 501 
 502     private final List<LinuxFileAssociation> associations;
 503 
 504     private final List<Map<String, ? super Object>> launchers;
 505 
 506     private final OverridableResource iconResource;
 507     private final OverridableResource desktopFileResource;
 508 
 509     private final DesktopFile mimeInfoFile;
 510     private final DesktopFile desktopFile;
 511     private final DesktopFile iconFile;
 512 
 513     private final List<DesktopIntegration> nestedIntegrations;
 514 
 515     private final Map<String, String> desktopFileData;
 516 
 517     private static final BundlerParamInfo<String> MENU_GROUP =
 518         new StandardBundlerParam<>(
 519                 Arguments.CLIOptions.LINUX_MENU_GROUP.getId(),
 520                 String.class,
 521                 params -> I18N.getString("param.menu-group.default"),
 522                 (s, p) -> s
 523         );
 524 
 525     private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
 526         new StandardBundlerParam<>(
 527                 Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(),
 528                 Boolean.class,
 529                 params -> false,
 530                 (s, p) -> (s == null || "null".equalsIgnoreCase(s))
 531                         ? false : Boolean.valueOf(s)
 532         );
 533 }