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 }