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