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