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