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