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, 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", configScript); 483 } 484 return buildMSI(p, outdir); 485 } 486 return null; 487 } catch (IOException ex) { 488 Log.verbose(ex); 489 throw new PackagerException(ex); 490 } 491 } 492 493 // name of post-image script 494 private File getConfig_Script(Map<String, ? super Object> params) { 495 return new File(CONFIG_ROOT.fetchFrom(params), 496 APP_NAME.fetchFrom(params) + "-post-image.wsf"); 497 } 498 499 private boolean prepareBasicProjectConfig( 500 Map<String, ? super Object> params) throws IOException { 501 fetchResource(getConfig_Script(params).getName(), 502 I18N.getString("resource.post-install-script"), 503 (String) null, 504 getConfig_Script(params), 505 VERBOSE.fetchFrom(params), 506 RESOURCE_DIR.fetchFrom(params)); 507 return true; 508 } 509 510 private String relativePath(File basedir, File file) { 511 return file.getAbsolutePath().substring( 512 basedir.getAbsolutePath().length() + 1); 513 } 514 515 boolean prepareMainProjectFile( 516 Map<String, ? super Object> params) throws IOException { 517 Map<String, String> data = new HashMap<>(); 518 519 UUID productGUID = UUID.randomUUID(); 520 521 Log.verbose(MessageFormat.format( 522 I18N.getString("message.generated-product-guid"), 523 productGUID.toString())); 524 525 // we use random GUID for product itself but 526 // user provided for upgrade guid 527 // Upgrade guid is important to decide whether it is an upgrade of 528 // installed app. I.e. we need it to be the same for 529 // 2 different versions of app if possible 530 data.put("PRODUCT_GUID", productGUID.toString()); 531 data.put("PRODUCT_UPGRADE_GUID", 532 UPGRADE_UUID.fetchFrom(params).toString()); 533 data.put("UPGRADE_BLOCK", getUpgradeBlock(params)); 534 535 data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params)); 536 data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params)); 537 data.put("APPLICATION_VENDOR", VENDOR.fetchFrom(params)); 538 data.put("APPLICATION_VERSION", PRODUCT_VERSION.fetchFrom(params)); 539 540 // WinAppBundler will add application folder again => step out 541 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 542 File launcher = new File(imageRootDir, 543 WinAppBundler.getLauncherName(params)); 544 545 String launcherPath = relativePath(imageRootDir, launcher); 546 data.put("APPLICATION_LAUNCHER", launcherPath); 547 548 String iconPath = launcherPath.replace(".exe", ".ico"); 549 550 data.put("APPLICATION_ICON", iconPath); 551 552 data.put("REGISTRY_ROOT", getRegistryRoot(params)); 553 554 boolean canUseWix36Features = CAN_USE_WIX36.fetchFrom(params); 555 data.put("WIX36_ONLY_START", 556 canUseWix36Features ? "" : "<!--"); 557 data.put("WIX36_ONLY_END", 558 canUseWix36Features ? "" : "-->"); 559 560 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 561 data.put("INSTALL_SCOPE", "perMachine"); 562 } else { 563 data.put("INSTALL_SCOPE", "perUser"); 564 } 565 566 data.put("PLATFORM", "x64"); 567 data.put("WIN64", "yes"); 568 569 data.put("UI_BLOCK", getUIBlock(params)); 570 571 // Add CA to check install dir 572 if (INSTALLDIR_CHOOSER.fetchFrom(params)) { 573 data.put("CA_BLOCK", CA_BLOCK); 574 data.put("INVALID_INSTALL_DIR_DLG_BLOCK", INVALID_INSTALL_DIR_DLG_BLOCK); 575 } else { 576 data.put("CA_BLOCK", ""); 577 data.put("INVALID_INSTALL_DIR_DLG_BLOCK", ""); 578 } 579 580 List<Map<String, ? super Object>> addLaunchers = 581 ADD_LAUNCHERS.fetchFrom(params); 582 583 StringBuilder addLauncherIcons = new StringBuilder(); 584 for (int i = 0; i < addLaunchers.size(); i++) { 585 Map<String, ? super Object> sl = addLaunchers.get(i); 586 // <Icon Id="DesktopIcon.exe" SourceFile="APPLICATION_ICON" /> 587 if (SHORTCUT_HINT.fetchFrom(sl) || MENU_HINT.fetchFrom(sl)) { 588 File addLauncher = new File(imageRootDir, 589 WinAppBundler.getLauncherName(sl)); 590 String addLauncherPath = 591 relativePath(imageRootDir, addLauncher); 592 String addLauncherIconPath = 593 addLauncherPath.replace(".exe", ".ico"); 594 595 addLauncherIcons.append(" <Icon Id=\"Launcher"); 596 addLauncherIcons.append(i); 597 addLauncherIcons.append(".exe\" SourceFile=\""); 598 addLauncherIcons.append(addLauncherIconPath); 599 addLauncherIcons.append("\" />\r\n"); 600 } 601 } 602 data.put("ADD_LAUNCHER_ICONS", addLauncherIcons.toString()); 603 604 String wxs = StandardBundlerParam.isRuntimeInstaller(params) ? 605 MSI_PROJECT_TEMPLATE_SERVER_JRE : MSI_PROJECT_TEMPLATE; 606 607 try (Writer w = new BufferedWriter( 608 new FileWriter(getConfig_ProjectFile(params)))) { 609 610 String content = preprocessTextResource( 611 getConfig_ProjectFile(params).getName(), 612 I18N.getString("resource.wix-config-file"), 613 wxs, data, VERBOSE.fetchFrom(params), 614 RESOURCE_DIR.fetchFrom(params)); 615 w.write(content); 616 } 617 return true; 618 } 619 private int id; 620 private int compId; 621 private final static String LAUNCHER_ID = "LauncherId"; 622 623 private static final String CA_BLOCK = 624 "<Binary Id=\"CustomActionDLL\" SourceFile=\"wixhelper.dll\" />\n" + 625 "<CustomAction Id=\"CHECK_INSTALLDIR\" BinaryKey=\"CustomActionDLL\" " + 626 "DllEntry=\"CheckInstallDir\" />"; 627 628 private static final String INVALID_INSTALL_DIR_DLG_BLOCK = 629 "<Dialog Id=\"InvalidInstallDir\" Width=\"300\" Height=\"85\" " + 630 "Title=\"[ProductName] Setup\" NoMinimize=\"yes\">\n" + 631 "<Control Id=\"InvalidInstallDirYes\" Type=\"PushButton\" X=\"100\" Y=\"55\" " + 632 "Width=\"50\" Height=\"15\" Default=\"no\" Cancel=\"no\" Text=\"Yes\">\n" + 633 "<Publish Event=\"NewDialog\" Value=\"VerifyReadyDlg\">1</Publish>\n" + 634 "</Control>\n" + 635 "<Control Id=\"InvalidInstallDirNo\" Type=\"PushButton\" X=\"150\" Y=\"55\" " + 636 "Width=\"50\" Height=\"15\" Default=\"yes\" Cancel=\"yes\" Text=\"No\">\n" + 637 "<Publish Event=\"NewDialog\" Value=\"InstallDirDlg\">1</Publish>\n" + 638 "</Control>\n" + 639 "<Control Id=\"Text\" Type=\"Text\" X=\"25\" Y=\"15\" Width=\"250\" Height=\"30\" " + 640 "TabSkip=\"no\">\n" + 641 "<Text>" + I18N.getString("message.install.dir.exist") + "</Text>\n" + 642 "</Control>\n" + 643 "</Dialog>"; 644 645 /** 646 * Overrides the dialog sequence in built-in dialog set "WixUI_InstallDir" 647 * to exclude license dialog 648 */ 649 private static final String TWEAK_FOR_EXCLUDING_LICENSE = 650 " <Publish Dialog=\"WelcomeDlg\" Control=\"Next\"" 651 + " Event=\"NewDialog\" Value=\"InstallDirDlg\"" 652 + " Order=\"2\">1</Publish>\n" 653 + " <Publish Dialog=\"InstallDirDlg\" Control=\"Back\"" 654 + " Event=\"NewDialog\" Value=\"WelcomeDlg\"" 655 + " Order=\"2\">1</Publish>\n"; 656 657 private static final String CHECK_INSTALL_DLG_CTRL = 658 " <Publish Dialog=\"InstallDirDlg\" Control=\"Next\"" 659 + " Event=\"DoAction\" Value=\"CHECK_INSTALLDIR\"" 660 + " Order=\"3\">1</Publish>\n" 661 + " <Publish Dialog=\"InstallDirDlg\" Control=\"Next\"" 662 + " Event=\"NewDialog\" Value=\"InvalidInstallDir\"" 663 + " Order=\"5\">INSTALLDIR_VALID=\"0\"</Publish>\n" 664 + " <Publish Dialog=\"InstallDirDlg\" Control=\"Next\"" 665 + " Event=\"NewDialog\" Value=\"VerifyReadyDlg\"" 666 + " Order=\"5\">INSTALLDIR_VALID=\"1\"</Publish>\n"; 667 668 // Required upgrade element for installers which support major upgrade (when user 669 // specifies --win-upgrade-uuid). We will allow downgrades. 670 private static final String UPGRADE_BLOCK = 671 "<MajorUpgrade AllowDowngrades=\"yes\"/>"; 672 673 private String getUpgradeBlock(Map<String, ? super Object> params) { 674 if (UPGRADE_UUID.getIsDefaultValue()) { 675 return ""; 676 } else { 677 return UPGRADE_BLOCK; 678 } 679 } 680 681 /** 682 * Creates UI element using WiX built-in dialog sets 683 * - WixUI_InstallDir/WixUI_Minimal. 684 * The dialog sets are the closest to what we want to implement. 685 * 686 * WixUI_Minimal for license dialog only 687 * WixUI_InstallDir for installdir dialog only or for both 688 * installdir/license dialogs 689 */ 690 private String getUIBlock(Map<String, ? super Object> params) throws IOException { 691 String uiBlock = ""; // UI-less element 692 693 // Copy CA dll to include with installer 694 if (INSTALLDIR_CHOOSER.fetchFrom(params)) { 695 File helper = new File(CONFIG_ROOT.fetchFrom(params), "wixhelper.dll"); 696 try (InputStream is_lib = getResourceAsStream("wixhelper.dll")) { 697 Files.copy(is_lib, helper.toPath()); 698 } 699 } 700 701 if (INSTALLDIR_CHOOSER.fetchFrom(params)) { 702 boolean enableTweakForExcludingLicense = 703 (getLicenseFile(params) == null); 704 uiBlock = " <Property Id=\"WIXUI_INSTALLDIR\"" 705 + " Value=\"APPLICATIONFOLDER\" />\n" 706 + " <UIRef Id=\"WixUI_InstallDir\" />\n" 707 + (enableTweakForExcludingLicense ? 708 TWEAK_FOR_EXCLUDING_LICENSE : "") 709 + CHECK_INSTALL_DLG_CTRL; 710 } else if (getLicenseFile(params) != null) { 711 uiBlock = " <UIRef Id=\"WixUI_Minimal\" />\n"; 712 } 713 714 return uiBlock; 715 } 716 717 private void walkFileTree(Map<String, ? super Object> params, 718 File root, PrintStream out, String prefix) { 719 List<File> dirs = new ArrayList<>(); 720 List<File> files = new ArrayList<>(); 721 722 if (!root.isDirectory()) { 723 throw new RuntimeException( 724 MessageFormat.format( 725 I18N.getString("error.cannot-walk-directory"), 726 root.getAbsolutePath())); 727 } 728 729 // sort to files and dirs 730 File[] children = root.listFiles(); 731 if (children != null) { 732 for (File f : children) { 733 if (f.isDirectory()) { 734 dirs.add(f); 735 } else { 736 files.add(f); 737 } 738 } 739 } 740 741 // have files => need to output component 742 out.println(prefix + " <Component Id=\"comp" + (compId++) 743 + "\" DiskId=\"1\"" 744 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 745 + " Win64=\"yes\"" 746 + ">"); 747 out.println(prefix + " <CreateFolder/>"); 748 out.println(prefix + " <RemoveFolder Id=\"RemoveDir" 749 + (id++) + "\" On=\"uninstall\" />"); 750 751 boolean needRegistryKey = !MSI_SYSTEM_WIDE.fetchFrom(params); 752 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 753 File launcherFile = 754 new File(imageRootDir, WinAppBundler.getLauncherName(params)); 755 756 // Find out if we need to use registry. We need it if 757 // - we doing user level install as file can not serve as KeyPath 758 // - if we adding shortcut in this component 759 760 for (File f: files) { 761 boolean isLauncher = f.equals(launcherFile); 762 if (isLauncher) { 763 needRegistryKey = true; 764 } 765 } 766 767 if (needRegistryKey) { 768 // has to be under HKCU to make WiX happy 769 out.println(prefix + " <RegistryKey Root=\"HKCU\" " 770 + " Key=\"Software\\" + VENDOR.fetchFrom(params) + "\\" 771 + APP_NAME.fetchFrom(params) + "\"" 772 + (CAN_USE_WIX36.fetchFrom(params) ? 773 ">" : " Action=\"createAndRemoveOnUninstall\">")); 774 out.println(prefix 775 + " <RegistryValue Name=\"Version\" Value=\"" 776 + VERSION.fetchFrom(params) 777 + "\" Type=\"string\" KeyPath=\"yes\"/>"); 778 out.println(prefix + " </RegistryKey>"); 779 } 780 781 boolean menuShortcut = MENU_HINT.fetchFrom(params); 782 boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(params); 783 784 Map<String, String> idToFileMap = new TreeMap<>(); 785 boolean launcherSet = false; 786 787 for (File f : files) { 788 boolean isLauncher = f.equals(launcherFile); 789 790 launcherSet = launcherSet || isLauncher; 791 792 boolean doShortcuts = 793 isLauncher && (menuShortcut || desktopShortcut); 794 795 String thisFileId = isLauncher ? LAUNCHER_ID : ("FileId" + (id++)); 796 idToFileMap.put(f.getName(), thisFileId); 797 798 out.println(prefix + " <File Id=\"" + 799 thisFileId + "\"" 800 + " Name=\"" + f.getName() + "\" " 801 + " Source=\"" + relativePath(imageRootDir, f) + "\"" 802 + " ProcessorArchitecture=\"x64\"" + ">"); 803 if (doShortcuts && desktopShortcut) { 804 out.println(prefix 805 + " <Shortcut Id=\"desktopShortcut\" Directory=" 806 + "\"DesktopFolder\"" 807 + " Name=\"" + APP_NAME.fetchFrom(params) 808 + "\" WorkingDirectory=\"INSTALLDIR\"" 809 + " Advertise=\"no\" Icon=\"DesktopIcon.exe\"" 810 + " IconIndex=\"0\" />"); 811 } 812 if (doShortcuts && menuShortcut) { 813 out.println(prefix 814 + " <Shortcut Id=\"ExeShortcut\" Directory=" 815 + "\"ProgramMenuDir\"" 816 + " Name=\"" + APP_NAME.fetchFrom(params) 817 + "\" Advertise=\"no\" Icon=\"StartMenuIcon.exe\"" 818 + " IconIndex=\"0\" />"); 819 } 820 821 List<Map<String, ? super Object>> addLaunchers = 822 ADD_LAUNCHERS.fetchFrom(params); 823 for (int i = 0; i < addLaunchers.size(); i++) { 824 Map<String, ? super Object> sl = addLaunchers.get(i); 825 File addLauncherFile = new File(imageRootDir, 826 WinAppBundler.getLauncherName(sl)); 827 if (f.equals(addLauncherFile)) { 828 if (SHORTCUT_HINT.fetchFrom(sl)) { 829 out.println(prefix 830 + " <Shortcut Id=\"desktopShortcut" 831 + i + "\" Directory=\"DesktopFolder\"" 832 + " Name=\"" + APP_NAME.fetchFrom(sl) 833 + "\" WorkingDirectory=\"INSTALLDIR\"" 834 + " Advertise=\"no\" Icon=\"Launcher" 835 + i + ".exe\" IconIndex=\"0\" />"); 836 } 837 if (MENU_HINT.fetchFrom(sl)) { 838 out.println(prefix 839 + " <Shortcut Id=\"ExeShortcut" 840 + i + "\" Directory=\"ProgramMenuDir\"" 841 + " Name=\"" + APP_NAME.fetchFrom(sl) 842 + "\" Advertise=\"no\" Icon=\"Launcher" 843 + i + ".exe\" IconIndex=\"0\" />"); 844 // Should we allow different menu groups? Not for now. 845 } 846 } 847 } 848 out.println(prefix + " </File>"); 849 } 850 851 if (launcherSet) { 852 List<Map<String, ? super Object>> fileAssociations = 853 FILE_ASSOCIATIONS.fetchFrom(params); 854 String regName = APP_REGISTRY_NAME.fetchFrom(params); 855 Set<String> defaultedMimes = new TreeSet<>(); 856 int count = 0; 857 for (Map<String, ? super Object> fa : fileAssociations) { 858 String description = FA_DESCRIPTION.fetchFrom(fa); 859 List<String> extensions = FA_EXTENSIONS.fetchFrom(fa); 860 List<String> mimeTypes = FA_CONTENT_TYPE.fetchFrom(fa); 861 File icon = FA_ICON.fetchFrom(fa); // TODO FA_ICON_ICO 862 863 String mime = (mimeTypes == null || 864 mimeTypes.isEmpty()) ? null : mimeTypes.get(0); 865 866 if (extensions == null) { 867 Log.verbose(I18N.getString( 868 "message.creating-association-with-null-extension")); 869 870 String entryName = regName + "File"; 871 if (count > 0) { 872 entryName += "." + count; 873 } 874 count++; 875 out.print(prefix + " <ProgId Id='" + entryName 876 + "' Description='" + description + "'"); 877 if (icon != null && icon.exists()) { 878 out.print(" Icon='" + idToFileMap.get(icon.getName()) 879 + "' IconIndex='0'"); 880 } 881 out.println(" />"); 882 } else { 883 for (String ext : extensions) { 884 String entryName = regName + "File"; 885 if (count > 0) { 886 entryName += "." + count; 887 } 888 count++; 889 890 out.print(prefix + " <ProgId Id='" + entryName 891 + "' Description='" + description + "'"); 892 if (icon != null && icon.exists()) { 893 out.print(" Icon='" 894 + idToFileMap.get(icon.getName()) 895 + "' IconIndex='0'"); 896 } 897 out.println(">"); 898 899 if (extensions == null) { 900 Log.verbose(I18N.getString( 901 "message.creating-association-with-null-extension")); 902 } else { 903 out.print(prefix + " <Extension Id='" 904 + ext + "' Advertise='no'"); 905 if (mime == null) { 906 out.println(">"); 907 } else { 908 out.println(" ContentType='" + mime + "'>"); 909 if (!defaultedMimes.contains(mime)) { 910 out.println(prefix 911 + " <MIME ContentType='" 912 + mime + "' Default='yes' />"); 913 defaultedMimes.add(mime); 914 } 915 } 916 out.println(prefix 917 + " <Verb Id='open' Command='Open' " 918 + "TargetFile='" + LAUNCHER_ID 919 + "' Argument='\"%1\"' />"); 920 out.println(prefix + " </Extension>"); 921 } 922 out.println(prefix + " </ProgId>"); 923 } 924 } 925 } 926 } 927 928 out.println(prefix + " </Component>"); 929 930 for (File d : dirs) { 931 out.println(prefix + " <Directory Id=\"dirid" + (id++) 932 + "\" Name=\"" + d.getName() + "\">"); 933 walkFileTree(params, d, out, prefix + " "); 934 out.println(prefix + " </Directory>"); 935 } 936 } 937 938 String getRegistryRoot(Map<String, ? super Object> params) { 939 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 940 return "HKLM"; 941 } else { 942 return "HKCU"; 943 } 944 } 945 946 boolean prepareContentList(Map<String, ? super Object> params) 947 throws FileNotFoundException { 948 File f = new File( 949 CONFIG_ROOT.fetchFrom(params), MSI_PROJECT_CONTENT_FILE); 950 951 try (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 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( 1028 " <ComponentRef Id=\"CleanupMainApplicationFolder\" />"); 1029 out.println(" </Feature>"); 1030 out.println("</Include>"); 1031 1032 } 1033 return true; 1034 } 1035 1036 private File getConfig_ProjectFile(Map<String, ? super Object> params) { 1037 return new File(CONFIG_ROOT.fetchFrom(params), 1038 APP_NAME.fetchFrom(params) + ".wxs"); 1039 } 1040 1041 private String getLicenseFile(Map<String, ? super Object> p) { 1042 String licenseFile = LICENSE_FILE.fetchFrom(p); 1043 if (licenseFile != null) { 1044 File lfile = new File(licenseFile); 1045 File destFile = new File(CONFIG_ROOT.fetchFrom(p), lfile.getName()); 1046 String filePath = destFile.getAbsolutePath(); 1047 if (filePath.contains(" ")) { 1048 return "\"" + filePath + "\""; 1049 } else { 1050 return filePath; 1051 } 1052 } 1053 1054 return null; 1055 } 1056 1057 private boolean prepareWiXConfig( 1058 Map<String, ? super Object> params) throws IOException { 1059 return prepareMainProjectFile(params) && prepareContentList(params); 1060 1061 } 1062 private final static String MSI_PROJECT_TEMPLATE = "template.wxs"; 1063 private final static String MSI_PROJECT_TEMPLATE_SERVER_JRE = 1064 "template.jre.wxs"; 1065 private final static String MSI_PROJECT_CONTENT_FILE = "bundle.wxi"; 1066 1067 private File buildMSI(Map<String, ? super Object> params, File outdir) 1068 throws IOException { 1069 File tmpDir = new File(TEMP_ROOT.fetchFrom(params), "tmp"); 1070 File candleOut = new File( 1071 tmpDir, APP_NAME.fetchFrom(params) +".wixobj"); 1072 File msiOut = new File( 1073 outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi"); 1074 1075 Log.verbose(MessageFormat.format(I18N.getString( 1076 "message.preparing-msi-config"), msiOut.getAbsolutePath())); 1077 1078 msiOut.getParentFile().mkdirs(); 1079 1080 // run candle 1081 ProcessBuilder pb = new ProcessBuilder( 1082 TOOL_CANDLE_EXECUTABLE.fetchFrom(params), 1083 "-nologo", 1084 getConfig_ProjectFile(params).getAbsolutePath(), 1085 "-ext", "WixUtilExtension", 1086 "-out", candleOut.getAbsolutePath()); 1087 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1088 IOUtils.exec(pb); 1089 1090 Log.verbose(MessageFormat.format(I18N.getString( 1091 "message.generating-msi"), msiOut.getAbsolutePath())); 1092 1093 boolean enableLicenseUI = (getLicenseFile(params) != null); 1094 boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params); 1095 1096 List<String> commandLine = new ArrayList<>(); 1097 1098 commandLine.add(TOOL_LIGHT_EXECUTABLE.fetchFrom(params)); 1099 if (enableLicenseUI) { 1100 commandLine.add("-dWixUILicenseRtf="+getLicenseFile(params)); 1101 } 1102 commandLine.add("-nologo"); 1103 commandLine.add("-spdb"); 1104 commandLine.add("-sice:60"); 1105 // ignore warnings due to "missing launcguage info" (ICE60) 1106 commandLine.add(candleOut.getAbsolutePath()); 1107 commandLine.add("-ext"); 1108 commandLine.add("WixUtilExtension"); 1109 if (enableLicenseUI || enableInstalldirUI) { 1110 commandLine.add("-ext"); 1111 commandLine.add("WixUIExtension.dll"); 1112 } 1113 1114 // Only needed if we using CA dll, so Wix can find it 1115 if (enableInstalldirUI) { 1116 commandLine.add("-b"); 1117 commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 1118 } 1119 1120 commandLine.add("-out"); 1121 commandLine.add(msiOut.getAbsolutePath()); 1122 1123 // create .msi 1124 pb = new ProcessBuilder(commandLine); 1125 1126 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1127 IOUtils.exec(pb); 1128 1129 candleOut.delete(); 1130 IOUtils.deleteRecursive(tmpDir); 1131 1132 return msiOut; 1133 } 1134 1135 public static void ensureByMutationFileIsRTF(File f) { 1136 if (f == null || !f.isFile()) return; 1137 1138 try { 1139 boolean existingLicenseIsRTF = false; 1140 1141 try (FileInputStream fin = new FileInputStream(f)) { 1142 byte[] firstBits = new byte[7]; 1143 1144 if (fin.read(firstBits) == firstBits.length) { 1145 String header = new String(firstBits); 1146 existingLicenseIsRTF = "{\\rtf1\\".equals(header); 1147 } 1148 } 1149 1150 if (!existingLicenseIsRTF) { 1151 List<String> oldLicense = Files.readAllLines(f.toPath()); 1152 try (Writer w = Files.newBufferedWriter( 1153 f.toPath(), Charset.forName("Windows-1252"))) { 1154 w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033" 1155 + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n" 1156 + "\\viewkind4\\uc1\\pard\\sa200\\sl276" 1157 + "\\slmult1\\lang9\\fs20 "); 1158 oldLicense.forEach(l -> { 1159 try { 1160 for (char c : l.toCharArray()) { 1161 // 0x00 <= ch < 0x20 Escaped (\'hh) 1162 // 0x20 <= ch < 0x80 Raw(non - escaped) char 1163 // 0x80 <= ch <= 0xFF Escaped(\ 'hh) 1164 // 0x5C, 0x7B, 0x7D (special RTF characters 1165 // \,{,})Escaped(\'hh) 1166 // ch > 0xff Escaped (\\ud###?) 1167 if (c < 0x10) { 1168 w.write("\\'0"); 1169 w.write(Integer.toHexString(c)); 1170 } else if (c > 0xff) { 1171 w.write("\\ud"); 1172 w.write(Integer.toString(c)); 1173 // \\uc1 is in the header and in effect 1174 // so we trail with a replacement char if 1175 // the font lacks that character - '?' 1176 w.write("?"); 1177 } else if ((c < 0x20) || (c >= 0x80) || 1178 (c == 0x5C) || (c == 0x7B) || 1179 (c == 0x7D)) { 1180 w.write("\\'"); 1181 w.write(Integer.toHexString(c)); 1182 } else { 1183 w.write(c); 1184 } 1185 } 1186 // blank lines are interpreted as paragraph breaks 1187 if (l.length() < 1) { 1188 w.write("\\par"); 1189 } else { 1190 w.write(" "); 1191 } 1192 w.write("\r\n"); 1193 } catch (IOException e) { 1194 Log.verbose(e); 1195 } 1196 }); 1197 w.write("}\r\n"); 1198 } 1199 } 1200 } catch (IOException e) { 1201 Log.verbose(e); 1202 } 1203 1204 } 1205 }