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