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 
  26 package jdk.incubator.jpackage.internal;
  27 
  28 import java.io.IOException;
  29 import java.nio.charset.StandardCharsets;
  30 import java.nio.file.Path;
  31 import java.text.MessageFormat;
  32 import java.util.*;
  33 import java.util.function.*;
  34 import java.util.stream.Collectors;
  35 import java.util.stream.Stream;
  36 import javax.xml.stream.XMLStreamException;
  37 import javax.xml.stream.XMLStreamWriter;
  38 import jdk.incubator.jpackage.internal.IOUtils.XmlConsumer;
  39 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*;
  40 import static jdk.incubator.jpackage.internal.WinMsiBundler.*;
  41 import static jdk.incubator.jpackage.internal.WindowsBundlerParam.MENU_GROUP;
  42 import static jdk.incubator.jpackage.internal.WindowsBundlerParam.WINDOWS_INSTALL_DIR;
  43 
  44 /**
  45  * Creates application WiX source files.
  46  */
  47 class WixSourcesBuilder {
  48 
  49     WixSourcesBuilder setWixVersion(DottedVersion v) {
  50         wixVersion = v;
  51         return this;
  52     }
  53 
  54     WixSourcesBuilder initFromParams(Path appImageRoot,
  55             Map<String, ? super Object> params) {
  56         Supplier<ApplicationLayout> appImageSupplier = () -> {
  57             if (StandardBundlerParam.isRuntimeInstaller(params)) {
  58                 return ApplicationLayout.javaRuntime();
  59             } else {
  60                 return ApplicationLayout.platformAppImage();
  61             }
  62         };
  63 
  64         systemWide = MSI_SYSTEM_WIDE.fetchFrom(params);
  65 
  66         registryKeyPath = Path.of("Software",
  67                 VENDOR.fetchFrom(params),
  68                 APP_NAME.fetchFrom(params),
  69                 VERSION.fetchFrom(params)).toString();
  70 
  71         installDir = (systemWide ? PROGRAM_FILES : LOCAL_PROGRAM_FILES).resolve(
  72                 WINDOWS_INSTALL_DIR.fetchFrom(params));
  73 
  74         do {
  75             ApplicationLayout layout = appImageSupplier.get();
  76             // Don't want AppImageFile.FILENAME in installed application.
  77             // Register it with app image at a role without a match in installed
  78             // app layout to exclude it from layout transformation.
  79             layout.pathGroup().setPath(new Object(),
  80                     AppImageFile.getPathInAppImage(Path.of("")));
  81 
  82             // Want absolute paths to source files in generated WiX sources.
  83             // This is to handle scenario if sources would be processed from
  84             // differnt current directory.
  85             appImage = layout.resolveAt(appImageRoot.toAbsolutePath().normalize());
  86         } while (false);
  87 
  88         installedAppImage = appImageSupplier.get().resolveAt(INSTALLDIR);
  89 
  90         shortcutFolders = new HashSet<>();
  91         if (SHORTCUT_HINT.fetchFrom(params)) {
  92             shortcutFolders.add(ShortcutsFolder.Desktop);
  93         }
  94         if (MENU_HINT.fetchFrom(params)) {
  95             shortcutFolders.add(ShortcutsFolder.ProgramMenu);
  96         }
  97 
  98         if (StandardBundlerParam.isRuntimeInstaller(params)) {
  99             launcherPaths = Collections.emptyList();
 100         } else {
 101             launcherPaths = AppImageFile.getLauncherNames(appImageRoot, params).stream()
 102                     .map(name -> installedAppImage.launchersDirectory().resolve(name))
 103                     .map(WixSourcesBuilder::addExeSuffixToPath)
 104                     .collect(Collectors.toList());
 105         }
 106 
 107         programMenuFolderName = MENU_GROUP.fetchFrom(params);
 108 
 109         initFileAssociations(params);
 110 
 111         return this;
 112     }
 113 
 114     void createMainFragment(Path file) throws IOException {
 115         removeFolderItems = new HashMap<>();
 116         defaultedMimes = new HashSet<>();
 117         IOUtils.createXml(file, xml -> {
 118             xml.writeStartElement("Wix");
 119             xml.writeDefaultNamespace("http://schemas.microsoft.com/wix/2006/wi");
 120             xml.writeNamespace("util",
 121                     "http://schemas.microsoft.com/wix/UtilExtension");
 122 
 123             xml.writeStartElement("Fragment");
 124 
 125             addFaComponentGroup(xml);
 126 
 127             addShortcutComponentGroup(xml);
 128 
 129             addFilesComponentGroup(xml);
 130 
 131             xml.writeEndElement();  // <Fragment>
 132 
 133             addIconsFragment(xml);
 134 
 135             xml.writeEndElement(); // <Wix>
 136         });
 137     }
 138 
 139     void logWixFeatures() {
 140         if (wixVersion.compareTo("3.6") >= 0) {
 141             Log.verbose(MessageFormat.format(I18N.getString(
 142                     "message.use-wix36-features"), wixVersion));
 143         }
 144     }
 145 
 146     private void normalizeFileAssociation(FileAssociation fa) {
 147         fa.launcherPath = addExeSuffixToPath(
 148                 installedAppImage.launchersDirectory().resolve(fa.launcherPath));
 149 
 150         if (fa.iconPath != null && !fa.iconPath.toFile().exists()) {
 151             fa.iconPath = null;
 152         }
 153 
 154         if (fa.iconPath != null) {
 155             fa.iconPath = fa.iconPath.toAbsolutePath();
 156         }
 157 
 158         // Filter out empty extensions.
 159         fa.extensions = fa.extensions.stream().filter(Predicate.not(
 160                 String::isEmpty)).collect(Collectors.toList());
 161     }
 162 
 163     private static Path addExeSuffixToPath(Path path) {
 164         return IOUtils.addSuffix(path, ".exe");
 165     }
 166 
 167     private Path getInstalledFaIcoPath(FileAssociation fa) {
 168         String fname = String.format("fa_%s.ico", String.join("_", fa.extensions));
 169         return installedAppImage.destktopIntegrationDirectory().resolve(fname);
 170     }
 171 
 172     private void initFileAssociations(Map<String, ? super Object> params) {
 173         associations = FileAssociation.fetchFrom(params).stream()
 174                 .peek(this::normalizeFileAssociation)
 175                 // Filter out file associations without extensions.
 176                 .filter(fa -> !fa.extensions.isEmpty())
 177                 .collect(Collectors.toList());
 178 
 179         associations.stream().filter(fa -> fa.iconPath != null).forEach(fa -> {
 180             // Need to add fa icon in the image.
 181             Object key = new Object();
 182             appImage.pathGroup().setPath(key, fa.iconPath);
 183             installedAppImage.pathGroup().setPath(key, getInstalledFaIcoPath(fa));
 184         });
 185     }
 186 
 187     private static UUID createNameUUID(String str) {
 188         return UUID.nameUUIDFromBytes(str.getBytes(StandardCharsets.UTF_8));
 189     }
 190 
 191     private static UUID createNameUUID(Path path, String role) {
 192         if (path.isAbsolute() || !ROOT_DIRS.contains(path.getName(0))) {
 193             throw throwInvalidPathException(path);
 194         }
 195         // Paths are case insensitive on Windows
 196         String keyPath = path.toString().toLowerCase();
 197         if (role != null) {
 198             keyPath = role + "@" + keyPath;
 199         }
 200         return createNameUUID(keyPath);
 201     }
 202 
 203     /**
 204      * Value for Id attribute of various WiX elements.
 205      */
 206     enum Id {
 207         File,
 208         Folder("dir"),
 209         Shortcut,
 210         ProgId,
 211         Icon,
 212         CreateFolder("mkdir"),
 213         RemoveFolder("rm");
 214 
 215         Id() {
 216             this.prefix = name().toLowerCase();
 217         }
 218 
 219         Id(String prefix) {
 220             this.prefix = prefix;
 221         }
 222 
 223         String of(Path path) {
 224             if (this == Folder && KNOWN_DIRS.contains(path)) {
 225                 return path.getFileName().toString();
 226             }
 227 
 228             String result = of(path, prefix, name());
 229 
 230             if (this == Icon) {
 231                 // Icon id constructed from UUID value is too long and triggers
 232                 // CNDL1000 warning, so use Java hash code instead.
 233                 result = String.format("%s%d", prefix, result.hashCode()).replace(
 234                         "-", "_");
 235             }
 236 
 237             return result;
 238         }
 239 
 240         private static String of(Path path, String prefix, String role) {
 241             Objects.requireNonNull(role);
 242             Objects.requireNonNull(prefix);
 243             return String.format("%s%s", prefix,
 244                     createNameUUID(path, role).toString().replace("-", ""));
 245         }
 246 
 247         static String of(Path path, String prefix) {
 248             return of(path, prefix, prefix);
 249         }
 250 
 251         private final String prefix;
 252     }
 253 
 254     enum Component {
 255         File(cfg().file()),
 256         Shortcut(cfg().file().withRegistryKeyPath()),
 257         ProgId(cfg().file().withRegistryKeyPath()),
 258         CreateFolder(cfg().withRegistryKeyPath()),
 259         RemoveFolder(cfg().withRegistryKeyPath());
 260 
 261         Component() {
 262             this.cfg = cfg();
 263             this.id = Id.valueOf(name());
 264         }
 265 
 266         Component(Config cfg) {
 267             this.cfg = cfg;
 268             this.id = Id.valueOf(name());
 269         }
 270 
 271         UUID guidOf(Path path) {
 272             return createNameUUID(path, name());
 273         }
 274 
 275         String idOf(Path path) {
 276             return id.of(path);
 277         }
 278 
 279         boolean isRegistryKeyPath() {
 280             return cfg.withRegistryKeyPath;
 281         }
 282 
 283         boolean isFile() {
 284             return cfg.isFile;
 285         }
 286 
 287         static void startElement(XMLStreamWriter xml, String componentId,
 288                 String componentGuid) throws XMLStreamException, IOException {
 289             xml.writeStartElement("Component");
 290             xml.writeAttribute("Win64", "yes");
 291             xml.writeAttribute("Id", componentId);
 292             xml.writeAttribute("Guid", componentGuid);
 293         }
 294 
 295         private static final class Config {
 296             Config withRegistryKeyPath() {
 297                 withRegistryKeyPath = true;
 298                 return this;
 299             }
 300 
 301             Config file() {
 302                 isFile = true;
 303                 return this;
 304             }
 305 
 306             private boolean isFile;
 307             private boolean withRegistryKeyPath;
 308         }
 309 
 310         private static Config cfg() {
 311             return new Config();
 312         }
 313 
 314         private final Config cfg;
 315         private final Id id;
 316     };
 317 
 318     private static void addComponentGroup(XMLStreamWriter xml, String id,
 319             List<String> componentIds) throws XMLStreamException, IOException {
 320         xml.writeStartElement("ComponentGroup");
 321         xml.writeAttribute("Id", id);
 322         componentIds = componentIds.stream().filter(Objects::nonNull).collect(
 323                 Collectors.toList());
 324         for (var componentId : componentIds) {
 325             xml.writeStartElement("ComponentRef");
 326             xml.writeAttribute("Id", componentId);
 327             xml.writeEndElement();
 328         }
 329         xml.writeEndElement();
 330     }
 331 
 332     private String addComponent(XMLStreamWriter xml, Path path,
 333             Component role, XmlConsumer xmlConsumer) throws XMLStreamException,
 334             IOException {
 335 
 336         final Path directoryRefPath;
 337         if (role.isFile()) {
 338             directoryRefPath = path.getParent();
 339         } else {
 340             directoryRefPath = path;
 341         }
 342 
 343         xml.writeStartElement("DirectoryRef");
 344         xml.writeAttribute("Id", Id.Folder.of(directoryRefPath));
 345 
 346         final String componentId = "c" + role.idOf(path);
 347         Component.startElement(xml, componentId, String.format("{%s}",
 348                 role.guidOf(path)));
 349 
 350         boolean isRegistryKeyPath = !systemWide || role.isRegistryKeyPath();
 351         if (isRegistryKeyPath) {
 352             addRegistryKeyPath(xml, directoryRefPath);
 353             if ((role.isFile() || (role == Component.CreateFolder
 354                     && !systemWide)) && !SYSTEM_DIRS.contains(directoryRefPath)) {
 355                 xml.writeStartElement("RemoveFolder");
 356                 int counter = Optional.ofNullable(removeFolderItems.get(
 357                         directoryRefPath)).orElse(Integer.valueOf(0)).intValue() + 1;
 358                 removeFolderItems.put(directoryRefPath, counter);
 359                 xml.writeAttribute("Id", String.format("%s_%d", Id.RemoveFolder.of(
 360                         directoryRefPath), counter));
 361                 xml.writeAttribute("On", "uninstall");
 362                 xml.writeEndElement();
 363             }
 364         }
 365 
 366         xml.writeStartElement(role.name());
 367         if (role != Component.CreateFolder) {
 368             xml.writeAttribute("Id", role.idOf(path));
 369         }
 370 
 371         if (!isRegistryKeyPath) {
 372             xml.writeAttribute("KeyPath", "yes");
 373         }
 374 
 375         xmlConsumer.accept(xml);
 376         xml.writeEndElement();
 377 
 378         xml.writeEndElement(); // <Component>
 379         xml.writeEndElement(); // <DirectoryRef>
 380 
 381         return componentId;
 382     }
 383 
 384     private void addFaComponentGroup(XMLStreamWriter xml)
 385             throws XMLStreamException, IOException {
 386 
 387         List<String> componentIds = new ArrayList<>();
 388         for (var fa : associations) {
 389             componentIds.addAll(addFaComponents(xml, fa));
 390         }
 391         addComponentGroup(xml, "FileAssociations", componentIds);
 392     }
 393 
 394     private void addShortcutComponentGroup(XMLStreamWriter xml) throws
 395             XMLStreamException, IOException {
 396         List<String> componentIds = new ArrayList<>();
 397         Set<ShortcutsFolder> defineShortcutFolders = new HashSet<>();
 398         for (var launcherPath : launcherPaths) {
 399             for (var folder : shortcutFolders) {
 400                 String componentId = addShortcutComponent(xml, launcherPath,
 401                         folder);
 402                 if (componentId != null) {
 403                     defineShortcutFolders.add(folder);
 404                     componentIds.add(componentId);
 405                 }
 406             }
 407         }
 408 
 409         for (var folder : defineShortcutFolders) {
 410             Path path = folder.getPath(this);
 411             componentIds.addAll(addRootBranch(xml, path));
 412         }
 413 
 414         addComponentGroup(xml, "Shortcuts", componentIds);
 415     }
 416 
 417     private String addShortcutComponent(XMLStreamWriter xml, Path launcherPath,
 418             ShortcutsFolder folder) throws XMLStreamException, IOException {
 419         Objects.requireNonNull(folder);
 420 
 421         if (!INSTALLDIR.equals(launcherPath.getName(0))) {
 422             throw throwInvalidPathException(launcherPath);
 423         }
 424 
 425         String launcherBasename = IOUtils.replaceSuffix(
 426                 launcherPath.getFileName(), "").toString();
 427 
 428         Path shortcutPath = folder.getPath(this).resolve(launcherBasename);
 429         return addComponent(xml, shortcutPath, Component.Shortcut, unused -> {
 430             final Path icoFile = IOUtils.addSuffix(
 431                     installedAppImage.destktopIntegrationDirectory().resolve(
 432                             launcherBasename), ".ico");
 433 
 434             xml.writeAttribute("Name", launcherBasename);
 435             xml.writeAttribute("WorkingDirectory", INSTALLDIR.toString());
 436             xml.writeAttribute("Advertise", "no");
 437             xml.writeAttribute("IconIndex", "0");
 438             xml.writeAttribute("Target", String.format("[#%s]",
 439                     Component.File.idOf(launcherPath)));
 440             xml.writeAttribute("Icon", Id.Icon.of(icoFile));
 441         });
 442     }
 443 
 444     private List<String> addFaComponents(XMLStreamWriter xml,
 445             FileAssociation fa) throws XMLStreamException, IOException {
 446         List<String> components = new ArrayList<>();
 447         for (var extension: fa.extensions) {
 448             Path path = INSTALLDIR.resolve(String.format("%s_%s", extension,
 449                     fa.launcherPath.getFileName()));
 450             components.add(addComponent(xml, path, Component.ProgId, unused -> {
 451                 xml.writeAttribute("Description", fa.description);
 452 
 453                 if (fa.iconPath != null) {
 454                     xml.writeAttribute("Icon", Id.File.of(getInstalledFaIcoPath(
 455                             fa)));
 456                     xml.writeAttribute("IconIndex", "0");
 457                 }
 458 
 459                 xml.writeStartElement("Extension");
 460                 xml.writeAttribute("Id", extension);
 461                 xml.writeAttribute("Advertise", "no");
 462 
 463                 var mimeIt = fa.mimeTypes.iterator();
 464                 if (mimeIt.hasNext()) {
 465                     String mime = mimeIt.next();
 466                     xml.writeAttribute("ContentType", mime);
 467 
 468                     if (!defaultedMimes.contains(mime)) {
 469                         xml.writeStartElement("MIME");
 470                         xml.writeAttribute("ContentType", mime);
 471                         xml.writeAttribute("Default", "yes");
 472                         xml.writeEndElement();
 473                         defaultedMimes.add(mime);
 474                     }
 475                 }
 476 
 477                 xml.writeStartElement("Verb");
 478                 xml.writeAttribute("Id", "open");
 479                 xml.writeAttribute("Command", "Open");
 480                 xml.writeAttribute("Argument", "%1");
 481                 xml.writeAttribute("TargetFile", Id.File.of(fa.launcherPath));
 482                 xml.writeEndElement(); // <Verb>
 483 
 484                 xml.writeEndElement(); // <Extension>
 485             }));
 486         }
 487 
 488         return components;
 489     }
 490 
 491     private List<String> addRootBranch(XMLStreamWriter xml, Path path)
 492             throws XMLStreamException, IOException {
 493         if (!ROOT_DIRS.contains(path.getName(0))) {
 494             throw throwInvalidPathException(path);
 495         }
 496 
 497         Function<Path, String> createDirectoryName = dir -> null;
 498 
 499         boolean sysDir = true;
 500         int levels = 1;
 501         var dirIt = path.iterator();
 502         xml.writeStartElement("DirectoryRef");
 503         xml.writeAttribute("Id", dirIt.next().toString());
 504 
 505         path = path.getName(0);
 506         while (dirIt.hasNext()) {
 507             levels++;
 508             Path name = dirIt.next();
 509             path = path.resolve(name);
 510 
 511             if (sysDir && !SYSTEM_DIRS.contains(path)) {
 512                 sysDir = false;
 513                 createDirectoryName = dir -> dir.getFileName().toString();
 514             }
 515 
 516             final String directoryId;
 517             if (!sysDir && path.equals(installDir)) {
 518                 directoryId = INSTALLDIR.toString();
 519             } else {
 520                 directoryId = Id.Folder.of(path);
 521             }
 522             xml.writeStartElement("Directory");
 523             xml.writeAttribute("Id", directoryId);
 524 
 525             String directoryName = createDirectoryName.apply(path);
 526             if (directoryName != null) {
 527                 xml.writeAttribute("Name", directoryName);
 528             }
 529         }
 530 
 531         while (0 != levels--) {
 532             xml.writeEndElement();
 533         }
 534 
 535         List<String> componentIds = new ArrayList<>();
 536         while (!SYSTEM_DIRS.contains(path = path.getParent())) {
 537             componentIds.add(addRemoveDirectoryComponent(xml, path));
 538         }
 539 
 540         return componentIds;
 541     }
 542 
 543     private String addRemoveDirectoryComponent(XMLStreamWriter xml, Path path)
 544             throws XMLStreamException, IOException {
 545         return addComponent(xml, path, Component.RemoveFolder,
 546                 unused -> xml.writeAttribute("On", "uninstall"));
 547     }
 548 
 549     private List<String> addDirectoryHierarchy(XMLStreamWriter xml)
 550             throws XMLStreamException, IOException {
 551 
 552         Set<Path> allDirs = new HashSet<>();
 553         Set<Path> emptyDirs = new HashSet<>();
 554         appImage.transform(installedAppImage, new PathGroup.TransformHandler() {
 555             @Override
 556             public void copyFile(Path src, Path dst) throws IOException {
 557                 Path dir = dst.getParent();
 558                 createDirectory(dir);
 559                 emptyDirs.remove(dir);
 560             }
 561 
 562             @Override
 563             public void createDirectory(final Path dir) throws IOException {
 564                 if (!allDirs.contains(dir)) {
 565                     emptyDirs.add(dir);
 566                 }
 567 
 568                 Path it = dir;
 569                 while (it != null && allDirs.add(it)) {
 570                     it = it.getParent();
 571                 }
 572 
 573                 it = dir;
 574                 while ((it = it.getParent()) != null && emptyDirs.remove(it));
 575             }
 576         });
 577 
 578         List<String> componentIds = new ArrayList<>();
 579         for (var dir : emptyDirs) {
 580             componentIds.add(addComponent(xml, dir, Component.CreateFolder,
 581                     unused -> {}));
 582         }
 583 
 584         if (!systemWide) {
 585             // Per-user install requires <RemoveFolder> component in every
 586             // directory.
 587             for (var dir : allDirs.stream()
 588                     .filter(Predicate.not(emptyDirs::contains))
 589                     .filter(Predicate.not(removeFolderItems::containsKey))
 590                     .collect(Collectors.toList())) {
 591                 componentIds.add(addRemoveDirectoryComponent(xml, dir));
 592             }
 593         }
 594 
 595         allDirs.remove(INSTALLDIR);
 596         for (var dir : allDirs) {
 597             xml.writeStartElement("DirectoryRef");
 598             xml.writeAttribute("Id", Id.Folder.of(dir.getParent()));
 599             xml.writeStartElement("Directory");
 600             xml.writeAttribute("Id", Id.Folder.of(dir));
 601             xml.writeAttribute("Name", dir.getFileName().toString());
 602             xml.writeEndElement();
 603             xml.writeEndElement();
 604         }
 605 
 606         componentIds.addAll(addRootBranch(xml, installDir));
 607 
 608         return componentIds;
 609     }
 610 
 611     private void addFilesComponentGroup(XMLStreamWriter xml)
 612             throws XMLStreamException, IOException {
 613 
 614         List<Map.Entry<Path, Path>> files = new ArrayList<>();
 615         appImage.transform(installedAppImage, new PathGroup.TransformHandler() {
 616             @Override
 617             public void copyFile(Path src, Path dst) throws IOException {
 618                 files.add(Map.entry(src, dst));
 619             }
 620 
 621             @Override
 622             public void createDirectory(final Path dir) throws IOException {
 623             }
 624         });
 625 
 626         List<String> componentIds = new ArrayList<>();
 627         for (var file : files) {
 628             Path src = file.getKey();
 629             Path dst = file.getValue();
 630 
 631             componentIds.add(addComponent(xml, dst, Component.File, unused -> {
 632                 xml.writeAttribute("Source", src.normalize().toString());
 633                 Path name = dst.getFileName();
 634                 if (!name.equals(src.getFileName())) {
 635                     xml.writeAttribute("Name", name.toString());
 636                 }
 637             }));
 638         }
 639 
 640         componentIds.addAll(addDirectoryHierarchy(xml));
 641 
 642         componentIds.add(addDirectoryCleaner(xml, INSTALLDIR));
 643 
 644         addComponentGroup(xml, "Files", componentIds);
 645     }
 646 
 647     private void addIconsFragment(XMLStreamWriter xml) throws
 648             XMLStreamException, IOException {
 649 
 650         PathGroup srcPathGroup = appImage.pathGroup();
 651         PathGroup dstPathGroup = installedAppImage.pathGroup();
 652 
 653         // Build list of copy operations for all .ico files in application image
 654         List<Map.Entry<Path, Path>> icoFiles = new ArrayList<>();
 655         srcPathGroup.transform(dstPathGroup, new PathGroup.TransformHandler() {
 656             @Override
 657             public void copyFile(Path src, Path dst) throws IOException {
 658                 if (src.getFileName().toString().endsWith(".ico")) {
 659                     icoFiles.add(Map.entry(src, dst));
 660                 }
 661             }
 662 
 663             @Override
 664             public void createDirectory(Path dst) throws IOException {
 665             }
 666         });
 667 
 668         xml.writeStartElement("Fragment");
 669         for (var icoFile : icoFiles) {
 670             xml.writeStartElement("Icon");
 671             xml.writeAttribute("Id", Id.Icon.of(icoFile.getValue()));
 672             xml.writeAttribute("SourceFile", icoFile.getKey().toString());
 673             xml.writeEndElement();
 674         }
 675         xml.writeEndElement();
 676     }
 677 
 678     private void addRegistryKeyPath(XMLStreamWriter xml, Path path) throws
 679             XMLStreamException, IOException {
 680         addRegistryKeyPath(xml, path, () -> "ProductCode", () -> "[ProductCode]");
 681     }
 682 
 683     private void addRegistryKeyPath(XMLStreamWriter xml, Path path,
 684             Supplier<String> nameAttr, Supplier<String> valueAttr) throws
 685             XMLStreamException, IOException {
 686 
 687         String regRoot = USER_PROFILE_DIRS.stream().anyMatch(path::startsWith)
 688                 || !systemWide ? "HKCU" : "HKLM";
 689 
 690         xml.writeStartElement("RegistryKey");
 691         xml.writeAttribute("Root", regRoot);
 692         xml.writeAttribute("Key", registryKeyPath);
 693         if (wixVersion.compareTo("3.6") < 0) {
 694             xml.writeAttribute("Action", "createAndRemoveOnUninstall");
 695         }
 696         xml.writeStartElement("RegistryValue");
 697         xml.writeAttribute("Type", "string");
 698         xml.writeAttribute("KeyPath", "yes");
 699         xml.writeAttribute("Name", nameAttr.get());
 700         xml.writeAttribute("Value", valueAttr.get());
 701         xml.writeEndElement(); // <RegistryValue>
 702         xml.writeEndElement(); // <RegistryKey>
 703     }
 704 
 705     private String addDirectoryCleaner(XMLStreamWriter xml, Path path) throws
 706             XMLStreamException, IOException {
 707         if (wixVersion.compareTo("3.6") < 0) {
 708             return null;
 709         }
 710 
 711         // rm -rf
 712         final String baseId = Id.of(path, "rm_rf");
 713         final String propertyId = baseId.toUpperCase();
 714         final String componentId = ("c" + baseId);
 715 
 716         xml.writeStartElement("Property");
 717         xml.writeAttribute("Id", propertyId);
 718         xml.writeStartElement("RegistrySearch");
 719         xml.writeAttribute("Id", Id.of(path, "regsearch"));
 720         xml.writeAttribute("Root", systemWide ? "HKLM" : "HKCU");
 721         xml.writeAttribute("Key", registryKeyPath);
 722         xml.writeAttribute("Type", "raw");
 723         xml.writeAttribute("Name", propertyId);
 724         xml.writeEndElement(); // <RegistrySearch>
 725         xml.writeEndElement(); // <Property>
 726 
 727         xml.writeStartElement("DirectoryRef");
 728         xml.writeAttribute("Id", INSTALLDIR.toString());
 729         Component.startElement(xml, componentId, "*");
 730 
 731         addRegistryKeyPath(xml, INSTALLDIR, () -> propertyId, () -> {
 732             // The following code converts a path to value to be saved in registry.
 733             // E.g.:
 734             //  INSTALLDIR -> [INSTALLDIR]
 735             //  TERGETDIR/ProgramFiles64Folder/foo/bar -> [ProgramFiles64Folder]foo/bar
 736             final Path rootDir = KNOWN_DIRS.stream()
 737                     .sorted(Comparator.comparing(Path::getNameCount).reversed())
 738                     .filter(path::startsWith)
 739                     .findFirst().get();
 740             StringBuilder sb = new StringBuilder();
 741             sb.append(String.format("[%s]", rootDir.getFileName().toString()));
 742             sb.append(rootDir.relativize(path).toString());
 743             return sb.toString();
 744         });
 745 
 746         xml.writeStartElement(
 747                 "http://schemas.microsoft.com/wix/UtilExtension",
 748                 "RemoveFolderEx");
 749         xml.writeAttribute("On", "uninstall");
 750         xml.writeAttribute("Property", propertyId);
 751         xml.writeEndElement(); // <RemoveFolderEx>
 752         xml.writeEndElement(); // <Component>
 753         xml.writeEndElement(); // <DirectoryRef>
 754 
 755         return componentId;
 756     }
 757 
 758     private static IllegalArgumentException throwInvalidPathException(Path v) {
 759         throw new IllegalArgumentException(String.format("Invalid path [%s]", v));
 760     }
 761 
 762     enum ShortcutsFolder {
 763         ProgramMenu(PROGRAM_MENU_PATH),
 764         Desktop(DESKTOP_PATH);
 765 
 766         private ShortcutsFolder(Path root) {
 767             this.root = root;
 768         }
 769 
 770         Path getPath(WixSourcesBuilder outer) {
 771             if (this == ProgramMenu) {
 772                 return root.resolve(outer.programMenuFolderName);
 773             }
 774             return root;
 775         }
 776 
 777         private final Path root;
 778     }
 779 
 780     private DottedVersion wixVersion;
 781 
 782     private boolean systemWide;
 783 
 784     private String registryKeyPath;
 785 
 786     private Path installDir;
 787 
 788     private String programMenuFolderName;
 789 
 790     private List<FileAssociation> associations;
 791 
 792     private Set<ShortcutsFolder> shortcutFolders;
 793 
 794     private List<Path> launcherPaths;
 795 
 796     private ApplicationLayout appImage;
 797     private ApplicationLayout installedAppImage;
 798 
 799     private Map<Path, Integer> removeFolderItems;
 800     private Set<String> defaultedMimes;
 801 
 802     private final static Path TARGETDIR = Path.of("TARGETDIR");
 803 
 804     private final static Path INSTALLDIR = Path.of("INSTALLDIR");
 805 
 806     private final static Set<Path> ROOT_DIRS = Set.of(INSTALLDIR, TARGETDIR);
 807 
 808     private final static Path PROGRAM_MENU_PATH = TARGETDIR.resolve("ProgramMenuFolder");
 809 
 810     private final static Path DESKTOP_PATH = TARGETDIR.resolve("DesktopFolder");
 811 
 812     private final static Path PROGRAM_FILES = TARGETDIR.resolve("ProgramFiles64Folder");
 813 
 814     private final static Path LOCAL_PROGRAM_FILES = TARGETDIR.resolve("LocalAppDataFolder");
 815 
 816     private final static Set<Path> SYSTEM_DIRS = Set.of(TARGETDIR,
 817             PROGRAM_MENU_PATH, DESKTOP_PATH, PROGRAM_FILES, LOCAL_PROGRAM_FILES);
 818 
 819     private final static Set<Path> KNOWN_DIRS = Stream.of(Set.of(INSTALLDIR),
 820             SYSTEM_DIRS).flatMap(Set::stream).collect(
 821             Collectors.toUnmodifiableSet());
 822 
 823     private final static Set<Path> USER_PROFILE_DIRS = Set.of(LOCAL_PROGRAM_FILES,
 824             PROGRAM_MENU_PATH, DESKTOP_PATH);
 825 
 826     private static final StandardBundlerParam<Boolean> MENU_HINT =
 827         new WindowsBundlerParam<>(
 828                 Arguments.CLIOptions.WIN_MENU_HINT.getId(),
 829                 Boolean.class,
 830                 params -> false,
 831                 // valueOf(null) is false,
 832                 // and we actually do want null in some cases
 833                 (s, p) -> (s == null ||
 834                         "null".equalsIgnoreCase(s))? true : Boolean.valueOf(s)
 835         );
 836 
 837     private static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
 838         new WindowsBundlerParam<>(
 839                 Arguments.CLIOptions.WIN_SHORTCUT_HINT.getId(),
 840                 Boolean.class,
 841                 params -> false,
 842                 // valueOf(null) is false,
 843                 // and we actually do want null in some cases
 844                 (s, p) -> (s == null ||
 845                        "null".equalsIgnoreCase(s))? false : Boolean.valueOf(s)
 846         );
 847 }