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