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