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