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 public static boolean isSupported() { 242 try { 243 return validateWixTools(); 244 } catch (Exception e) { 245 return false; 246 } 247 } 248 249 private static String findToolVersion(String toolName) { 250 try { 251 if (toolName == null || "".equals(toolName)) return null; 252 253 ProcessBuilder pb = new ProcessBuilder( 254 toolName, 255 "/?"); 256 VersionExtractor ve = new VersionExtractor("version (\\d+.\\d+)"); 257 // not interested in the output 258 IOUtils.exec(pb, true, ve); 259 String version = ve.getVersion(); 260 Log.verbose(MessageFormat.format( 261 I18N.getString("message.tool-version"), 262 toolName, version)); 263 return version; 264 } catch (Exception e) { 265 Log.verbose(e); 266 return null; 267 } 268 } 269 270 public static boolean validateWixTools() { 271 String candleVersion = findToolVersion(getCandlePath()); 272 String lightVersion = findToolVersion(getLightPath()); 273 274 // WiX 3.0+ is required 275 String minVersion = "3.0"; 276 277 if (VersionExtractor.isLessThan(candleVersion, minVersion)) { 278 Log.verbose(MessageFormat.format( 279 I18N.getString("message.wrong-tool-version"), 280 TOOL_CANDLE, candleVersion, minVersion)); 281 return false; 282 } 283 if (VersionExtractor.isLessThan(lightVersion, minVersion)) { 284 Log.verbose(MessageFormat.format( 285 I18N.getString("message.wrong-tool-version"), 286 TOOL_LIGHT, lightVersion, minVersion)); 287 return false; 288 } 289 return true; 290 } 291 292 @Override 293 public boolean validate(Map<String, ? super Object> params) 294 throws ConfigException { 295 try { 296 if (params == null) throw new ConfigException( 297 I18N.getString("error.parameters-null"), 298 I18N.getString("error.parameters-null.advice")); 299 300 // run basic validation to ensure requirements are met 301 // we are not interested in return code, only possible exception 302 if (!validateWixTools()){ 303 throw new ConfigException( 304 I18N.getString("error.no-wix-tools"), 305 I18N.getString("error.no-wix-tools.advice")); 306 } 307 308 String lightVersion = findToolVersion(getLightPath()); 309 if (!VersionExtractor.isLessThan(lightVersion, "3.6")) { 310 Log.verbose(I18N.getString("message.use-wix36-features")); 311 params.put(CAN_USE_WIX36.getID(), Boolean.TRUE); 312 } 313 314 /********* validate bundle parameters *************/ 315 316 String version = PRODUCT_VERSION.fetchFrom(params); 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(params); 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> params) 405 throws PackagerException, IOException { 406 File appImage = StandardBundlerParam.getPredefinedAppImage(params); 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(MSI_IMAGE_DIR.fetchFrom(params), 412 APP_NAME.fetchFrom(params)); 413 // copy everything from appImage dir into appDir/name 414 IOUtils.copyRecursive(appImage.toPath(), appDir.toPath()); 415 } else { 416 appDir = APP_BUNDLER.fetchFrom(params).doBundle(params, 417 MSI_IMAGE_DIR.fetchFrom(params), true); 418 } 419 420 params.put(WIN_APP_IMAGE.getID(), appDir); 421 422 String licenseFile = LICENSE_FILE.fetchFrom(params); 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(params), 427 lfile.getName()); 428 429 IOUtils.copyFile(lfile, destFile); 430 destFile.setWritable(true); 431 ensureByMutationFileIsRTF(destFile); 432 } 433 434 // copy file association icons 435 List<Map<String, ? super Object>> fileAssociations = 436 FILE_ASSOCIATIONS.fetchFrom(params); 437 for (Map<String, ? super Object> fa : fileAssociations) { 438 File icon = FA_ICON.fetchFrom(fa); 439 if (icon == null) { 440 continue; 441 } 442 443 File faIconFile = new File(appDir, icon.getName()); 444 445 if (icon.exists()) { 446 try { 447 IOUtils.copyFile(icon, faIconFile); 448 } catch (IOException e) { 449 Log.verbose(e); 450 } 451 } 452 } 453 454 return appDir != null; 455 } 456 457 public File bundle(Map<String, ? super Object> params, File outdir) 458 throws PackagerException { 459 460 IOUtils.writableOutputDir(outdir.toPath()); 461 462 // validate we have valid tools before continuing 463 String light = getLightPath(); 464 String candle = getCandlePath(); 465 if (light == null || !new File(light).isFile() || 466 candle == null || !new File(candle).isFile()) { 467 Log.verbose(MessageFormat.format( 468 I18N.getString("message.light-file-string"), light)); 469 Log.verbose(MessageFormat.format( 470 I18N.getString("message.candle-file-string"), candle)); 471 throw new PackagerException("error.no-wix-tools"); 472 } 473 474 Map<String, String> wixVars = null; 475 476 File imageDir = MSI_IMAGE_DIR.fetchFrom(params); 477 try { 478 imageDir.mkdirs(); 479 480 boolean menuShortcut = MENU_HINT.fetchFrom(params); 481 boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(params); 482 if (!menuShortcut && !desktopShortcut) { 483 // both can not be false - user will not find the app 484 Log.verbose(I18N.getString("message.one-shortcut-required")); 485 params.put(MENU_HINT.getID(), true); 486 } 487 488 prepareBasicProjectConfig(params); 489 if (prepareProto(params)) { 490 wixVars = prepareWiXConfig(params); 491 492 File configScriptSrc = getConfig_Script(params); 493 if (configScriptSrc.exists()) { 494 // we need to be running post script in the image folder 495 496 // NOTE: Would it be better to generate it to the image 497 // folder and save only if "verbose" is requested? 498 499 // for now we replicate it 500 File configScript = 501 new File(imageDir, configScriptSrc.getName()); 502 IOUtils.copyFile(configScriptSrc, configScript); 503 Log.verbose(MessageFormat.format( 504 I18N.getString("message.running-wsh-script"), 505 configScript.getAbsolutePath())); 506 IOUtils.run("wscript", configScript); 507 } 508 return buildMSI(params, wixVars, 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 void 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 } 532 533 private static String relativePath(File basedir, File file) { 534 return file.getAbsolutePath().substring( 535 basedir.getAbsolutePath().length() + 1); 536 } 537 538 private void prepareIconsFile( 539 Map<String, ? super Object> params) throws IOException { 540 541 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 542 543 List<Map<String, ? super Object>> addLaunchers = 544 ADD_LAUNCHERS.fetchFrom(params); 545 546 XMLOutputFactory xmlFactory = XMLOutputFactory.newInstance(); 547 try (Writer w = new BufferedWriter(new FileWriter(new File( 548 CONFIG_ROOT.fetchFrom(params), "icons.wxi")))) { 549 XMLStreamWriter xml = xmlFactory.createXMLStreamWriter(w); 550 551 xml.writeStartDocument(); 552 xml.writeStartElement("Include"); 553 554 File launcher = new File(imageRootDir, 555 WinAppBundler.getLauncherRelativePath(params)); 556 if (launcher.exists()) { 557 String iconPath = launcher.getAbsolutePath().replace(".exe", ".ico"); 558 if (MENU_HINT.fetchFrom(params)) { 559 xml.writeStartElement("Icon"); 560 xml.writeAttribute("Id", "StartMenuIcon.exe"); 561 xml.writeAttribute("SourceFile", iconPath); 562 xml.writeEndElement(); 563 } 564 if (SHORTCUT_HINT.fetchFrom(params)) { 565 xml.writeStartElement("Icon"); 566 xml.writeAttribute("Id", "DesktopIcon.exe"); 567 xml.writeAttribute("SourceFile", iconPath); 568 xml.writeEndElement(); 569 } 570 } 571 572 for (int i = 0; i < addLaunchers.size(); i++) { 573 Map<String, ? super Object> sl = addLaunchers.get(i); 574 if (SHORTCUT_HINT.fetchFrom(sl) || MENU_HINT.fetchFrom(sl)) { 575 File addLauncher = new File(imageRootDir, 576 WinAppBundler.getLauncherRelativePath(sl)); 577 String addLauncherPath 578 = relativePath(imageRootDir, addLauncher); 579 String addLauncherIconPath 580 = addLauncherPath.replace(".exe", ".ico"); 581 582 xml.writeStartElement("Icon"); 583 xml.writeAttribute("Id", "Launcher" + i + ".exe"); 584 xml.writeAttribute("SourceFile", addLauncherIconPath); 585 xml.writeEndElement(); 586 } 587 } 588 589 xml.writeEndElement(); 590 xml.writeEndDocument(); 591 xml.flush(); 592 xml.close(); 593 } catch (XMLStreamException ex) { 594 Log.verbose(ex); 595 throw new IOException(ex); 596 } 597 } 598 599 Map<String, String> prepareMainProjectFile( 600 Map<String, ? super Object> params) throws IOException { 601 Map<String, String> data = new HashMap<>(); 602 603 UUID productGUID = UUID.randomUUID(); 604 605 Log.verbose(MessageFormat.format( 606 I18N.getString("message.generated-product-guid"), 607 productGUID.toString())); 608 609 // we use random GUID for product itself but 610 // user provided for upgrade guid 611 // Upgrade guid is important to decide whether it is an upgrade of 612 // installed app. I.e. we need it to be the same for 613 // 2 different versions of app if possible 614 data.put("JpProductCode", productGUID.toString()); 615 data.put("JpProductUpgradeCode", 616 UPGRADE_UUID.fetchFrom(params).toString()); 617 618 if (!UPGRADE_UUID.getIsDefaultValue()) { 619 data.put("JpAllowDowngrades", "yes"); 620 } 621 622 if (CAN_USE_WIX36.fetchFrom(params)) { 623 data.put("JpWixVersion36OrNewer", "yes"); 624 } 625 626 data.put("JpAppName", APP_NAME.fetchFrom(params)); 627 data.put("JpAppDescription", DESCRIPTION.fetchFrom(params)); 628 data.put("JpAppVendor", VENDOR.fetchFrom(params)); 629 data.put("JpAppVersion", PRODUCT_VERSION.fetchFrom(params)); 630 631 data.put("JpConfigDir", CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 632 633 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 634 635 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 636 data.put("JpIsSystemWide", "yes"); 637 } 638 639 String licenseFile = LICENSE_FILE.fetchFrom(params); 640 if (licenseFile != null) { 641 String lname = new File(licenseFile).getName(); 642 File destFile = new File(CONFIG_ROOT.fetchFrom(params), lname); 643 data.put("JpLicenseRtf", destFile.getAbsolutePath()); 644 } 645 646 // Copy CA dll to include with installer 647 if (INSTALLDIR_CHOOSER.fetchFrom(params)) { 648 data.put("JpInstallDirChooser", "yes"); 649 String fname = "wixhelper.dll"; 650 try (InputStream is = getResourceAsStream(fname)) { 651 Files.copy(is, Paths.get( 652 CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), fname)); 653 } 654 } 655 656 // Copy l10n files. 657 for (String loc : Arrays.asList("en", "ja", "zh_CN")) { 658 String fname = "MsiInstallerStrings_" + loc + ".wxl"; 659 try (InputStream is = getResourceAsStream(fname)) { 660 Files.copy(is, Paths.get( 661 CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), fname)); 662 } 663 } 664 665 try (InputStream is = getResourceAsStream("main.wxs")) { 666 Files.copy(is, Paths.get( 667 getConfig_ProjectFile(params).getAbsolutePath())); 668 } 669 670 return data; 671 } 672 private int id; 673 private int compId; 674 private final static String LAUNCHER_ID = "LauncherId"; 675 676 private void walkFileTree(Map<String, ? super Object> params, 677 File root, PrintStream out, String prefix) { 678 List<File> dirs = new ArrayList<>(); 679 List<File> files = new ArrayList<>(); 680 681 if (!root.isDirectory()) { 682 throw new RuntimeException( 683 MessageFormat.format( 684 I18N.getString("error.cannot-walk-directory"), 685 root.getAbsolutePath())); 686 } 687 688 // sort to files and dirs 689 File[] children = root.listFiles(); 690 if (children != null) { 691 for (File f : children) { 692 if (f.isDirectory()) { 693 dirs.add(f); 694 } else { 695 files.add(f); 696 } 697 } 698 } 699 700 // have files => need to output component 701 out.println(prefix + " <Component Id=\"comp" + (compId++) 702 + "\" DiskId=\"1\"" 703 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 704 + " Win64=\"yes\"" 705 + ">"); 706 out.println(prefix + " <CreateFolder/>"); 707 out.println(prefix + " <RemoveFolder Id=\"RemoveDir" 708 + (id++) + "\" On=\"uninstall\" />"); 709 710 boolean needRegistryKey = !MSI_SYSTEM_WIDE.fetchFrom(params); 711 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 712 File launcherFile = new File(imageRootDir, 713 WinAppBundler.getLauncherRelativePath(params)); 714 715 // Find out if we need to use registry. We need it if 716 // - we doing user level install as file can not serve as KeyPath 717 // - if we adding shortcut in this component 718 719 for (File f: files) { 720 boolean isLauncher = f.equals(launcherFile); 721 if (isLauncher) { 722 needRegistryKey = true; 723 } 724 } 725 726 if (needRegistryKey) { 727 // has to be under HKCU to make WiX happy 728 out.println(prefix + " <RegistryKey Root=\"HKCU\" " 729 + " Key=\"Software\\" + VENDOR.fetchFrom(params) + "\\" 730 + APP_NAME.fetchFrom(params) + "\"" 731 + (CAN_USE_WIX36.fetchFrom(params) ? 732 ">" : " Action=\"createAndRemoveOnUninstall\">")); 733 out.println(prefix 734 + " <RegistryValue Name=\"Version\" Value=\"" 735 + VERSION.fetchFrom(params) 736 + "\" Type=\"string\" KeyPath=\"yes\"/>"); 737 out.println(prefix + " </RegistryKey>"); 738 } 739 740 boolean menuShortcut = MENU_HINT.fetchFrom(params); 741 boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(params); 742 743 Map<String, String> idToFileMap = new TreeMap<>(); 744 boolean launcherSet = false; 745 746 for (File f : files) { 747 boolean isLauncher = f.equals(launcherFile); 748 749 launcherSet = launcherSet || isLauncher; 750 751 boolean doShortcuts = 752 isLauncher && (menuShortcut || desktopShortcut); 753 754 String thisFileId = isLauncher ? LAUNCHER_ID : ("FileId" + (id++)); 755 idToFileMap.put(f.getName(), thisFileId); 756 757 out.println(prefix + " <File Id=\"" + 758 thisFileId + "\"" 759 + " Name=\"" + f.getName() + "\" " 760 + " Source=\"" + relativePath(imageRootDir, f) + "\"" 761 + " ProcessorArchitecture=\"x64\"" + ">"); 762 if (doShortcuts && desktopShortcut) { 763 out.println(prefix 764 + " <Shortcut Id=\"desktopShortcut\" Directory=" 765 + "\"DesktopFolder\"" 766 + " Name=\"" + APP_NAME.fetchFrom(params) 767 + "\" WorkingDirectory=\"INSTALLDIR\"" 768 + " Advertise=\"no\" Icon=\"DesktopIcon.exe\"" 769 + " IconIndex=\"0\" />"); 770 } 771 if (doShortcuts && menuShortcut) { 772 out.println(prefix 773 + " <Shortcut Id=\"ExeShortcut\" Directory=" 774 + "\"ProgramMenuDir\"" 775 + " Name=\"" + APP_NAME.fetchFrom(params) 776 + "\" Advertise=\"no\" Icon=\"StartMenuIcon.exe\"" 777 + " IconIndex=\"0\" />"); 778 } 779 780 List<Map<String, ? super Object>> addLaunchers = 781 ADD_LAUNCHERS.fetchFrom(params); 782 for (int i = 0; i < addLaunchers.size(); i++) { 783 Map<String, ? super Object> sl = addLaunchers.get(i); 784 File addLauncherFile = new File(imageRootDir, 785 WinAppBundler.getLauncherRelativePath(sl)); 786 if (f.equals(addLauncherFile)) { 787 if (SHORTCUT_HINT.fetchFrom(sl)) { 788 out.println(prefix 789 + " <Shortcut Id=\"desktopShortcut" 790 + i + "\" Directory=\"DesktopFolder\"" 791 + " Name=\"" + APP_NAME.fetchFrom(sl) 792 + "\" WorkingDirectory=\"INSTALLDIR\"" 793 + " Advertise=\"no\" Icon=\"Launcher" 794 + i + ".exe\" IconIndex=\"0\" />"); 795 } 796 if (MENU_HINT.fetchFrom(sl)) { 797 out.println(prefix 798 + " <Shortcut Id=\"ExeShortcut" 799 + i + "\" Directory=\"ProgramMenuDir\"" 800 + " Name=\"" + APP_NAME.fetchFrom(sl) 801 + "\" Advertise=\"no\" Icon=\"Launcher" 802 + i + ".exe\" IconIndex=\"0\" />"); 803 // Should we allow different menu groups? Not for now. 804 } 805 } 806 } 807 out.println(prefix + " </File>"); 808 } 809 810 if (launcherSet) { 811 List<Map<String, ? super Object>> fileAssociations = 812 FILE_ASSOCIATIONS.fetchFrom(params); 813 Set<String> defaultedMimes = new TreeSet<>(); 814 for (Map<String, ? super Object> fa : fileAssociations) { 815 String description = FA_DESCRIPTION.fetchFrom(fa); 816 List<String> extensions = FA_EXTENSIONS.fetchFrom(fa); 817 List<String> mimeTypes = FA_CONTENT_TYPE.fetchFrom(fa); 818 File icon = FA_ICON.fetchFrom(fa); 819 820 String mime = (mimeTypes == null || 821 mimeTypes.isEmpty()) ? null : mimeTypes.get(0); 822 823 String entryName = APP_REGISTRY_NAME.fetchFrom(params) + "File"; 824 825 if (extensions == null) { 826 Log.verbose(I18N.getString( 827 "message.creating-association-with-null-extension")); 828 829 out.print(prefix + " <ProgId Id='" + entryName 830 + "' Description='" + description + "'"); 831 if (icon != null && icon.exists()) { 832 out.print(" Icon='" + idToFileMap.get(icon.getName()) 833 + "' IconIndex='0'"); 834 } 835 out.println(" />"); 836 } else { 837 for (String ext : extensions) { 838 839 entryName = ext.toUpperCase() + "File"; 840 841 out.print(prefix + " <ProgId Id='" + entryName 842 + "' Description='" + description + "'"); 843 if (icon != null && icon.exists()) { 844 out.print(" Icon='" 845 + idToFileMap.get(icon.getName()) 846 + "' IconIndex='0'"); 847 } 848 out.println(">"); 849 850 out.print(prefix + " <Extension Id='" 851 + ext + "' Advertise='no'"); 852 if (mime == null) { 853 out.println(">"); 854 } else { 855 out.println(" ContentType='" + mime + "'>"); 856 if (!defaultedMimes.contains(mime)) { 857 out.println(prefix 858 + " <MIME ContentType='" 859 + mime + "' Default='yes' />"); 860 defaultedMimes.add(mime); 861 } 862 } 863 out.println(prefix 864 + " <Verb Id='open' Command='Open' " 865 + "TargetFile='" + LAUNCHER_ID 866 + "' Argument='\"%1\"' />"); 867 out.println(prefix + " </Extension>"); 868 out.println(prefix + " </ProgId>"); 869 } 870 } 871 } 872 } 873 874 out.println(prefix + " </Component>"); 875 876 for (File d : dirs) { 877 out.println(prefix + " <Directory Id=\"dirid" + (id++) 878 + "\" Name=\"" + d.getName() + "\">"); 879 walkFileTree(params, d, out, prefix + " "); 880 out.println(prefix + " </Directory>"); 881 } 882 } 883 884 void prepareContentList(Map<String, ? super Object> params) 885 throws FileNotFoundException { 886 File f = new File( 887 CONFIG_ROOT.fetchFrom(params), MSI_PROJECT_CONTENT_FILE); 888 889 try (PrintStream out = new PrintStream(f)) { 890 891 // opening 892 out.println("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"); 893 out.println("<Include>"); 894 895 out.println(" <Directory Id=\"TARGETDIR\" Name=\"SourceDir\">"); 896 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 897 // install to programfiles 898 out.println(" <Directory Id=\"ProgramFiles64Folder\" " 899 + "Name=\"PFiles\">"); 900 } else { 901 // install to user folder 902 out.println( 903 " <Directory Name=\"AppData\" Id=\"LocalAppDataFolder\">"); 904 } 905 906 // reset counters 907 compId = 0; 908 id = 0; 909 910 // We should get valid folder or subfolders 911 String installDir = WINDOWS_INSTALL_DIR.fetchFrom(params); 912 String [] installDirs = installDir.split(Pattern.quote("\\")); 913 for (int i = 0; i < (installDirs.length - 1); i++) { 914 out.println(" <Directory Id=\"SUBDIR" + i + "\" Name=\"" 915 + installDirs[i] + "\">"); 916 if (!MSI_SYSTEM_WIDE.fetchFrom(params)) { 917 out.println(" <Component Id=\"comp" + (compId++) 918 + "\" DiskId=\"1\"" 919 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 920 + " Win64=\"yes\"" 921 + ">"); 922 out.println("<CreateFolder/>"); 923 // has to be under HKCU to make WiX happy 924 out.println(" <RegistryKey Root=\"HKCU\" " 925 + " Key=\"Software\\" + VENDOR.fetchFrom(params) + "\\" 926 + APP_NAME.fetchFrom(params) + "\"" 927 + (CAN_USE_WIX36.fetchFrom(params) ? 928 ">" : " Action=\"createAndRemoveOnUninstall\">")); 929 out.println(" <RegistryValue Name=\"Version\" Value=\"" 930 + VERSION.fetchFrom(params) 931 + "\" Type=\"string\" KeyPath=\"yes\"/>"); 932 out.println(" </RegistryKey>"); 933 out.println(" <RemoveFolder Id=\"RemoveDir" 934 + (id++) + "\" Directory=\"SUBDIR" + i 935 + "\" On=\"uninstall\" />"); 936 out.println("</Component>"); 937 } 938 } 939 940 out.println(" <Directory Id=\"APPLICATIONFOLDER\" Name=\"" 941 + installDirs[installDirs.length - 1] + "\">"); 942 943 // dynamic part 944 walkFileTree(params, WIN_APP_IMAGE.fetchFrom(params), out, " "); 945 946 // closing 947 for (int i = 0; i < installDirs.length; i++) { 948 out.println(" </Directory>"); 949 } 950 out.println(" </Directory>"); 951 952 // for shortcuts 953 if (SHORTCUT_HINT.fetchFrom(params)) { 954 out.println(" <Directory Id=\"DesktopFolder\" />"); 955 } 956 if (MENU_HINT.fetchFrom(params)) { 957 out.println(" <Directory Id=\"ProgramMenuFolder\">"); 958 out.println(" <Directory Id=\"ProgramMenuDir\" Name=\"" 959 + MENU_GROUP.fetchFrom(params) + "\">"); 960 out.println(" <Component Id=\"comp" + (compId++) + "\"" 961 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 962 + " Win64=\"yes\"" 963 + ">"); 964 out.println(" <RemoveFolder Id=\"ProgramMenuDir\" " 965 + "On=\"uninstall\" />"); 966 // This has to be under HKCU to make WiX happy. 967 // There are numberous discussions on this amoung WiX users 968 // (if user A installs and user B uninstalls key is left behind) 969 // there are suggested workarounds but none are appealing. 970 // Leave it for now 971 out.println( 972 " <RegistryValue Root=\"HKCU\" Key=\"Software\\" 973 + VENDOR.fetchFrom(params) + "\\" 974 + APP_NAME.fetchFrom(params) 975 + "\" Type=\"string\" Value=\"\" />"); 976 out.println(" </Component>"); 977 out.println(" </Directory>"); 978 out.println(" </Directory>"); 979 } 980 981 out.println(" </Directory>"); 982 983 out.println(" <Feature Id=\"DefaultFeature\" " 984 + "Title=\"Main Feature\" Level=\"1\">"); 985 for (int j = 0; j < compId; j++) { 986 out.println(" <ComponentRef Id=\"comp" + j + "\" />"); 987 } 988 // component is defined in the main.wsx 989 out.println( 990 " <ComponentRef Id=\"CleanupMainApplicationFolder\" />"); 991 out.println(" </Feature>"); 992 out.println("</Include>"); 993 994 } 995 } 996 997 private File getConfig_ProjectFile(Map<String, ? super Object> params) { 998 return new File(CONFIG_ROOT.fetchFrom(params), 999 APP_NAME.fetchFrom(params) + ".wxs"); 1000 } 1001 1002 private Map<String, String> prepareWiXConfig( 1003 Map<String, ? super Object> params) throws IOException { 1004 prepareContentList(params); 1005 prepareIconsFile(params); 1006 return prepareMainProjectFile(params); 1007 } 1008 1009 private final static String MSI_PROJECT_CONTENT_FILE = "bundle.wxi"; 1010 1011 private File buildMSI(Map<String, ? super Object> params, 1012 Map<String, String> wixVars, File outdir) 1013 throws IOException { 1014 File tmpDir = new File(TEMP_ROOT.fetchFrom(params), "tmp"); 1015 File candleOut = new File( 1016 tmpDir, APP_NAME.fetchFrom(params) + ".wixobj"); 1017 File msiOut = new File( 1018 outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi"); 1019 1020 Log.verbose(MessageFormat.format(I18N.getString( 1021 "message.preparing-msi-config"), msiOut.getAbsolutePath())); 1022 1023 msiOut.getParentFile().mkdirs(); 1024 1025 List<String> commandLine = new ArrayList<>(Arrays.asList( 1026 getCandlePath(), 1027 "-nologo", 1028 getConfig_ProjectFile(params).getAbsolutePath(), 1029 "-ext", "WixUtilExtension", 1030 "-out", candleOut.getAbsolutePath())); 1031 for(Map.Entry<String, String> wixVar: wixVars.entrySet()) { 1032 String v = "-d" + wixVar.getKey() + "=" + wixVar.getValue(); 1033 commandLine.add(v); 1034 } 1035 ProcessBuilder pb = new ProcessBuilder(commandLine); 1036 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1037 IOUtils.exec(pb); 1038 1039 Log.verbose(MessageFormat.format(I18N.getString( 1040 "message.generating-msi"), msiOut.getAbsolutePath())); 1041 1042 boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null); 1043 boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params); 1044 1045 commandLine = new ArrayList<>(); 1046 1047 commandLine.add(getLightPath()); 1048 1049 commandLine.add("-nologo"); 1050 commandLine.add("-spdb"); 1051 if (!MSI_SYSTEM_WIDE.fetchFrom(params)) { 1052 commandLine.add("-sice:ICE91"); 1053 } 1054 commandLine.add(candleOut.getAbsolutePath()); 1055 commandLine.add("-ext"); 1056 commandLine.add("WixUtilExtension"); 1057 if (enableLicenseUI || enableInstalldirUI) { 1058 commandLine.add("-ext"); 1059 commandLine.add("WixUIExtension"); 1060 } 1061 1062 commandLine.add("-loc"); 1063 commandLine.add(new File(CONFIG_ROOT.fetchFrom(params), I18N.getString( 1064 "resource.wxl-file-name")).getAbsolutePath()); 1065 1066 // Only needed if we using CA dll, so Wix can find it 1067 if (enableInstalldirUI) { 1068 commandLine.add("-b"); 1069 commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 1070 } 1071 1072 commandLine.add("-out"); 1073 commandLine.add(msiOut.getAbsolutePath()); 1074 1075 // create .msi 1076 pb = new ProcessBuilder(commandLine); 1077 1078 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1079 IOUtils.exec(pb); 1080 1081 candleOut.delete(); 1082 IOUtils.deleteRecursive(tmpDir); 1083 1084 return msiOut; 1085 } 1086 1087 public static void ensureByMutationFileIsRTF(File f) { 1088 if (f == null || !f.isFile()) return; 1089 1090 try { 1091 boolean existingLicenseIsRTF = false; 1092 1093 try (FileInputStream fin = new FileInputStream(f)) { 1094 byte[] firstBits = new byte[7]; 1095 1096 if (fin.read(firstBits) == firstBits.length) { 1097 String header = new String(firstBits); 1098 existingLicenseIsRTF = "{\\rtf1\\".equals(header); 1099 } 1100 } 1101 1102 if (!existingLicenseIsRTF) { 1103 List<String> oldLicense = Files.readAllLines(f.toPath()); 1104 try (Writer w = Files.newBufferedWriter( 1105 f.toPath(), Charset.forName("Windows-1252"))) { 1106 w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033" 1107 + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n" 1108 + "\\viewkind4\\uc1\\pard\\sa200\\sl276" 1109 + "\\slmult1\\lang9\\fs20 "); 1110 oldLicense.forEach(l -> { 1111 try { 1112 for (char c : l.toCharArray()) { 1113 // 0x00 <= ch < 0x20 Escaped (\'hh) 1114 // 0x20 <= ch < 0x80 Raw(non - escaped) char 1115 // 0x80 <= ch <= 0xFF Escaped(\ 'hh) 1116 // 0x5C, 0x7B, 0x7D (special RTF characters 1117 // \,{,})Escaped(\'hh) 1118 // ch > 0xff Escaped (\\ud###?) 1119 if (c < 0x10) { 1120 w.write("\\'0"); 1121 w.write(Integer.toHexString(c)); 1122 } else if (c > 0xff) { 1123 w.write("\\ud"); 1124 w.write(Integer.toString(c)); 1125 // \\uc1 is in the header and in effect 1126 // so we trail with a replacement char if 1127 // the font lacks that character - '?' 1128 w.write("?"); 1129 } else if ((c < 0x20) || (c >= 0x80) || 1130 (c == 0x5C) || (c == 0x7B) || 1131 (c == 0x7D)) { 1132 w.write("\\'"); 1133 w.write(Integer.toHexString(c)); 1134 } else { 1135 w.write(c); 1136 } 1137 } 1138 // blank lines are interpreted as paragraph breaks 1139 if (l.length() < 1) { 1140 w.write("\\par"); 1141 } else { 1142 w.write(" "); 1143 } 1144 w.write("\r\n"); 1145 } catch (IOException e) { 1146 Log.verbose(e); 1147 } 1148 }); 1149 w.write("}\r\n"); 1150 } 1151 } 1152 } catch (IOException e) { 1153 Log.verbose(e); 1154 } 1155 1156 } 1157 }