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.incubator.jpackage.internal;
  27 
  28 import java.io.*;
  29 import java.nio.charset.Charset;
  30 import java.nio.charset.StandardCharsets;
  31 import java.nio.file.Files;
  32 import java.nio.file.Path;
  33 import java.nio.file.Paths;
  34 import java.text.MessageFormat;
  35 import java.util.*;
  36 import java.util.regex.Pattern;
  37 import java.util.stream.Collectors;
  38 import java.util.stream.Stream;
  39 import javax.xml.stream.XMLOutputFactory;
  40 import javax.xml.stream.XMLStreamException;
  41 import javax.xml.stream.XMLStreamWriter;
  42 import static jdk.incubator.jpackage.internal.OverridableResource.createResource;
  43 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*;
  44 
  45 import static jdk.incubator.jpackage.internal.WindowsBundlerParam.*;
  46 
  47 /**
  48  * WinMsiBundler
  49  *
  50  * Produces .msi installer from application image. Uses WiX Toolkit to build
  51  * .msi installer.
  52  * <p>
  53  * {@link #execute} method creates a number of source files with the description
  54  * of installer to be processed by WiX tools. Generated source files are stored
  55  * in "config" subdirectory next to "app" subdirectory in the root work
  56  * directory. The following WiX source files are generated:
  57  * <ul>
  58  * <li>main.wxs. Main source file with the installer description
  59  * <li>bundle.wxf. Source file with application and Java run-time directory tree
  60  * description.
  61  * </ul>
  62  * <p>
  63  * main.wxs file is a copy of main.wxs resource from
  64  * jdk.incubator.jpackage.internal.resources package. It is parametrized with the
  65  * following WiX variables:
  66  * <ul>
  67  * <li>JpAppName. Name of the application. Set to the value of --name command
  68  * line option
  69  * <li>JpAppVersion. Version of the application. Set to the value of
  70  * --app-version command line option
  71  * <li>JpAppVendor. Vendor of the application. Set to the value of --vendor
  72  * command line option
  73  * <li>JpAppDescription. Description of the application. Set to the value of
  74  * --description command line option
  75  * <li>JpProductCode. Set to product code UUID of the application. Random value
  76  * generated by jpackage every time {@link #execute} method is called
  77  * <li>JpProductUpgradeCode. Set to upgrade code UUID of the application. Random
  78  * value generated by jpackage every time {@link #execute} method is called if
  79  * --win-upgrade-uuid command line option is not specified. Otherwise this
  80  * variable is set to the value of --win-upgrade-uuid command line option
  81  * <li>JpAllowDowngrades. Set to "yes" if --win-upgrade-uuid command line option
  82  * was specified. Undefined otherwise
  83  * <li>JpLicenseRtf. Set to the value of --license-file command line option.
  84  * Undefined is --license-file command line option was not specified
  85  * <li>JpInstallDirChooser. Set to "yes" if --win-dir-chooser command line
  86  * option was specified. Undefined otherwise
  87  * <li>JpConfigDir. Absolute path to the directory with generated WiX source
  88  * files.
  89  * <li>JpIsSystemWide. Set to "yes" if --win-per-user-install command line
  90  * option was not specified. Undefined otherwise
  91  * </ul>
  92  */
  93 public class WinMsiBundler  extends AbstractBundler {
  94 
  95     public static final BundlerParamInfo<WinAppBundler> APP_BUNDLER =
  96             new WindowsBundlerParam<>(
  97             "win.app.bundler",
  98             WinAppBundler.class,
  99             params -> new WinAppBundler(),
 100             null);
 101 
 102     public static final BundlerParamInfo<File> MSI_IMAGE_DIR =
 103             new WindowsBundlerParam<>(
 104             "win.msi.imageDir",
 105             File.class,
 106             params -> {
 107                 File imagesRoot = IMAGES_ROOT.fetchFrom(params);
 108                 if (!imagesRoot.exists()) imagesRoot.mkdirs();
 109                 return new File(imagesRoot, "win-msi.image");
 110             },
 111             (s, p) -> null);
 112 
 113     public static final BundlerParamInfo<File> WIN_APP_IMAGE =
 114             new WindowsBundlerParam<>(
 115             "win.app.image",
 116             File.class,
 117             null,
 118             (s, p) -> null);
 119 
 120     public static final StandardBundlerParam<Boolean> MSI_SYSTEM_WIDE  =
 121             new StandardBundlerParam<>(
 122                     Arguments.CLIOptions.WIN_PER_USER_INSTALLATION.getId(),
 123                     Boolean.class,
 124                     params -> true, // MSIs default to system wide
 125                     // valueOf(null) is false,
 126                     // and we actually do want null
 127                     (s, p) -> (s == null || "null".equalsIgnoreCase(s))? null
 128                             : Boolean.valueOf(s)
 129             );
 130 
 131 
 132     public static final StandardBundlerParam<String> PRODUCT_VERSION =
 133             new StandardBundlerParam<>(
 134                     "win.msi.productVersion",
 135                     String.class,
 136                     VERSION::fetchFrom,
 137                     (s, p) -> s
 138             );
 139 
 140     private static final BundlerParamInfo<String> UPGRADE_UUID =
 141             new WindowsBundlerParam<>(
 142             Arguments.CLIOptions.WIN_UPGRADE_UUID.getId(),
 143             String.class,
 144             null,
 145             (s, p) -> s);
 146 
 147     @Override
 148     public String getName() {
 149         return I18N.getString("msi.bundler.name");
 150     }
 151 
 152     @Override
 153     public String getID() {
 154         return "msi";
 155     }
 156 
 157     @Override
 158     public String getBundleType() {
 159         return "INSTALLER";
 160     }
 161 
 162     @Override
 163     public File execute(Map<String, ? super Object> params,
 164             File outputParentDir) throws PackagerException {
 165         return bundle(params, outputParentDir);
 166     }
 167 
 168     @Override
 169     public boolean supported(boolean platformInstaller) {
 170         try {
 171             if (wixToolset == null) {
 172                 wixToolset = WixTool.toolset();
 173             }
 174             return true;
 175         } catch (ConfigException ce) {
 176             Log.error(ce.getMessage());
 177             if (ce.getAdvice() != null) {
 178                 Log.error(ce.getAdvice());
 179             }
 180         } catch (Exception e) {
 181             Log.error(e.getMessage());
 182         }
 183         return false;
 184     }
 185 
 186     @Override
 187     public boolean isDefault() {
 188         return false;
 189     }
 190 
 191     private static UUID getUpgradeCode(Map<String, ? super Object> params) {
 192         String upgradeCode = UPGRADE_UUID.fetchFrom(params);
 193         if (upgradeCode != null) {
 194             return UUID.fromString(upgradeCode);
 195         }
 196         return createNameUUID("UpgradeCode", params, List.of(VENDOR, APP_NAME));
 197     }
 198 
 199     private static UUID getProductCode(Map<String, ? super Object> params) {
 200         return createNameUUID("ProductCode", params, List.of(VENDOR, APP_NAME,
 201                 VERSION));
 202     }
 203 
 204     private static UUID createNameUUID(String prefix,
 205             Map<String, ? super Object> params,
 206             List<StandardBundlerParam<String>> components) {
 207         String key = Stream.concat(Stream.of(prefix), components.stream().map(
 208                 c -> c.fetchFrom(params))).collect(Collectors.joining("/"));
 209         return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8));
 210     }
 211 
 212     @Override
 213     public boolean validate(Map<String, ? super Object> params)
 214             throws ConfigException {
 215         try {
 216             if (wixToolset == null) {
 217                 wixToolset = WixTool.toolset();
 218             }
 219 
 220             try {
 221                 getUpgradeCode(params);
 222             } catch (IllegalArgumentException ex) {
 223                 throw new ConfigException(ex);
 224             }
 225 
 226             for (var toolInfo: wixToolset.values()) {
 227                 Log.verbose(MessageFormat.format(I18N.getString(
 228                         "message.tool-version"), toolInfo.path.getFileName(),
 229                         toolInfo.version));
 230             }
 231 
 232             wixSourcesBuilder.setWixVersion(wixToolset.get(WixTool.Light).version);
 233 
 234             wixSourcesBuilder.logWixFeatures();
 235 
 236             /********* validate bundle parameters *************/
 237 
 238             String version = PRODUCT_VERSION.fetchFrom(params);
 239             if (!isVersionStringValid(version)) {
 240                 throw new ConfigException(
 241                         MessageFormat.format(I18N.getString(
 242                                 "error.version-string-wrong-format"), version),
 243                         MessageFormat.format(I18N.getString(
 244                                 "error.version-string-wrong-format.advice"),
 245                                 PRODUCT_VERSION.getID()));
 246             }
 247 
 248             // only one mime type per association, at least one file extension
 249             List<Map<String, ? super Object>> associations =
 250                     FILE_ASSOCIATIONS.fetchFrom(params);
 251             if (associations != null) {
 252                 for (int i = 0; i < associations.size(); i++) {
 253                     Map<String, ? super Object> assoc = associations.get(i);
 254                     List<String> mimes = FA_CONTENT_TYPE.fetchFrom(assoc);
 255                     if (mimes.size() > 1) {
 256                         throw new ConfigException(MessageFormat.format(
 257                                 I18N.getString("error.too-many-content-types-for-file-association"), i),
 258                                 I18N.getString("error.too-many-content-types-for-file-association.advice"));
 259                     }
 260                 }
 261             }
 262 
 263             return true;
 264         } catch (RuntimeException re) {
 265             if (re.getCause() instanceof ConfigException) {
 266                 throw (ConfigException) re.getCause();
 267             } else {
 268                 throw new ConfigException(re);
 269             }
 270         }
 271     }
 272 
 273     // https://msdn.microsoft.com/en-us/library/aa370859%28v=VS.85%29.aspx
 274     // The format of the string is as follows:
 275     //     major.minor.build
 276     // The first field is the major version and has a maximum value of 255.
 277     // The second field is the minor version and has a maximum value of 255.
 278     // The third field is called the build version or the update version and
 279     // has a maximum value of 65,535.
 280     static boolean isVersionStringValid(String v) {
 281         if (v == null) {
 282             return true;
 283         }
 284 
 285         String p[] = v.split("\\.");
 286         if (p.length > 3) {
 287             Log.verbose(I18N.getString(
 288                     "message.version-string-too-many-components"));
 289             return false;
 290         }
 291 
 292         try {
 293             int val = Integer.parseInt(p[0]);
 294             if (val < 0 || val > 255) {
 295                 Log.verbose(I18N.getString(
 296                         "error.version-string-major-out-of-range"));
 297                 return false;
 298             }
 299             if (p.length > 1) {
 300                 val = Integer.parseInt(p[1]);
 301                 if (val < 0 || val > 255) {
 302                     Log.verbose(I18N.getString(
 303                             "error.version-string-minor-out-of-range"));
 304                     return false;
 305                 }
 306             }
 307             if (p.length > 2) {
 308                 val = Integer.parseInt(p[2]);
 309                 if (val < 0 || val > 65535) {
 310                     Log.verbose(I18N.getString(
 311                             "error.version-string-build-out-of-range"));
 312                     return false;
 313                 }
 314             }
 315         } catch (NumberFormatException ne) {
 316             Log.verbose(I18N.getString("error.version-string-part-not-number"));
 317             Log.verbose(ne);
 318             return false;
 319         }
 320 
 321         return true;
 322     }
 323 
 324     private void prepareProto(Map<String, ? super Object> params)
 325                 throws PackagerException, IOException {
 326         File appImage = StandardBundlerParam.getPredefinedAppImage(params);
 327         File appDir = null;
 328 
 329         // we either have an application image or need to build one
 330         if (appImage != null) {
 331             appDir = new File(MSI_IMAGE_DIR.fetchFrom(params),
 332                     APP_NAME.fetchFrom(params));
 333             // copy everything from appImage dir into appDir/name
 334             IOUtils.copyRecursive(appImage.toPath(), appDir.toPath());
 335         } else {
 336             appDir = APP_BUNDLER.fetchFrom(params).doBundle(params,
 337                     MSI_IMAGE_DIR.fetchFrom(params), true);
 338         }
 339 
 340         params.put(WIN_APP_IMAGE.getID(), appDir);
 341 
 342         String licenseFile = LICENSE_FILE.fetchFrom(params);
 343         if (licenseFile != null) {
 344             // need to copy license file to the working directory
 345             // and convert to rtf if needed
 346             File lfile = new File(licenseFile);
 347             File destFile = new File(CONFIG_ROOT.fetchFrom(params),
 348                     lfile.getName());
 349 
 350             IOUtils.copyFile(lfile, destFile);
 351             destFile.setWritable(true);
 352             ensureByMutationFileIsRTF(destFile);
 353         }
 354     }
 355 
 356     public File bundle(Map<String, ? super Object> params, File outdir)
 357             throws PackagerException {
 358 
 359         IOUtils.writableOutputDir(outdir.toPath());
 360 
 361         Path imageDir = MSI_IMAGE_DIR.fetchFrom(params).toPath();
 362         try {
 363             Files.createDirectories(imageDir);
 364 
 365             prepareProto(params);
 366 
 367             wixSourcesBuilder
 368             .initFromParams(WIN_APP_IMAGE.fetchFrom(params).toPath(), params)
 369             .createMainFragment(CONFIG_ROOT.fetchFrom(params).toPath().resolve(
 370                     "bundle.wxf"));
 371 
 372             Map<String, String> wixVars = prepareMainProjectFile(params);
 373 
 374             new ScriptRunner()
 375             .setDirectory(imageDir)
 376             .setResourceCategoryId("resource.post-app-image-script")
 377             .setScriptNameSuffix("post-image")
 378             .setEnvironmentVariable("JpAppImageDir", imageDir.toAbsolutePath().toString())
 379             .run(params);
 380 
 381             return buildMSI(params, wixVars, outdir);
 382         } catch (IOException ex) {
 383             Log.verbose(ex);
 384             throw new PackagerException(ex);
 385         }
 386     }
 387 
 388     Map<String, String> prepareMainProjectFile(
 389             Map<String, ? super Object> params) throws IOException {
 390         Map<String, String> data = new HashMap<>();
 391 
 392         final UUID productCode = getProductCode(params);
 393         final UUID upgradeCode = getUpgradeCode(params);
 394 
 395         data.put("JpProductCode", productCode.toString());
 396         data.put("JpProductUpgradeCode", upgradeCode.toString());
 397 
 398         Log.verbose(MessageFormat.format(I18N.getString("message.product-code"),
 399                 productCode));
 400         Log.verbose(MessageFormat.format(I18N.getString("message.upgrade-code"),
 401                 upgradeCode));
 402 
 403         data.put("JpAllowUpgrades", "yes");
 404 
 405         data.put("JpAppName", APP_NAME.fetchFrom(params));
 406         data.put("JpAppDescription", DESCRIPTION.fetchFrom(params));
 407         data.put("JpAppVendor", VENDOR.fetchFrom(params));
 408         data.put("JpAppVersion", PRODUCT_VERSION.fetchFrom(params));
 409 
 410         final Path configDir = CONFIG_ROOT.fetchFrom(params).toPath();
 411 
 412         data.put("JpConfigDir", configDir.toAbsolutePath().toString());
 413 
 414         if (MSI_SYSTEM_WIDE.fetchFrom(params)) {
 415             data.put("JpIsSystemWide", "yes");
 416         }
 417 
 418         String licenseFile = LICENSE_FILE.fetchFrom(params);
 419         if (licenseFile != null) {
 420             String lname = new File(licenseFile).getName();
 421             File destFile = new File(CONFIG_ROOT.fetchFrom(params), lname);
 422             data.put("JpLicenseRtf", destFile.getAbsolutePath());
 423         }
 424 
 425         // Copy CA dll to include with installer
 426         if (INSTALLDIR_CHOOSER.fetchFrom(params)) {
 427             data.put("JpInstallDirChooser", "yes");
 428             String fname = "wixhelper.dll";
 429             try (InputStream is = OverridableResource.readDefault(fname)) {
 430                 Files.copy(is, Paths.get(
 431                         CONFIG_ROOT.fetchFrom(params).getAbsolutePath(),
 432                         fname));
 433             }
 434         }
 435 
 436         // Copy l10n files.
 437         for (String loc : Arrays.asList("en", "ja", "zh_CN")) {
 438             String fname = "MsiInstallerStrings_" + loc + ".wxl";
 439             try (InputStream is = OverridableResource.readDefault(fname)) {
 440                 Files.copy(is, Paths.get(
 441                         CONFIG_ROOT.fetchFrom(params).getAbsolutePath(),
 442                         fname));
 443             }
 444         }
 445 
 446         createResource("main.wxs", params)
 447                 .setCategory(I18N.getString("resource.main-wix-file"))
 448                 .saveToFile(configDir.resolve("main.wxs"));
 449 
 450         createResource("overrides.wxi", params)
 451                 .setCategory(I18N.getString("resource.overrides-wix-file"))
 452                 .saveToFile(configDir.resolve("overrides.wxi"));
 453 
 454         return data;
 455     }
 456 
 457     private File buildMSI(Map<String, ? super Object> params,
 458             Map<String, String> wixVars, File outdir)
 459             throws IOException {
 460 
 461         File msiOut = new File(
 462                 outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi");
 463 
 464         Log.verbose(MessageFormat.format(I18N.getString(
 465                 "message.preparing-msi-config"), msiOut.getAbsolutePath()));
 466 
 467         WixPipeline wixPipeline = new WixPipeline()
 468         .setToolset(wixToolset.entrySet().stream().collect(
 469                 Collectors.toMap(
 470                         entry -> entry.getKey(),
 471                         entry -> entry.getValue().path)))
 472         .setWixObjDir(TEMP_ROOT.fetchFrom(params).toPath().resolve("wixobj"))
 473         .setWorkDir(WIN_APP_IMAGE.fetchFrom(params).toPath())
 474         .addSource(CONFIG_ROOT.fetchFrom(params).toPath().resolve("main.wxs"), wixVars)
 475         .addSource(CONFIG_ROOT.fetchFrom(params).toPath().resolve("bundle.wxf"), null);
 476 
 477         Log.verbose(MessageFormat.format(I18N.getString(
 478                 "message.generating-msi"), msiOut.getAbsolutePath()));
 479 
 480         boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null);
 481         boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params);
 482 
 483         List<String> lightArgs = new ArrayList<>();
 484 
 485         if (!MSI_SYSTEM_WIDE.fetchFrom(params)) {
 486             wixPipeline.addLightOptions("-sice:ICE91");
 487         }
 488         if (enableLicenseUI || enableInstalldirUI) {
 489             wixPipeline.addLightOptions("-ext", "WixUIExtension");
 490         }
 491 
 492         wixPipeline.addLightOptions("-loc",
 493                 CONFIG_ROOT.fetchFrom(params).toPath().resolve(I18N.getString(
 494                         "resource.wxl-file-name")).toAbsolutePath().toString());
 495 
 496         // Only needed if we using CA dll, so Wix can find it
 497         if (enableInstalldirUI) {
 498             wixPipeline.addLightOptions("-b", CONFIG_ROOT.fetchFrom(params).getAbsolutePath());
 499         }
 500 
 501         wixPipeline.buildMsi(msiOut.toPath().toAbsolutePath());
 502 
 503         return msiOut;
 504     }
 505 
 506     public static void ensureByMutationFileIsRTF(File f) {
 507         if (f == null || !f.isFile()) return;
 508 
 509         try {
 510             boolean existingLicenseIsRTF = false;
 511 
 512             try (FileInputStream fin = new FileInputStream(f)) {
 513                 byte[] firstBits = new byte[7];
 514 
 515                 if (fin.read(firstBits) == firstBits.length) {
 516                     String header = new String(firstBits);
 517                     existingLicenseIsRTF = "{\\rtf1\\".equals(header);
 518                 }
 519             }
 520 
 521             if (!existingLicenseIsRTF) {
 522                 List<String> oldLicense = Files.readAllLines(f.toPath());
 523                 try (Writer w = Files.newBufferedWriter(
 524                         f.toPath(), Charset.forName("Windows-1252"))) {
 525                     w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033"
 526                             + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n"
 527                             + "\\viewkind4\\uc1\\pard\\sa200\\sl276"
 528                             + "\\slmult1\\lang9\\fs20 ");
 529                     oldLicense.forEach(l -> {
 530                         try {
 531                             for (char c : l.toCharArray()) {
 532                                 // 0x00 <= ch < 0x20 Escaped (\'hh)
 533                                 // 0x20 <= ch < 0x80 Raw(non - escaped) char
 534                                 // 0x80 <= ch <= 0xFF Escaped(\ 'hh)
 535                                 // 0x5C, 0x7B, 0x7D (special RTF characters
 536                                 // \,{,})Escaped(\'hh)
 537                                 // ch > 0xff Escaped (\\ud###?)
 538                                 if (c < 0x10) {
 539                                     w.write("\\'0");
 540                                     w.write(Integer.toHexString(c));
 541                                 } else if (c > 0xff) {
 542                                     w.write("\\ud");
 543                                     w.write(Integer.toString(c));
 544                                     // \\uc1 is in the header and in effect
 545                                     // so we trail with a replacement char if
 546                                     // the font lacks that character - '?'
 547                                     w.write("?");
 548                                 } else if ((c < 0x20) || (c >= 0x80) ||
 549                                         (c == 0x5C) || (c == 0x7B) ||
 550                                         (c == 0x7D)) {
 551                                     w.write("\\'");
 552                                     w.write(Integer.toHexString(c));
 553                                 } else {
 554                                     w.write(c);
 555                                 }
 556                             }
 557                             // blank lines are interpreted as paragraph breaks
 558                             if (l.length() < 1) {
 559                                 w.write("\\par");
 560                             } else {
 561                                 w.write(" ");
 562                             }
 563                             w.write("\r\n");
 564                         } catch (IOException e) {
 565                             Log.verbose(e);
 566                         }
 567                     });
 568                     w.write("}\r\n");
 569                 }
 570             }
 571         } catch (IOException e) {
 572             Log.verbose(e);
 573         }
 574 
 575     }
 576 
 577     private Map<WixTool, WixTool.ToolInfo> wixToolset;
 578     private WixSourcesBuilder wixSourcesBuilder = new WixSourcesBuilder();
 579 
 580 }