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