1 /*
   2  * Copyright (c) 2012, 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.jpackage.internal;
  27 
  28 import java.io.*;
  29 import java.nio.charset.Charset;
  30 import java.nio.file.Files;
  31 import java.nio.file.Paths;
  32 import java.text.MessageFormat;
  33 import java.util.*;
  34 import java.util.regex.Pattern;
  35 import javax.xml.stream.XMLOutputFactory;
  36 import javax.xml.stream.XMLStreamException;
  37 import javax.xml.stream.XMLStreamWriter;
  38 
  39 import static jdk.jpackage.internal.WindowsBundlerParam.*;
  40 
  41 /**
  42  * WinMsiBundler
  43  *
  44  * Produces .msi installer from application image. Uses WiX Toolkit to build
  45  * .msi installer.
  46  * <p>
  47  * {@link #execute} method creates a number of source files with the description
  48  * of installer to be processed by WiX tools. Generated source files are stored
  49  * in "config" subdirectory next to "app" subdirectory in the root work
  50  * directory. The following WiX source files are generated:
  51  * <ul>
  52  * <li>main.wxs. Main source file with the installer description
  53  * <li>bundle.wxi. Source file with application and Java run-time directory tree
  54  * description. This source file is included from main.wxs
  55  * <li>icons.wxi. Source file with the list of icons used by the application.
  56  * This source file is included from main.wxs
  57  * </ul>
  58  * <p>
  59  * main.wxs file is a copy of main.wxs resource from
  60  * jdk.jpackage.internal.resources package. It is parametrized with the
  61  * following WiX variables:
  62  * <ul>
  63  * <li>JpAppName. Name of the application. Set to the value of --name command
  64  * line option
  65  * <li>JpAppVersion. Version of the application. Set to the value of
  66  * --app-version command line option
  67  * <li>JpAppVendor. Vendor of the application. Set to the value of --vendor
  68  * command line option
  69  * <li>JpAppDescription. Description of the application. Set to the value of
  70  * --description command line option
  71  * <li>JpProductCode. Set to product code UUID of the application. Random value
  72  * generated by jpackage every time {@link #execute} method is called
  73  * <li>JpProductUpgradeCode. Set to upgrade code UUID of the application. Random
  74  * value generated by jpackage every time {@link #execute} method is called if
  75  * --win-upgrade-uuid command line option is not specified. Otherwise this
  76  * variable is set to the value of --win-upgrade-uuid command line option
  77  * <li>JpAllowDowngrades. Set to "yes" if --win-upgrade-uuid command line option
  78  * was specified. Undefined otherwise
  79  * <li>JpLicenseRtf. Set to the value of --license-file command line option.
  80  * Undefined is --license-file command line option was not specified
  81  * <li>JpInstallDirChooser. Set to "yes" if --win-dir-chooser command line
  82  * option was specified. Undefined otherwise
  83  * <li>JpConfigDir. Absolute path to the directory with generated WiX source
  84  * files.
  85  * <li>JpIsSystemWide. Set to "yes" if --win-per-user-install command line
  86  * option was not specified. Undefined otherwise
  87  * <li>JpWixVersion36OrNewer. Set to "yes" if WiX Toolkit v3.6 or newer is used.
  88  * Undefined otherwise
  89  * </ul>
  90  */
  91 public class WinMsiBundler  extends AbstractBundler {
  92 
  93     private static final ResourceBundle I18N = ResourceBundle.getBundle(
  94             "jdk.jpackage.internal.resources.WinResources");
  95 
  96     public static final BundlerParamInfo<WinAppBundler> APP_BUNDLER =
  97             new WindowsBundlerParam<>(
  98             "win.app.bundler",
  99             WinAppBundler.class,
 100             params -> new WinAppBundler(),
 101             null);
 102 
 103     public static final BundlerParamInfo<Boolean> CAN_USE_WIX36 =
 104             new WindowsBundlerParam<>(
 105             "win.msi.canUseWix36",
 106             Boolean.class,
 107             params -> false,
 108             (s, p) -> Boolean.valueOf(s));
 109 
 110     public static final BundlerParamInfo<File> MSI_IMAGE_DIR =
 111             new WindowsBundlerParam<>(
 112             "win.msi.imageDir",
 113             File.class,
 114             params -> {
 115                 File imagesRoot = IMAGES_ROOT.fetchFrom(params);
 116                 if (!imagesRoot.exists()) imagesRoot.mkdirs();
 117                 return new File(imagesRoot, "win-msi.image");
 118             },
 119             (s, p) -> null);
 120 
 121     public static final BundlerParamInfo<File> WIN_APP_IMAGE =
 122             new WindowsBundlerParam<>(
 123             "win.app.image",
 124             File.class,
 125             null,
 126             (s, p) -> null);
 127 
 128     public static final StandardBundlerParam<Boolean> MSI_SYSTEM_WIDE  =
 129             new StandardBundlerParam<>(
 130                     Arguments.CLIOptions.WIN_PER_USER_INSTALLATION.getId(),
 131                     Boolean.class,
 132                     params -> true, // MSIs default to system wide
 133                     // valueOf(null) is false,
 134                     // and we actually do want null
 135                     (s, p) -> (s == null || "null".equalsIgnoreCase(s))? null
 136                             : Boolean.valueOf(s)
 137             );
 138 
 139 
 140     public static final StandardBundlerParam<String> PRODUCT_VERSION =
 141             new StandardBundlerParam<>(
 142                     "win.msi.productVersion",
 143                     String.class,
 144                     VERSION::fetchFrom,
 145                     (s, p) -> s
 146             );
 147 
 148     public static final BundlerParamInfo<UUID> UPGRADE_UUID =
 149             new WindowsBundlerParam<>(
 150             Arguments.CLIOptions.WIN_UPGRADE_UUID.getId(),
 151             UUID.class,
 152             params -> UUID.randomUUID(),
 153             (s, p) -> UUID.fromString(s));
 154 
 155     private static final String TOOL_CANDLE = "candle.exe";
 156     private static final String TOOL_LIGHT = "light.exe";
 157     // autodetect just v3.7, v3.8, 3.9, 3.10 and 3.11
 158     private static final String AUTODETECT_DIRS =
 159             ";C:\\Program Files (x86)\\WiX Toolset v3.11\\bin;"
 160             + "C:\\Program Files\\WiX Toolset v3.11\\bin;"
 161             + "C:\\Program Files (x86)\\WiX Toolset v3.10\\bin;"
 162             + "C:\\Program Files\\WiX Toolset v3.10\\bin;"
 163             + "C:\\Program Files (x86)\\WiX Toolset v3.9\\bin;"
 164             + "C:\\Program Files\\WiX Toolset v3.9\\bin;"
 165             + "C:\\Program Files (x86)\\WiX Toolset v3.8\\bin;"
 166             + "C:\\Program Files\\WiX Toolset v3.8\\bin;"
 167             + "C:\\Program Files (x86)\\WiX Toolset v3.7\\bin;"
 168             + "C:\\Program Files\\WiX Toolset v3.7\\bin";
 169 
 170     private static String getCandlePath() {
 171         for (String dirString : (System.getenv("PATH")
 172                 + AUTODETECT_DIRS).split(";")) {
 173             File f = new File(dirString.replace("\"", ""), TOOL_CANDLE);
 174             if (f.isFile()) {
 175                 return f.toString();
 176             }
 177         }
 178         return null;
 179     }
 180 
 181     private static String getLightPath() {
 182         for (String dirString : (System.getenv("PATH")
 183                 + AUTODETECT_DIRS).split(";")) {
 184             File f = new File(dirString.replace("\"", ""), TOOL_LIGHT);
 185             if (f.isFile()) {
 186                 return f.toString();
 187             }
 188         }
 189         return null;
 190     }
 191         
 192 
 193     public static final StandardBundlerParam<Boolean> MENU_HINT =
 194         new WindowsBundlerParam<>(
 195                 Arguments.CLIOptions.WIN_MENU_HINT.getId(),
 196                 Boolean.class,
 197                 params -> false,
 198                 // valueOf(null) is false,
 199                 // and we actually do want null in some cases
 200                 (s, p) -> (s == null ||
 201                         "null".equalsIgnoreCase(s))? true : Boolean.valueOf(s)
 202         );
 203 
 204     public static final StandardBundlerParam<Boolean> SHORTCUT_HINT =
 205         new WindowsBundlerParam<>(
 206                 Arguments.CLIOptions.WIN_SHORTCUT_HINT.getId(),
 207                 Boolean.class,
 208                 params -> false,
 209                 // valueOf(null) is false,
 210                 // and we actually do want null in some cases
 211                 (s, p) -> (s == null ||
 212                        "null".equalsIgnoreCase(s))? false : Boolean.valueOf(s)
 213         );
 214 
 215     @Override
 216     public String getName() {
 217         return I18N.getString("msi.bundler.name");
 218     }
 219 
 220     @Override
 221     public String getID() {
 222         return "msi";
 223     }
 224 
 225     @Override
 226     public String getBundleType() {
 227         return "INSTALLER";
 228     }
 229 
 230     @Override
 231     public File execute(Map<String, ? super Object> params,
 232             File outputParentDir) throws PackagerException {
 233         return bundle(params, outputParentDir);
 234     }
 235 
 236     @Override
 237     public boolean supported(boolean platformInstaller) {
 238         return isSupported();
 239     }
 240 
 241     public static boolean isSupported() {
 242         try {
 243             return validateWixTools();
 244         } catch (Exception e) {
 245             return false;
 246         }
 247     }
 248 
 249     private static String findToolVersion(String toolName) {
 250         try {
 251             if (toolName == null || "".equals(toolName)) return null;
 252 
 253             ProcessBuilder pb = new ProcessBuilder(
 254                     toolName,
 255                     "/?");
 256             VersionExtractor ve = new VersionExtractor("version (\\d+.\\d+)");
 257             // not interested in the output
 258             IOUtils.exec(pb, true, ve);
 259             String version = ve.getVersion();
 260             Log.verbose(MessageFormat.format(
 261                     I18N.getString("message.tool-version"),
 262                     toolName, version));
 263             return version;
 264         } catch (Exception e) {
 265             if (Log.isDebug()) {
 266                 Log.verbose(e);
 267             }
 268             return null;
 269         }
 270     }
 271 
 272     public static boolean validateWixTools() {
 273         String candleVersion = findToolVersion(getCandlePath());
 274         String lightVersion = findToolVersion(getLightPath());
 275 
 276         // WiX 3.0+ is required
 277         String minVersion = "3.0";
 278 
 279         if (VersionExtractor.isLessThan(candleVersion, minVersion)) {
 280             Log.verbose(MessageFormat.format(
 281                     I18N.getString("message.wrong-tool-version"),
 282                     TOOL_CANDLE, candleVersion, minVersion));
 283             return false;
 284         }
 285         if (VersionExtractor.isLessThan(lightVersion, minVersion)) {
 286             Log.verbose(MessageFormat.format(
 287                     I18N.getString("message.wrong-tool-version"),
 288                     TOOL_LIGHT, lightVersion, minVersion));
 289             return false;
 290         }
 291         return true;
 292     }
 293 
 294     @Override
 295     public boolean validate(Map<String, ? super Object> params)
 296             throws ConfigException {
 297         try {
 298             if (params == null) throw new ConfigException(
 299                     I18N.getString("error.parameters-null"),
 300                     I18N.getString("error.parameters-null.advice"));
 301 
 302             // run basic validation to ensure requirements are met
 303             // we are not interested in return code, only possible exception
 304             if (!validateWixTools()){
 305                 throw new ConfigException(
 306                         I18N.getString("error.no-wix-tools"),
 307                         I18N.getString("error.no-wix-tools.advice"));
 308             }
 309 
 310             String lightVersion = findToolVersion(getLightPath());
 311             if (!VersionExtractor.isLessThan(lightVersion, "3.6")) {
 312                 Log.verbose(I18N.getString("message.use-wix36-features"));
 313                 params.put(CAN_USE_WIX36.getID(), Boolean.TRUE);
 314             }
 315 
 316             /********* validate bundle parameters *************/
 317 
 318             String version = PRODUCT_VERSION.fetchFrom(params);
 319             if (!isVersionStringValid(version)) {
 320                 throw new ConfigException(
 321                         MessageFormat.format(I18N.getString(
 322                                 "error.version-string-wrong-format"), version),
 323                         MessageFormat.format(I18N.getString(
 324                                 "error.version-string-wrong-format.advice"),
 325                                 PRODUCT_VERSION.getID()));
 326             }
 327 
 328             // only one mime type per association, at least one file extension
 329             List<Map<String, ? super Object>> associations =
 330                     FILE_ASSOCIATIONS.fetchFrom(params);
 331             if (associations != null) {
 332                 for (int i = 0; i < associations.size(); i++) {
 333                     Map<String, ? super Object> assoc = associations.get(i);
 334                     List<String> mimes = FA_CONTENT_TYPE.fetchFrom(assoc);
 335                     if (mimes.size() > 1) {
 336                         throw new ConfigException(MessageFormat.format(
 337                                 I18N.getString("error.too-many-content-"
 338                                 + "types-for-file-association"), i),
 339                                 I18N.getString("error.too-many-content-"
 340                                 + "types-for-file-association.advice"));
 341                     }
 342                 }
 343             }
 344 
 345             return true;
 346         } catch (RuntimeException re) {
 347             if (re.getCause() instanceof ConfigException) {
 348                 throw (ConfigException) re.getCause();
 349             } else {
 350                 throw new ConfigException(re);
 351             }
 352         }
 353     }
 354 
 355     // http://msdn.microsoft.com/en-us/library/aa370859%28v=VS.85%29.aspx
 356     // The format of the string is as follows:
 357     //     major.minor.build
 358     // The first field is the major version and has a maximum value of 255.
 359     // The second field is the minor version and has a maximum value of 255.
 360     // The third field is called the build version or the update version and
 361     // has a maximum value of 65,535.
 362     static boolean isVersionStringValid(String v) {
 363         if (v == null) {
 364             return true;
 365         }
 366 
 367         String p[] = v.split("\\.");
 368         if (p.length > 3) {
 369             Log.verbose(I18N.getString(
 370                     "message.version-string-too-many-components"));
 371             return false;
 372         }
 373 
 374         try {
 375             int val = Integer.parseInt(p[0]);
 376             if (val < 0 || val > 255) {
 377                 Log.verbose(I18N.getString(
 378                         "error.version-string-major-out-of-range"));
 379                 return false;
 380             }
 381             if (p.length > 1) {
 382                 val = Integer.parseInt(p[1]);
 383                 if (val < 0 || val > 255) {
 384                     Log.verbose(I18N.getString(
 385                             "error.version-string-minor-out-of-range"));
 386                     return false;
 387                 }
 388             }
 389             if (p.length > 2) {
 390                 val = Integer.parseInt(p[2]);
 391                 if (val < 0 || val > 65535) {
 392                     Log.verbose(I18N.getString(
 393                             "error.version-string-build-out-of-range"));
 394                     return false;
 395                 }
 396             }
 397         } catch (NumberFormatException ne) {
 398             Log.verbose(I18N.getString("error.version-string-part-not-number"));
 399             Log.verbose(ne);
 400             return false;
 401         }
 402 
 403         return true;
 404     }
 405 
 406     private boolean prepareProto(Map<String, ? super Object> params)
 407                 throws PackagerException, IOException {
 408         File appImage = StandardBundlerParam.getPredefinedAppImage(params);
 409         File appDir = null;
 410 
 411         // we either have an application image or need to build one
 412         if (appImage != null) {
 413             appDir = new File(MSI_IMAGE_DIR.fetchFrom(params),
 414                     APP_NAME.fetchFrom(params));
 415             // copy everything from appImage dir into appDir/name
 416             IOUtils.copyRecursive(appImage.toPath(), appDir.toPath());
 417         } else {
 418             appDir = APP_BUNDLER.fetchFrom(params).doBundle(params,
 419                     MSI_IMAGE_DIR.fetchFrom(params), true);
 420         }
 421 
 422         params.put(WIN_APP_IMAGE.getID(), appDir);
 423 
 424         String licenseFile = LICENSE_FILE.fetchFrom(params);
 425         if (licenseFile != null) {
 426             // need to copy license file to the working directory and convert to rtf if needed
 427             File lfile = new File(licenseFile);
 428             File destFile = new File(CONFIG_ROOT.fetchFrom(params),
 429                     lfile.getName());
 430 
 431             IOUtils.copyFile(lfile, destFile);
 432             destFile.setWritable(true);
 433             ensureByMutationFileIsRTF(destFile);
 434         }
 435 
 436         // copy file association icons
 437         List<Map<String, ? super Object>> fileAssociations =
 438                 FILE_ASSOCIATIONS.fetchFrom(params);
 439         for (Map<String, ? super Object> fa : fileAssociations) {
 440             File icon = FA_ICON.fetchFrom(fa);
 441             if (icon == null) {
 442                 continue;
 443             }
 444 
 445             File faIconFile = new File(appDir, icon.getName());
 446 
 447             if (icon.exists()) {
 448                 try {
 449                     IOUtils.copyFile(icon, faIconFile);
 450                 } catch (IOException e) {
 451                     Log.verbose(e);
 452                 }
 453             }
 454         }
 455 
 456         return appDir != null;
 457     }
 458 
 459     public File bundle(Map<String, ? super Object> params, File outdir)
 460             throws PackagerException {
 461 
 462         IOUtils.writableOutputDir(outdir.toPath());
 463 
 464         // validate we have valid tools before continuing
 465         String light = getLightPath();
 466         String candle = getCandlePath();
 467         if (light == null || !new File(light).isFile() ||
 468             candle == null || !new File(candle).isFile()) {
 469             Log.verbose(MessageFormat.format(
 470                    I18N.getString("message.light-file-string"), light));
 471             Log.verbose(MessageFormat.format(
 472                    I18N.getString("message.candle-file-string"), candle));
 473             throw new PackagerException("error.no-wix-tools");
 474         }
 475 
 476         Map<String, String> wixVars = null;
 477 
 478         File imageDir = MSI_IMAGE_DIR.fetchFrom(params);
 479         try {
 480             imageDir.mkdirs();
 481 
 482             boolean menuShortcut = MENU_HINT.fetchFrom(params);
 483             boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(params);
 484             if (!menuShortcut && !desktopShortcut) {
 485                 // both can not be false - user will not find the app
 486                 Log.verbose(I18N.getString("message.one-shortcut-required"));
 487                 params.put(MENU_HINT.getID(), true);
 488             }
 489 
 490             prepareBasicProjectConfig(params);
 491             if (prepareProto(params)) {
 492                 wixVars = prepareWiXConfig(params);
 493 
 494                 File configScriptSrc = getConfig_Script(params);
 495                 if (configScriptSrc.exists()) {
 496                     // we need to be running post script in the image folder
 497 
 498                     // NOTE: Would it be better to generate it to the image
 499                     // folder and save only if "verbose" is requested?
 500 
 501                     // for now we replicate it
 502                     File configScript =
 503                         new File(imageDir, configScriptSrc.getName());
 504                     IOUtils.copyFile(configScriptSrc, configScript);
 505                     Log.verbose(MessageFormat.format(
 506                             I18N.getString("message.running-wsh-script"),
 507                             configScript.getAbsolutePath()));
 508                     IOUtils.run("wscript", configScript);
 509                 }
 510                 return buildMSI(params, wixVars, outdir);
 511             }
 512             return null;
 513         } catch (IOException ex) {
 514             Log.verbose(ex);
 515             throw new PackagerException(ex);
 516         }
 517     }
 518 
 519     // name of post-image script
 520     private File getConfig_Script(Map<String, ? super Object> params) {
 521         return new File(CONFIG_ROOT.fetchFrom(params),
 522                 APP_NAME.fetchFrom(params) + "-post-image.wsf");
 523     }
 524 
 525     private void prepareBasicProjectConfig(
 526         Map<String, ? super Object> params) throws IOException {
 527         fetchResource(getConfig_Script(params).getName(),
 528                 I18N.getString("resource.post-install-script"),
 529                 (String) null,
 530                 getConfig_Script(params),
 531                 VERBOSE.fetchFrom(params),
 532                 RESOURCE_DIR.fetchFrom(params));
 533     }
 534 
 535     private static String relativePath(File basedir, File file) {
 536         return file.getAbsolutePath().substring(
 537                 basedir.getAbsolutePath().length() + 1);
 538     }
 539 
 540     private void prepareIconsFile(
 541             Map<String, ? super Object> params) throws IOException {
 542 
 543         File imageRootDir = WIN_APP_IMAGE.fetchFrom(params);
 544 
 545         List<Map<String, ? super Object>> addLaunchers =
 546                 ADD_LAUNCHERS.fetchFrom(params);
 547 
 548         XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance();
 549         try (Writer w = new BufferedWriter(new FileWriter(new File(
 550                 CONFIG_ROOT.fetchFrom(params), "icons.wxi")))) {
 551             XMLStreamWriter xml = xmlFactory.createXMLStreamWriter(w);
 552 
 553             xml.writeStartDocument();
 554             xml.writeStartElement("Include");
 555 
 556             File launcher = new File(imageRootDir,
 557                     WinAppBundler.getLauncherRelativePath(params));
 558             if (launcher.exists()) {
 559                 String iconPath = launcher.getAbsolutePath().replace(".exe", ".ico");
 560                 if (MENU_HINT.fetchFrom(params)) {
 561                     xml.writeStartElement("Icon");
 562                     xml.writeAttribute("Id", "StartMenuIcon.exe");
 563                     xml.writeAttribute("SourceFile", iconPath);
 564                     xml.writeEndElement();
 565                 }
 566                 if (SHORTCUT_HINT.fetchFrom(params)) {
 567                     xml.writeStartElement("Icon");
 568                     xml.writeAttribute("Id", "DesktopIcon.exe");
 569                     xml.writeAttribute("SourceFile", iconPath);
 570                     xml.writeEndElement();
 571                 }
 572             }
 573 
 574             for (int i = 0; i < addLaunchers.size(); i++) {
 575                 Map<String, ? super Object> sl = addLaunchers.get(i);
 576                 if (SHORTCUT_HINT.fetchFrom(sl) || MENU_HINT.fetchFrom(sl)) {
 577                     File addLauncher = new File(imageRootDir,
 578                             WinAppBundler.getLauncherRelativePath(sl));
 579                     String addLauncherPath
 580                             = relativePath(imageRootDir, addLauncher);
 581                     String addLauncherIconPath
 582                             = addLauncherPath.replace(".exe", ".ico");
 583 
 584                     xml.writeStartElement("Icon");
 585                     xml.writeAttribute("Id", "Launcher" + i + ".exe");
 586                     xml.writeAttribute("SourceFile", addLauncherIconPath);
 587                     xml.writeEndElement();
 588                 }
 589             }
 590 
 591             xml.writeEndElement();
 592             xml.writeEndDocument();
 593             xml.flush();
 594             xml.close();
 595         } catch (XMLStreamException ex) {
 596             Log.verbose(ex);
 597             throw new IOException(ex);
 598         }
 599     }
 600 
 601     Map<String, String> prepareMainProjectFile(
 602             Map<String, ? super Object> params) throws IOException {
 603         Map<String, String> data = new HashMap<>();
 604 
 605         UUID productGUID = UUID.randomUUID();
 606 
 607         Log.verbose(MessageFormat.format(
 608                 I18N.getString("message.generated-product-guid"),
 609                 productGUID.toString()));
 610 
 611         // we use random GUID for product itself but
 612         // user provided for upgrade guid
 613         // Upgrade guid is important to decide whether it is an upgrade of
 614         // installed app.  I.e. we need it to be the same for
 615         // 2 different versions of app if possible
 616         data.put("JpProductCode", productGUID.toString());
 617         data.put("JpProductUpgradeCode",
 618                 UPGRADE_UUID.fetchFrom(params).toString());
 619 
 620         if (!UPGRADE_UUID.getIsDefaultValue()) {
 621             data.put("JpAllowDowngrades", "yes");
 622         }
 623 
 624         if (CAN_USE_WIX36.fetchFrom(params)) {
 625             data.put("JpWixVersion36OrNewer", "yes");
 626         }
 627 
 628         data.put("JpAppName", APP_NAME.fetchFrom(params));
 629         data.put("JpAppDescription", DESCRIPTION.fetchFrom(params));
 630         data.put("JpAppVendor", VENDOR.fetchFrom(params));
 631         data.put("JpAppVersion", PRODUCT_VERSION.fetchFrom(params));
 632 
 633         data.put("JpConfigDir", CONFIG_ROOT.fetchFrom(params).getAbsolutePath());
 634 
 635         File imageRootDir = WIN_APP_IMAGE.fetchFrom(params);
 636 
 637         if (MSI_SYSTEM_WIDE.fetchFrom(params)) {
 638             data.put("JpIsSystemWide", "yes");
 639         }
 640 
 641         String licenseFile = LICENSE_FILE.fetchFrom(params);
 642         if (licenseFile != null) {
 643             String lname = new File(licenseFile).getName();
 644             File destFile = new File(CONFIG_ROOT.fetchFrom(params), lname);
 645             data.put("JpLicenseRtf", destFile.getAbsolutePath());
 646         }
 647 
 648         // Copy CA dll to include with installer
 649         if (INSTALLDIR_CHOOSER.fetchFrom(params)) {
 650             data.put("JpInstallDirChooser", "yes");
 651             String fname = "wixhelper.dll";
 652             try (InputStream is = getResourceAsStream(fname)) {
 653                 Files.copy(is, Paths.get(
 654                         CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), fname));
 655             }
 656         }
 657 
 658         // Copy l10n files.
 659         for (String loc : Arrays.asList("en", "ja", "zh_CN")) {
 660             String fname = "MsiInstallerStrings_" + loc + ".wxl";
 661             try (InputStream is = getResourceAsStream(fname)) {
 662                 Files.copy(is, Paths.get(
 663                         CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), fname));
 664             }
 665         }
 666 
 667         try (InputStream is = getResourceAsStream("main.wxs")) {
 668             Files.copy(is, Paths.get(
 669                     getConfig_ProjectFile(params).getAbsolutePath()));
 670         }
 671 
 672         return data;
 673     }
 674     private int id;
 675     private int compId;
 676     private final static String LAUNCHER_ID = "LauncherId";
 677 
 678     private void walkFileTree(Map<String, ? super Object> params,
 679             File root, PrintStream out, String prefix) {
 680         List<File> dirs = new ArrayList<>();
 681         List<File> files = new ArrayList<>();
 682 
 683         if (!root.isDirectory()) {
 684             throw new RuntimeException(
 685                     MessageFormat.format(
 686                             I18N.getString("error.cannot-walk-directory"),
 687                             root.getAbsolutePath()));
 688         }
 689 
 690         // sort to files and dirs
 691         File[] children = root.listFiles();
 692         if (children != null) {
 693             for (File f : children) {
 694                 if (f.isDirectory()) {
 695                     dirs.add(f);
 696                 } else {
 697                     files.add(f);
 698                 }
 699             }
 700         }
 701 
 702         // have files => need to output component
 703         out.println(prefix + " <Component Id=\"comp" + (compId++)
 704                 + "\" DiskId=\"1\""
 705                 + " Guid=\"" + UUID.randomUUID().toString() + "\""
 706                 + " Win64=\"yes\""
 707                 + ">");
 708         out.println(prefix + "  <CreateFolder/>");
 709         out.println(prefix + "  <RemoveFolder Id=\"RemoveDir"
 710                 + (id++) + "\" On=\"uninstall\" />");
 711 
 712         boolean needRegistryKey = !MSI_SYSTEM_WIDE.fetchFrom(params);
 713         File imageRootDir = WIN_APP_IMAGE.fetchFrom(params);
 714         File launcherFile = new File(imageRootDir,
 715                 WinAppBundler.getLauncherRelativePath(params));
 716 
 717         // Find out if we need to use registry. We need it if
 718         //  - we doing user level install as file can not serve as KeyPath
 719         //  - if we adding shortcut in this component
 720 
 721         for (File f: files) {
 722             boolean isLauncher = f.equals(launcherFile);
 723             if (isLauncher) {
 724                 needRegistryKey = true;
 725             }
 726         }
 727 
 728         if (needRegistryKey) {
 729             // has to be under HKCU to make WiX happy
 730             out.println(prefix + "    <RegistryKey Root=\"HKCU\" "
 731                     + " Key=\"Software\\" + VENDOR.fetchFrom(params) + "\\"
 732                     + APP_NAME.fetchFrom(params) + "\""
 733                     + (CAN_USE_WIX36.fetchFrom(params) ?
 734                     ">" : " Action=\"createAndRemoveOnUninstall\">"));
 735             out.println(prefix
 736                     + "     <RegistryValue Name=\"Version\" Value=\""
 737                     + VERSION.fetchFrom(params)
 738                     + "\" Type=\"string\" KeyPath=\"yes\"/>");
 739             out.println(prefix + "   </RegistryKey>");
 740         }
 741 
 742         boolean menuShortcut = MENU_HINT.fetchFrom(params);
 743         boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(params);
 744 
 745         Map<String, String> idToFileMap = new TreeMap<>();
 746         boolean launcherSet = false;
 747 
 748         for (File f : files) {
 749             boolean isLauncher = f.equals(launcherFile);
 750 
 751             launcherSet = launcherSet || isLauncher;
 752 
 753             boolean doShortcuts =
 754                 isLauncher && (menuShortcut || desktopShortcut);
 755 
 756             String thisFileId = isLauncher ? LAUNCHER_ID : ("FileId" + (id++));
 757             idToFileMap.put(f.getName(), thisFileId);
 758 
 759             out.println(prefix + "   <File Id=\"" +
 760                     thisFileId + "\""
 761                     + " Name=\"" + f.getName() + "\" "
 762                     + " Source=\"" + relativePath(imageRootDir, f) + "\""
 763                     + " ProcessorArchitecture=\"x64\"" + ">");
 764             if (doShortcuts && desktopShortcut) {
 765                 out.println(prefix
 766                         + "  <Shortcut Id=\"desktopShortcut\" Directory="
 767                         + "\"DesktopFolder\""
 768                         + " Name=\"" + APP_NAME.fetchFrom(params)
 769                         + "\" WorkingDirectory=\"INSTALLDIR\""
 770                         + " Advertise=\"no\" Icon=\"DesktopIcon.exe\""
 771                         + " IconIndex=\"0\" />");
 772             }
 773             if (doShortcuts && menuShortcut) {
 774                 out.println(prefix
 775                         + "     <Shortcut Id=\"ExeShortcut\" Directory="
 776                         + "\"ProgramMenuDir\""
 777                         + " Name=\"" + APP_NAME.fetchFrom(params)
 778                         + "\" Advertise=\"no\" Icon=\"StartMenuIcon.exe\""
 779                         + " IconIndex=\"0\" />");
 780             }
 781 
 782             List<Map<String, ? super Object>> addLaunchers =
 783                     ADD_LAUNCHERS.fetchFrom(params);
 784             for (int i = 0; i < addLaunchers.size(); i++) {
 785                 Map<String, ? super Object> sl = addLaunchers.get(i);
 786                 File addLauncherFile = new File(imageRootDir,
 787                         WinAppBundler.getLauncherRelativePath(sl));
 788                 if (f.equals(addLauncherFile)) {
 789                     if (SHORTCUT_HINT.fetchFrom(sl)) {
 790                         out.println(prefix
 791                                 + "  <Shortcut Id=\"desktopShortcut"
 792                                 + i + "\" Directory=\"DesktopFolder\""
 793                                 + " Name=\"" + APP_NAME.fetchFrom(sl)
 794                                 + "\" WorkingDirectory=\"INSTALLDIR\""
 795                                 + " Advertise=\"no\" Icon=\"Launcher"
 796                                 + i + ".exe\" IconIndex=\"0\" />");
 797                     }
 798                     if (MENU_HINT.fetchFrom(sl)) {
 799                         out.println(prefix
 800                                 + "     <Shortcut Id=\"ExeShortcut"
 801                                 + i + "\" Directory=\"ProgramMenuDir\""
 802                                 + " Name=\"" + APP_NAME.fetchFrom(sl)
 803                                 + "\" Advertise=\"no\" Icon=\"Launcher"
 804                                 + i + ".exe\" IconIndex=\"0\" />");
 805                         // Should we allow different menu groups?  Not for now.
 806                     }
 807                 }
 808             }
 809             out.println(prefix + "   </File>");
 810         }
 811 
 812         if (launcherSet) {
 813             List<Map<String, ? super Object>> fileAssociations =
 814                 FILE_ASSOCIATIONS.fetchFrom(params);
 815             String regName = APP_REGISTRY_NAME.fetchFrom(params);
 816             Set<String> defaultedMimes = new TreeSet<>();
 817             int count = 0;
 818             for (Map<String, ? super Object> fa : fileAssociations) {
 819                 String description = FA_DESCRIPTION.fetchFrom(fa);
 820                 List<String> extensions = FA_EXTENSIONS.fetchFrom(fa);
 821                 List<String> mimeTypes = FA_CONTENT_TYPE.fetchFrom(fa);
 822                 File icon = FA_ICON.fetchFrom(fa);
 823 
 824                 String mime = (mimeTypes == null ||
 825                     mimeTypes.isEmpty()) ? null : mimeTypes.get(0);
 826 
 827                 if (extensions == null) {
 828                     Log.verbose(I18N.getString(
 829                           "message.creating-association-with-null-extension"));
 830 
 831                     String entryName = regName + "File";
 832                     if (count > 0) {
 833                         entryName += "." + count;
 834                     }
 835                     count++;
 836                     out.print(prefix + "   <ProgId Id='" + entryName
 837                             + "' Description='" + description + "'");
 838                     if (icon != null && icon.exists()) {
 839                         out.print(" Icon='" + idToFileMap.get(icon.getName())
 840                                 + "' IconIndex='0'");
 841                     }
 842                     out.println(" />");
 843                 } else {
 844                     for (String ext : extensions) {
 845                         String entryName = regName + "File";
 846                         if (count > 0) {
 847                             entryName += "." + count;
 848                         }
 849                         count++;
 850 
 851                         out.print(prefix + "   <ProgId Id='" + entryName
 852                                 + "' Description='" + description + "'");
 853                         if (icon != null && icon.exists()) {
 854                             out.print(" Icon='"
 855                                     + idToFileMap.get(icon.getName())
 856                                     + "' IconIndex='0'");
 857                         }
 858                         out.println(">");
 859 
 860                         if (extensions == null) {
 861                             Log.verbose(I18N.getString(
 862                             "message.creating-association-with-null-extension"));
 863                         } else {
 864                             out.print(prefix + "    <Extension Id='"
 865                                     + ext + "' Advertise='no'");
 866                             if (mime == null) {
 867                                 out.println(">");
 868                             } else {
 869                                 out.println(" ContentType='" + mime + "'>");
 870                                 if (!defaultedMimes.contains(mime)) {
 871                                     out.println(prefix
 872                                             + "      <MIME ContentType='"
 873                                             + mime + "' Default='yes' />");
 874                                     defaultedMimes.add(mime);
 875                                 }
 876                             }
 877                             out.println(prefix
 878                                     + "      <Verb Id='open' Command='Open' "
 879                                     + "TargetFile='" + LAUNCHER_ID
 880                                     + "' Argument='\"%1\"' />");
 881                             out.println(prefix + "    </Extension>");
 882                         }
 883                         out.println(prefix + "   </ProgId>");
 884                     }
 885                 }
 886             }
 887         }
 888 
 889         out.println(prefix + " </Component>");
 890 
 891         for (File d : dirs) {
 892             out.println(prefix + " <Directory Id=\"dirid" + (id++)
 893                     + "\" Name=\"" + d.getName() + "\">");
 894             walkFileTree(params, d, out, prefix + " ");
 895             out.println(prefix + " </Directory>");
 896         }
 897     }
 898 
 899     void prepareContentList(Map<String, ? super Object> params)
 900             throws FileNotFoundException {
 901         File f = new File(
 902                 CONFIG_ROOT.fetchFrom(params), MSI_PROJECT_CONTENT_FILE);
 903 
 904         try (PrintStream out = new PrintStream(f)) {
 905 
 906             // opening
 907             out.println("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");
 908             out.println("<Include>");
 909 
 910             out.println(" <Directory Id=\"TARGETDIR\" Name=\"SourceDir\">");
 911             if (MSI_SYSTEM_WIDE.fetchFrom(params)) {
 912                 // install to programfiles
 913                 out.println("  <Directory Id=\"ProgramFiles64Folder\" "
 914                             + "Name=\"PFiles\">");
 915             } else {
 916                 // install to user folder
 917                 out.println(
 918                     "  <Directory Name=\"AppData\" Id=\"LocalAppDataFolder\">");
 919             }
 920 
 921             // We should get valid folder or subfolders
 922             String installDir = WINDOWS_INSTALL_DIR.fetchFrom(params);
 923             String [] installDirs = installDir.split(Pattern.quote("\\"));
 924             for (int i = 0; i < (installDirs.length - 1); i++)  {
 925                 out.println("   <Directory Id=\"SUBDIR" + i + "\" Name=\""
 926                     + installDirs[i] + "\">");
 927             }
 928 
 929             out.println("   <Directory Id=\"APPLICATIONFOLDER\" Name=\""
 930                     + installDirs[installDirs.length - 1] + "\">");
 931 
 932             // dynamic part
 933             id = 0;
 934             compId = 0; // reset counters
 935             walkFileTree(params, WIN_APP_IMAGE.fetchFrom(params), out, "    ");
 936 
 937             // closing
 938             for (int i = 0; i < installDirs.length; i++)  {
 939                 out.println("   </Directory>");
 940             }
 941             out.println("  </Directory>");
 942 
 943             // for shortcuts
 944             if (SHORTCUT_HINT.fetchFrom(params)) {
 945                 out.println("  <Directory Id=\"DesktopFolder\" />");
 946             }
 947             if (MENU_HINT.fetchFrom(params)) {
 948                 out.println("  <Directory Id=\"ProgramMenuFolder\">");
 949                 out.println("    <Directory Id=\"ProgramMenuDir\" Name=\""
 950                         + MENU_GROUP.fetchFrom(params) + "\">");
 951                 out.println("      <Component Id=\"comp" + (compId++) + "\""
 952                         + " Guid=\"" + UUID.randomUUID().toString() + "\""
 953                         + " Win64=\"yes\""
 954                         + ">");
 955                 out.println("        <RemoveFolder Id=\"ProgramMenuDir\" "
 956                         + "On=\"uninstall\" />");
 957                 // This has to be under HKCU to make WiX happy.
 958                 // There are numberous discussions on this amoung WiX users
 959                 // (if user A installs and user B uninstalls key is left behind)
 960                 // there are suggested workarounds but none are appealing.
 961                 // Leave it for now
 962                 out.println(
 963                         "         <RegistryValue Root=\"HKCU\" Key=\"Software\\"
 964                         + VENDOR.fetchFrom(params) + "\\"
 965                         + APP_NAME.fetchFrom(params)
 966                         + "\" Type=\"string\" Value=\"\" />");
 967                 out.println("      </Component>");
 968                 out.println("    </Directory>");
 969                 out.println(" </Directory>");
 970             }
 971 
 972             out.println(" </Directory>");
 973 
 974             out.println(" <Feature Id=\"DefaultFeature\" "
 975                     + "Title=\"Main Feature\" Level=\"1\">");
 976             for (int j = 0; j < compId; j++) {
 977                 out.println("    <ComponentRef Id=\"comp" + j + "\" />");
 978             }
 979             // component is defined in the main.wsx
 980             out.println(
 981                     "    <ComponentRef Id=\"CleanupMainApplicationFolder\" />");
 982             out.println(" </Feature>");
 983             out.println("</Include>");
 984 
 985         }
 986     }
 987 
 988     private File getConfig_ProjectFile(Map<String, ? super Object> params) {
 989         return new File(CONFIG_ROOT.fetchFrom(params),
 990                 APP_NAME.fetchFrom(params) + ".wxs");
 991     }
 992 
 993     private Map<String, String> prepareWiXConfig(
 994             Map<String, ? super Object> params) throws IOException {
 995         prepareContentList(params);
 996         prepareIconsFile(params);
 997         return prepareMainProjectFile(params);
 998     }
 999 
1000     private final static String MSI_PROJECT_CONTENT_FILE = "bundle.wxi";
1001 
1002     private File buildMSI(Map<String, ? super Object> params,
1003             Map<String, String> wixVars, File outdir)
1004             throws IOException {
1005         File tmpDir = new File(TEMP_ROOT.fetchFrom(params), "tmp");
1006         File candleOut = new File(
1007                 tmpDir, APP_NAME.fetchFrom(params) + ".wixobj");
1008         File msiOut = new File(
1009                 outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi");
1010 
1011         Log.verbose(MessageFormat.format(I18N.getString(
1012                 "message.preparing-msi-config"), msiOut.getAbsolutePath()));
1013 
1014         msiOut.getParentFile().mkdirs();
1015 
1016         List<String> commandLine = new ArrayList<>(Arrays.asList(
1017                 getCandlePath(),
1018                 "-nologo",
1019                 getConfig_ProjectFile(params).getAbsolutePath(),
1020                 "-ext", "WixUtilExtension",
1021                 "-out", candleOut.getAbsolutePath()));
1022         for(Map.Entry<String, String> wixVar: wixVars.entrySet()) {
1023             String v = "-d" + wixVar.getKey() + "=" + wixVar.getValue();
1024             commandLine.add(v);
1025         }
1026         ProcessBuilder pb = new ProcessBuilder(commandLine);
1027         pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params));
1028         IOUtils.exec(pb);
1029 
1030         Log.verbose(MessageFormat.format(I18N.getString(
1031                 "message.generating-msi"), msiOut.getAbsolutePath()));
1032 
1033         boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null);
1034         boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params);
1035 
1036         commandLine = new ArrayList<>();
1037 
1038         commandLine.add(getLightPath());
1039 
1040         commandLine.add("-nologo");
1041         commandLine.add("-spdb");
1042         if (!MSI_SYSTEM_WIDE.fetchFrom(params)) {
1043             commandLine.add("-sice:ICE91");
1044         }
1045         commandLine.add(candleOut.getAbsolutePath());
1046         commandLine.add("-ext");
1047         commandLine.add("WixUtilExtension");
1048         if (enableLicenseUI || enableInstalldirUI) {
1049             commandLine.add("-ext");
1050             commandLine.add("WixUIExtension");
1051         }
1052 
1053         commandLine.add("-loc");
1054         commandLine.add(new File(CONFIG_ROOT.fetchFrom(params), I18N.getString(
1055                 "resource.wxl-file-name")).getAbsolutePath());
1056 
1057         // Only needed if we using CA dll, so Wix can find it
1058         if (enableInstalldirUI) {
1059             commandLine.add("-b");
1060             commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath());
1061         }
1062 
1063         commandLine.add("-out");
1064         commandLine.add(msiOut.getAbsolutePath());
1065 
1066         // create .msi
1067         pb = new ProcessBuilder(commandLine);
1068 
1069         pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params));
1070         IOUtils.exec(pb);
1071 
1072         candleOut.delete();
1073         IOUtils.deleteRecursive(tmpDir);
1074 
1075         return msiOut;
1076     }
1077 
1078     public static void ensureByMutationFileIsRTF(File f) {
1079         if (f == null || !f.isFile()) return;
1080 
1081         try {
1082             boolean existingLicenseIsRTF = false;
1083 
1084             try (FileInputStream fin = new FileInputStream(f)) {
1085                 byte[] firstBits = new byte[7];
1086 
1087                 if (fin.read(firstBits) == firstBits.length) {
1088                     String header = new String(firstBits);
1089                     existingLicenseIsRTF = "{\\rtf1\\".equals(header);
1090                 }
1091             }
1092 
1093             if (!existingLicenseIsRTF) {
1094                 List<String> oldLicense = Files.readAllLines(f.toPath());
1095                 try (Writer w = Files.newBufferedWriter(
1096                         f.toPath(), Charset.forName("Windows-1252"))) {
1097                     w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033"
1098                             + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n"
1099                             + "\\viewkind4\\uc1\\pard\\sa200\\sl276"
1100                             + "\\slmult1\\lang9\\fs20 ");
1101                     oldLicense.forEach(l -> {
1102                         try {
1103                             for (char c : l.toCharArray()) {
1104                                 // 0x00 <= ch < 0x20 Escaped (\'hh)
1105                                 // 0x20 <= ch < 0x80 Raw(non - escaped) char
1106                                 // 0x80 <= ch <= 0xFF Escaped(\ 'hh)
1107                                 // 0x5C, 0x7B, 0x7D (special RTF characters
1108                                 // \,{,})Escaped(\'hh)
1109                                 // ch > 0xff Escaped (\\ud###?)
1110                                 if (c < 0x10) {
1111                                     w.write("\\'0");
1112                                     w.write(Integer.toHexString(c));
1113                                 } else if (c > 0xff) {
1114                                     w.write("\\ud");
1115                                     w.write(Integer.toString(c));
1116                                     // \\uc1 is in the header and in effect
1117                                     // so we trail with a replacement char if
1118                                     // the font lacks that character - '?'
1119                                     w.write("?");
1120                                 } else if ((c < 0x20) || (c >= 0x80) ||
1121                                         (c == 0x5C) || (c == 0x7B) ||
1122                                         (c == 0x7D)) {
1123                                     w.write("\\'");
1124                                     w.write(Integer.toHexString(c));
1125                                 } else {
1126                                     w.write(c);
1127                                 }
1128                             }
1129                             // blank lines are interpreted as paragraph breaks
1130                             if (l.length() < 1) {
1131                                 w.write("\\par");
1132                             } else {
1133                                 w.write(" ");
1134                             }
1135                             w.write("\r\n");
1136                         } catch (IOException e) {
1137                             Log.verbose(e);
1138                         }
1139                     });
1140                     w.write("}\r\n");
1141                 }
1142             }
1143         } catch (IOException e) {
1144             Log.verbose(e);
1145         }
1146 
1147     }
1148 }