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