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