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.BufferedReader;
  29 import java.io.BufferedWriter;
  30 import java.io.File;
  31 import java.io.FileWriter;
  32 import java.io.IOException;
  33 import java.io.InputStream;
  34 import java.io.InputStreamReader;
  35 import java.io.Writer;
  36 import java.nio.file.Files;
  37 import java.nio.file.Path;
  38 import java.text.MessageFormat;
  39 import java.util.ArrayList;
  40 import java.util.Arrays;
  41 import java.util.Collections;
  42 import java.util.HashMap;
  43 import java.util.List;
  44 import java.util.Map;
  45 import java.util.ResourceBundle;
  46 import java.util.stream.Collectors;
  47 import java.util.stream.Stream;
  48 import javax.imageio.ImageIO;
  49 import javax.xml.stream.XMLOutputFactory;
  50 import javax.xml.stream.XMLStreamException;
  51 import javax.xml.stream.XMLStreamWriter;
  52 import static jdk.jpackage.internal.LinuxAppBundler.LINUX_INSTALL_DIR;
  53 import static jdk.jpackage.internal.LinuxAppBundler.LINUX_PACKAGE_DEPENDENCIES;
  54 import static jdk.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON;
  55 import static jdk.jpackage.internal.LinuxAppImageBuilder.ICON_PNG;
  56 import static jdk.jpackage.internal.StandardBundlerParam.*;
  57 
  58 
  59 abstract class LinuxPackageBundler extends AbstractBundler {
  60 
  61     protected static final ResourceBundle I18N = ResourceBundle.getBundle(
  62             "jdk.jpackage.internal.resources.LinuxResources");
  63 
  64     private static final String DESKTOP_COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL";
  65     private static final String DESKTOP_COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL";
  66     private static final String UTILITY_SCRIPTS = "UTILITY_SCRIPTS";
  67 
  68     private static final BundlerParamInfo<LinuxAppBundler> APP_BUNDLER =
  69         new StandardBundlerParam<>(
  70                 "linux.app.bundler",
  71                 LinuxAppBundler.class,
  72                 (params) -> new LinuxAppBundler(),
  73                 null
  74         );
  75 
  76     private static final BundlerParamInfo<String> MENU_GROUP =
  77         new StandardBundlerParam<>(
  78                 Arguments.CLIOptions.LINUX_MENU_GROUP.getId(),
  79                 String.class,
  80                 params -> I18N.getString("param.menu-group.default"),
  81                 (s, p) -> s
  82         );
  83 
  84     private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
  85         new StandardBundlerParam<>(
  86                 Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(),
  87                 Boolean.class,
  88                 params -> false,
  89                 (s, p) -> (s == null || "null".equalsIgnoreCase(s))
  90                         ? false : Boolean.valueOf(s)
  91         );
  92 
  93     LinuxPackageBundler(BundlerParamInfo<String> packageName) {
  94         this.packageName = packageName;
  95     }
  96 
  97     private final BundlerParamInfo<String> packageName;
  98 
  99     @Override
 100     final public boolean validate(Map<String, ? super Object> params)
 101             throws ConfigException {
 102         try {
 103             if (params == null) throw new ConfigException(
 104                     I18N.getString("error.parameters-null"),
 105                     I18N.getString("error.parameters-null.advice"));
 106 
 107             // run basic validation to ensure requirements are met
 108             // we are not interested in return code, only possible exception
 109             APP_BUNDLER.fetchFrom(params).validate(params);
 110 
 111             validateFileAssociations(FILE_ASSOCIATIONS.fetchFrom(params));
 112 
 113             // If package name has some restrictions, the string converter will
 114             // throw an exception if invalid
 115             packageName.getStringConverter().apply(packageName.fetchFrom(params),
 116                 params);
 117 
 118             // Packaging specific validation
 119             doValidate(params);
 120 
 121             return true;
 122         } catch (RuntimeException re) {
 123             if (re.getCause() instanceof ConfigException) {
 124                 throw (ConfigException) re.getCause();
 125             } else {
 126                 throw new ConfigException(re);
 127             }
 128         }
 129     }
 130 
 131     @Override
 132     final public String getBundleType() {
 133         return "INSTALLER";
 134     }
 135 
 136     @Override
 137     final public File execute(Map<String, ? super Object> params,
 138             File outputParentDir) throws PackagerException {
 139         IOUtils.writableOutputDir(outputParentDir.toPath());
 140 
 141         PlatformPackage thePackage = createMetaPackage(params);
 142 
 143         try {
 144             File appImage = StandardBundlerParam.getPredefinedAppImage(params);
 145 
 146             // we either have an application image or need to build one
 147             if (appImage != null) {
 148                 appImageLayout(params).resolveAt(appImage.toPath()).copy(
 149                         thePackage.sourceApplicationLayout());
 150             } else {
 151                 appImage = APP_BUNDLER.fetchFrom(params).doBundle(params,
 152                         thePackage.sourceRoot().toFile(), true);
 153                 ApplicationLayout srcAppLayout = appImageLayout(params).resolveAt(
 154                         appImage.toPath());
 155                 if (appImage.equals(PREDEFINED_RUNTIME_IMAGE.fetchFrom(params))) {
 156                     // Application image points to run-time image.
 157                     // Copy it.
 158                     srcAppLayout.copy(thePackage.sourceApplicationLayout());
 159                 } else {
 160                     // Application image is a newly created directory tree.
 161                     // Move it.
 162                     srcAppLayout.move(thePackage.sourceApplicationLayout());
 163                     if (appImage.exists()) {
 164                         // Empty app image directory might remain after all application
 165                         // directories have been moved.
 166                         appImage.delete();
 167                     }
 168                 }
 169             }
 170 
 171             Map<String, String> data = createDefaultReplacementData(params);
 172             if (StandardBundlerParam.isRuntimeInstaller(params)) {
 173                 Stream.of(DESKTOP_COMMANDS_INSTALL, DESKTOP_COMMANDS_UNINSTALL,
 174                         UTILITY_SCRIPTS).forEach(v -> data.put(v, ""));
 175             } else {
 176                 data.putAll(
 177                         new DesktopIntegration(thePackage, params).prepareForApplication());
 178             }
 179 
 180             data.putAll(createReplacementData(params));
 181 
 182             return buildPackageBundle(Collections.unmodifiableMap(data), params,
 183                     outputParentDir);
 184         } catch (IOException ex) {
 185             Log.verbose(ex);
 186             throw new PackagerException(ex);
 187         }
 188     }
 189 
 190     private Map<String, String> createDefaultReplacementData(
 191             Map<String, ? super Object> params) throws IOException {
 192         Map<String, String> data = new HashMap<>();
 193 
 194         data.put("APPLICATION_PACKAGE", createMetaPackage(params).name());
 195         data.put("APPLICATION_VENDOR", VENDOR.fetchFrom(params));
 196         data.put("APPLICATION_VERSION", VERSION.fetchFrom(params));
 197         data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
 198         data.put("APPLICATION_RELEASE", RELEASE.fetchFrom(params));
 199         data.put("PACKAGE_DEPENDENCIES", LINUX_PACKAGE_DEPENDENCIES.fetchFrom(
 200                 params));
 201 
 202         return data;
 203     }
 204 
 205     abstract void doValidate(Map<String, ? super Object> params)
 206             throws ConfigException;
 207 
 208     abstract protected Map<String, String> createReplacementData(
 209             Map<String, ? super Object> params) throws IOException;
 210 
 211     abstract protected File buildPackageBundle(
 212             Map<String, String> replacementData,
 213             Map<String, ? super Object> params, File outputParentDir) throws
 214             PackagerException, IOException;
 215 
 216     final protected PlatformPackage createMetaPackage(
 217             Map<String, ? super Object> params) {
 218         return new PlatformPackage() {
 219             @Override
 220             public String name() {
 221                 return packageName.fetchFrom(params);
 222             }
 223 
 224             @Override
 225             public Path sourceRoot() {
 226                 return IMAGES_ROOT.fetchFrom(params).toPath().toAbsolutePath();
 227             }
 228 
 229             @Override
 230             public ApplicationLayout sourceApplicationLayout() {
 231                 return appImageLayout(params).resolveAt(
 232                         applicationInstallDir(sourceRoot()));
 233             }
 234 
 235             @Override
 236             public ApplicationLayout installedApplicationLayout() {
 237                 return appImageLayout(params).resolveAt(
 238                         applicationInstallDir(Path.of("/")));
 239             }
 240 
 241             private Path applicationInstallDir(Path root) {
 242                 Path installDir = Path.of(LINUX_INSTALL_DIR.fetchFrom(params),
 243                         name());
 244                 if (installDir.isAbsolute()) {
 245                     installDir = Path.of("." + installDir.toString()).normalize();
 246                 }
 247                 return root.resolve(installDir);
 248             }
 249         };
 250     }
 251 
 252     private ApplicationLayout appImageLayout(
 253             Map<String, ? super Object> params) {
 254         if (StandardBundlerParam.isRuntimeInstaller(params)) {
 255             return ApplicationLayout.javaRuntime();
 256         }
 257         return ApplicationLayout.unixApp();
 258     }
 259 
 260     private static void validateFileAssociations(
 261             List<Map<String, ? super Object>> associations) throws
 262             ConfigException {
 263         // only one mime type per association, at least one file extention
 264         int assocIdx = 0;
 265         for (var assoc : associations) {
 266             ++assocIdx;
 267             List<String> mimes = FA_CONTENT_TYPE.fetchFrom(assoc);
 268             if (mimes == null || mimes.isEmpty()) {
 269                 String msgKey = "error.no-content-types-for-file-association";
 270                 throw new ConfigException(
 271                         MessageFormat.format(I18N.getString(msgKey), assocIdx),
 272                         I18N.getString(msgKey + ".advise"));
 273 
 274             }
 275 
 276             if (mimes.size() > 1) {
 277                 String msgKey = "error.too-many-content-types-for-file-association";
 278                 throw new ConfigException(
 279                         MessageFormat.format(I18N.getString(msgKey), assocIdx),
 280                         I18N.getString(msgKey + ".advise"));
 281             }
 282         }
 283     }
 284 
 285     /**
 286      * Helper to create files for desktop integration.
 287      */
 288     private class DesktopIntegration {
 289 
 290         DesktopIntegration(PlatformPackage thePackage,
 291                 Map<String, ? super Object> params) {
 292 
 293             associations = FILE_ASSOCIATIONS.fetchFrom(params).stream().filter(
 294                     a -> {
 295                         if (a == null) {
 296                             return false;
 297                         }
 298                         List<String> mimes = FA_CONTENT_TYPE.fetchFrom(a);
 299                         return (mimes != null && !mimes.isEmpty());
 300                     }).collect(Collectors.toUnmodifiableList());
 301 
 302             launchers = ADD_LAUNCHERS.fetchFrom(params);
 303 
 304             this.thePackage = thePackage;
 305 
 306             customIconFile = ICON_PNG.fetchFrom(params);
 307 
 308             verbose = VERBOSE.fetchFrom(params);
 309             resourceDir = RESOURCE_DIR.fetchFrom(params);
 310 
 311             // XDG recommends to use vendor prefix in desktop file names as xdg
 312             // commands copy files to system directories.
 313             // Package name should be a good prefix.
 314             final String desktopFileName = String.format("%s-%s.desktop",
 315                         thePackage.name(), APP_NAME.fetchFrom(params));
 316             final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
 317                         thePackage.name(), APP_NAME.fetchFrom(params));
 318 
 319             mimeInfoFile = new DesktopFile(mimeInfoFileName);
 320 
 321             if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) {
 322                 //
 323                 // Create primary .desktop file if one of conditions is met:
 324                 // - there are file associations configured
 325                 // - user explicitely requested to create a shortcut
 326                 // - custom icon specified
 327                 //
 328                 desktopFile = new DesktopFile(desktopFileName);
 329                 iconFile = new DesktopFile(String.format("%s.png",
 330                         APP_NAME.fetchFrom(params)));
 331             } else {
 332                 desktopFile = null;
 333                 iconFile = null;
 334             }
 335 
 336             this.desktopFileData = Collections.unmodifiableMap(
 337                     createDataForDesktopFile(params));
 338         }
 339 
 340         Map<String, String> prepareForApplication() throws IOException {
 341             if (iconFile != null) {
 342                 // Create application icon file.
 343                 prepareSrcIconFile();
 344             }
 345 
 346             Map<String, String> data = new HashMap<>(desktopFileData);
 347 
 348             final ShellCommands shellCommands;
 349             if (desktopFile != null) {
 350                 // Create application desktop description file.
 351                 createDesktopFile(data);
 352 
 353                 // Shell commands will be created only if desktop file
 354                 // should be installed.
 355                 shellCommands = new ShellCommands();
 356             } else {
 357                 shellCommands = null;
 358             }
 359 
 360             if (!associations.isEmpty()) {
 361                 // Create XML file with mime types corresponding to file associations.
 362                 createFileAssociationsMimeInfoFile();
 363 
 364                 shellCommands.setFileAssociations();
 365 
 366                 // Create icon files corresponding to file associations
 367                 Map<String, Path> mimeTypeWithIconFile = createFileAssociationIconFiles();
 368                 mimeTypeWithIconFile.forEach((k, v) -> {
 369                     shellCommands.addIcon(k, v);
 370                 });
 371             }
 372 
 373             // Create shell commands to install/uninstall integration with desktop of the app.
 374             if (shellCommands != null) {
 375                 shellCommands.applyTo(data);
 376             }
 377 
 378             boolean needCleanupScripts = !associations.isEmpty();
 379 
 380             // Take care of additional launchers if there are any.
 381             // Process every additional launcher as the main application launcher.
 382             // Collect shell commands to install/uninstall integration with desktop
 383             // of the additional launchers and append them to the corresponding
 384             // commands of the main launcher.
 385             List<String> installShellCmds = new ArrayList<>(Arrays.asList(
 386                     data.get(DESKTOP_COMMANDS_INSTALL)));
 387             List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
 388                     data.get(DESKTOP_COMMANDS_UNINSTALL)));
 389             for (Map<String, ? super Object> params : launchers) {
 390                 DesktopIntegration integration = new DesktopIntegration(
 391                         thePackage, params);
 392 
 393                 if (!integration.associations.isEmpty()) {
 394                     needCleanupScripts = true;
 395                 }
 396 
 397                 Map<String, String> launcherData = integration.prepareForApplication();
 398 
 399                 installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL));
 400                 uninstallShellCmds.add(launcherData.get(
 401                         DESKTOP_COMMANDS_UNINSTALL));
 402             }
 403 
 404             data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(
 405                     installShellCmds));
 406             data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(
 407                     uninstallShellCmds));
 408 
 409             if (needCleanupScripts) {
 410                 // Pull in utils.sh scrips library.
 411                 try (InputStream is = getResourceAsStream("utils.sh");
 412                         InputStreamReader isr = new InputStreamReader(is);
 413                         BufferedReader reader = new BufferedReader(isr)) {
 414                     data.put(UTILITY_SCRIPTS, reader.lines().collect(
 415                             Collectors.joining(System.lineSeparator())));
 416                 }
 417             } else {
 418                 data.put(UTILITY_SCRIPTS, "");
 419             }
 420 
 421             return data;
 422         }
 423 
 424         private Map<String, String> createDataForDesktopFile(
 425                 Map<String, ? super Object> params) {
 426             Map<String, String> data = new HashMap<>();
 427             data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params));
 428             data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params));
 429             data.put("APPLICATION_ICON",
 430                     iconFile != null ? iconFile.installPath().toString() : null);
 431             data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params));
 432             data.put("APPLICATION_LAUNCHER",
 433                     thePackage.installedApplicationLayout().launchersDirectory().resolve(
 434                             LinuxAppImageBuilder.getLauncherName(params)).toString());
 435 
 436             return data;
 437         }
 438 
 439         /**
 440          * Shell commands to integrate something with desktop.
 441          */
 442         private class ShellCommands {
 443 
 444             ShellCommands() {
 445                 registerIconCmds = new ArrayList<>();
 446                 unregisterIconCmds = new ArrayList<>();
 447 
 448                 registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
 449                         "install", desktopFile.installPath().toString());
 450                 unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu",
 451                         "uninstall", desktopFile.installPath().toString());
 452             }
 453 
 454             void setFileAssociations() {
 455                 registerFileAssociationsCmd = String.join(" ", "xdg-mime",
 456                         "install",
 457                         mimeInfoFile.installPath().toString());
 458                 unregisterFileAssociationsCmd = String.join(" ", "xdg-mime",
 459                         "uninstall", mimeInfoFile.installPath().toString());
 460 
 461                 //
 462                 // Add manual cleanup of system files to get rid of
 463                 // the default mime type handlers.
 464                 //
 465                 // Even after mime type is unregisterd with `xdg-mime uninstall`
 466                 // command and desktop file deleted with `xdg-desktop-menu uninstall`
 467                 // command, records in
 468                 // `/usr/share/applications/defaults.list` (Ubuntu 16) or
 469                 // `/usr/local/share/applications/defaults.list` (OracleLinux 7)
 470                 // files remain referencing deleted mime time and deleted
 471                 // desktop file which makes `xdg-mime query default` output name
 472                 // of non-existing desktop file.
 473                 //
 474                 String cleanUpCommand = String.join(" ",
 475                         "uninstall_default_mime_handler",
 476                         desktopFile.installPath().getFileName().toString(),
 477                         String.join(" ", getMimeTypeNamesFromFileAssociations()));
 478 
 479                 unregisterFileAssociationsCmd = stringifyShellCommands(
 480                         unregisterFileAssociationsCmd, cleanUpCommand);
 481             }
 482 
 483             void addIcon(String mimeType, Path iconFile) {
 484                 final int imgSize = getSquareSizeOfImage(iconFile.toFile());
 485                 final String dashMime = mimeType.replace('/', '-');
 486                 registerIconCmds.add(String.join(" ", "xdg-icon-resource",
 487                         "install", "--context", "mimetypes", "--size ",
 488                         Integer.toString(imgSize), iconFile.toString(), dashMime));
 489                 unregisterIconCmds.add(String.join(" ", "xdg-icon-resource",
 490                         "uninstall", dashMime));
 491             }
 492 
 493             void applyTo(Map<String, String> data) {
 494                 List<String> cmds = new ArrayList<>();
 495 
 496                 cmds.add(registerDesktopFileCmd);
 497                 cmds.add(registerFileAssociationsCmd);
 498                 cmds.addAll(registerIconCmds);
 499                 data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds));
 500 
 501                 cmds.clear();
 502                 cmds.add(unregisterDesktopFileCmd);
 503                 cmds.add(unregisterFileAssociationsCmd);
 504                 cmds.addAll(unregisterIconCmds);
 505                 data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
 506             }
 507 
 508             private String registerDesktopFileCmd;
 509             private String unregisterDesktopFileCmd;
 510 
 511             private String registerFileAssociationsCmd;
 512             private String unregisterFileAssociationsCmd;
 513 
 514             private List<String> registerIconCmds;
 515             private List<String> unregisterIconCmds;
 516         }
 517 
 518         private final PlatformPackage thePackage;
 519 
 520         private final List<Map<String, ? super Object>> associations;
 521 
 522         private final List<Map<String, ? super Object>> launchers;
 523 
 524         /**
 525          * Desktop integration file. xml, icon, etc.
 526          * Resides somewhere in application installation tree.
 527          * Has two paths:
 528          *  - path where it should be placed at package build time;
 529          *  - path where it should be installed by package manager;
 530          */
 531         private class DesktopFile {
 532 
 533             DesktopFile(String fileName) {
 534                 installPath = thePackage
 535                         .installedApplicationLayout()
 536                         .destktopIntegrationDirectory().resolve(fileName);
 537                 srcPath = thePackage
 538                         .sourceApplicationLayout()
 539                         .destktopIntegrationDirectory().resolve(fileName);
 540             }
 541 
 542             private final Path installPath;
 543             private final Path srcPath;
 544 
 545             Path installPath() {
 546                 return installPath;
 547             }
 548 
 549             Path srcPath() {
 550                 return srcPath;
 551             }
 552         }
 553 
 554         private final boolean verbose;
 555         private final File resourceDir;
 556 
 557         private final DesktopFile mimeInfoFile;
 558         private final DesktopFile desktopFile;
 559         private final DesktopFile iconFile;
 560 
 561         private final Map<String, String> desktopFileData;
 562 
 563         /**
 564          * Path to icon file provided by user or null.
 565          */
 566         private final File customIconFile;
 567 
 568         private void appendFileAssociation(XMLStreamWriter xml,
 569                 Map<String, ? super Object> assoc) throws XMLStreamException {
 570 
 571             xml.writeStartElement("mime-type");
 572             final String thisMime = FA_CONTENT_TYPE.fetchFrom(assoc).get(0);
 573             xml.writeAttribute("type", thisMime);
 574 
 575             final String description = FA_DESCRIPTION.fetchFrom(assoc);
 576             if (description != null && !description.isEmpty()) {
 577                 xml.writeStartElement("comment");
 578                 xml.writeCharacters(description);
 579                 xml.writeEndElement();
 580             }
 581 
 582             final List<String> extensions = FA_EXTENSIONS.fetchFrom(assoc);
 583             if (extensions == null) {
 584                 Log.error(I18N.getString(
 585                         "message.creating-association-with-null-extension"));
 586             } else {
 587                 for (String ext : extensions) {
 588                     xml.writeStartElement("glob");
 589                     xml.writeAttribute("pattern", "*." + ext);
 590                     xml.writeEndElement();
 591                 }
 592             }
 593 
 594             xml.writeEndElement();
 595         }
 596 
 597         private void createFileAssociationsMimeInfoFile() throws IOException {
 598             XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance();
 599 
 600             try (Writer w = new BufferedWriter(new FileWriter(
 601                     mimeInfoFile.srcPath().toFile()))) {
 602                 XMLStreamWriter xml = xmlFactory.createXMLStreamWriter(w);
 603 
 604                 xml.writeStartDocument();
 605                 xml.writeStartElement("mime-info");
 606                 xml.writeNamespace("xmlns",
 607                         "http://www.freedesktop.org/standards/shared-mime-info");
 608 
 609                 for (var assoc : associations) {
 610                     appendFileAssociation(xml, assoc);
 611                 }
 612 
 613                 xml.writeEndElement();
 614                 xml.writeEndDocument();
 615                 xml.flush();
 616                 xml.close();
 617 
 618             } catch (XMLStreamException ex) {
 619                 Log.verbose(ex);
 620                 throw new IOException(ex);
 621             }
 622         }
 623 
 624         private Map<String, Path> createFileAssociationIconFiles() throws
 625                 IOException {
 626             Map<String, Path> mimeTypeWithIconFile = new HashMap<>();
 627             for (var assoc : associations) {
 628                 File customFaIcon = FA_ICON.fetchFrom(assoc);
 629                 if (customFaIcon == null || !customFaIcon.exists() || getSquareSizeOfImage(
 630                         customFaIcon) == 0) {
 631                     continue;
 632                 }
 633 
 634                 String fname = iconFile.srcPath().getFileName().toString();
 635                 if (fname.indexOf(".") > 0) {
 636                     fname = fname.substring(0, fname.lastIndexOf("."));
 637                 }
 638 
 639                 DesktopFile faIconFile = new DesktopFile(
 640                         fname + "_fa_" + customFaIcon.getName());
 641 
 642                 IOUtils.copyFile(customFaIcon, faIconFile.srcPath().toFile());
 643 
 644                 mimeTypeWithIconFile.put(FA_CONTENT_TYPE.fetchFrom(assoc).get(0),
 645                         faIconFile.installPath());
 646             }
 647             return mimeTypeWithIconFile;
 648         }
 649 
 650         private void createDesktopFile(Map<String, String> data) throws IOException {
 651             List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
 652             data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
 653 
 654             // prepare desktop shortcut
 655             try (Writer w = Files.newBufferedWriter(desktopFile.srcPath())) {
 656                 String content = preprocessTextResource(
 657                         desktopFile.srcPath().getFileName().toString(),
 658                         I18N.getString("resource.menu-shortcut-descriptor"),
 659                         "template.desktop",
 660                         data,
 661                         verbose,
 662                         resourceDir);
 663                 w.write(content);
 664             }
 665         }
 666 
 667         private void prepareSrcIconFile() throws IOException {
 668             if (customIconFile == null || !customIconFile.exists()) {
 669                 fetchResource(iconFile.srcPath().getFileName().toString(),
 670                         I18N.getString("resource.menu-icon"),
 671                         DEFAULT_ICON,
 672                         iconFile.srcPath().toFile(),
 673                         verbose,
 674                         resourceDir);
 675             } else {
 676                 fetchResource(iconFile.srcPath().getFileName().toString(),
 677                         I18N.getString("resource.menu-icon"),
 678                         customIconFile,
 679                         iconFile.srcPath().toFile(),
 680                         verbose,
 681                         resourceDir);
 682             }
 683         }
 684 
 685         private List<String> getMimeTypeNamesFromFileAssociations() {
 686             return associations.stream().map(
 687                     a -> FA_CONTENT_TYPE.fetchFrom(a).get(0)).collect(
 688                             Collectors.toUnmodifiableList());
 689         }
 690     }
 691 
 692     private static int getSquareSizeOfImage(File f) {
 693         try {
 694             BufferedImage bi = ImageIO.read(f);
 695             if (bi.getWidth() == bi.getHeight()) {
 696                 return bi.getWidth();
 697             }
 698         } catch (IOException e) {
 699             Log.verbose(e);
 700         }
 701         return 0;
 702     }
 703 
 704     private static String stringifyShellCommands(String ... commands) {
 705         return stringifyShellCommands(Arrays.asList(commands));
 706     }
 707 
 708     private static String stringifyShellCommands(List<String> commands) {
 709         return String.join(System.lineSeparator(), commands.stream().filter(
 710                 s -> s != null && !s.isEmpty()).collect(Collectors.toList()));
 711     }
 712 }