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 String regName = APP_REGISTRY_NAME.fetchFrom(params); 814 Set<String> defaultedMimes = new TreeSet<>(); 815 int count = 0; 816 for (Map<String, ? super Object> fa : fileAssociations) { 817 String description = FA_DESCRIPTION.fetchFrom(fa); 818 List<String> extensions = FA_EXTENSIONS.fetchFrom(fa); 819 List<String> mimeTypes = FA_CONTENT_TYPE.fetchFrom(fa); 820 File icon = FA_ICON.fetchFrom(fa); 821 822 String mime = (mimeTypes == null || 823 mimeTypes.isEmpty()) ? null : mimeTypes.get(0); 824 825 if (extensions == null) { 826 Log.verbose(I18N.getString( 827 "message.creating-association-with-null-extension")); 828 829 String entryName = regName + "File"; 830 if (count > 0) { 831 entryName += "." + count; 832 } 833 count++; 834 out.print(prefix + " <ProgId Id='" + entryName 835 + "' Description='" + description + "'"); 836 if (icon != null && icon.exists()) { 837 out.print(" Icon='" + idToFileMap.get(icon.getName()) 838 + "' IconIndex='0'"); 839 } 840 out.println(" />"); 841 } else { 842 for (String ext : extensions) { 843 String entryName = regName + "File"; 844 if (count > 0) { 845 entryName += "." + count; 846 } 847 count++; 848 849 out.print(prefix + " <ProgId Id='" + entryName 850 + "' Description='" + description + "'"); 851 if (icon != null && icon.exists()) { 852 out.print(" Icon='" 853 + idToFileMap.get(icon.getName()) 854 + "' IconIndex='0'"); 855 } 856 out.println(">"); 857 858 if (extensions == null) { 859 Log.verbose(I18N.getString( 860 "message.creating-association-with-null-extension")); 861 } else { 862 out.print(prefix + " <Extension Id='" 863 + ext + "' Advertise='no'"); 864 if (mime == null) { 865 out.println(">"); 866 } else { 867 out.println(" ContentType='" + mime + "'>"); 868 if (!defaultedMimes.contains(mime)) { 869 out.println(prefix 870 + " <MIME ContentType='" 871 + mime + "' Default='yes' />"); 872 defaultedMimes.add(mime); 873 } 874 } 875 out.println(prefix 876 + " <Verb Id='open' Command='Open' " 877 + "TargetFile='" + LAUNCHER_ID 878 + "' Argument='\"%1\"' />"); 879 out.println(prefix + " </Extension>"); 880 } 881 out.println(prefix + " </ProgId>"); 882 } 883 } 884 } 885 } 886 887 out.println(prefix + " </Component>"); 888 889 for (File d : dirs) { 890 out.println(prefix + " <Directory Id=\"dirid" + (id++) 891 + "\" Name=\"" + d.getName() + "\">"); 892 walkFileTree(params, d, out, prefix + " "); 893 out.println(prefix + " </Directory>"); 894 } 895 } 896 897 void prepareContentList(Map<String, ? super Object> params) 898 throws FileNotFoundException { 899 File f = new File( 900 CONFIG_ROOT.fetchFrom(params), MSI_PROJECT_CONTENT_FILE); 901 902 try (PrintStream out = new PrintStream(f)) { 903 904 // opening 905 out.println("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"); 906 out.println("<Include>"); 907 908 out.println(" <Directory Id=\"TARGETDIR\" Name=\"SourceDir\">"); 909 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 910 // install to programfiles 911 out.println(" <Directory Id=\"ProgramFiles64Folder\" " 912 + "Name=\"PFiles\">"); 913 } else { 914 // install to user folder 915 out.println( 916 " <Directory Name=\"AppData\" Id=\"LocalAppDataFolder\">"); 917 } 918 919 // reset counters 920 compId = 0; 921 id = 0; 922 923 // We should get valid folder or subfolders 924 String installDir = WINDOWS_INSTALL_DIR.fetchFrom(params); 925 String [] installDirs = installDir.split(Pattern.quote("\\")); 926 for (int i = 0; i < (installDirs.length - 1); i++) { 927 out.println(" <Directory Id=\"SUBDIR" + i + "\" Name=\"" 928 + installDirs[i] + "\">"); 929 if (!MSI_SYSTEM_WIDE.fetchFrom(params)) { 930 out.println(" <Component Id=\"comp" + (compId++) 931 + "\" DiskId=\"1\"" 932 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 933 + " Win64=\"yes\"" 934 + ">"); 935 out.println("<CreateFolder/>"); 936 // has to be under HKCU to make WiX happy 937 out.println(" <RegistryKey Root=\"HKCU\" " 938 + " Key=\"Software\\" + VENDOR.fetchFrom(params) + "\\" 939 + APP_NAME.fetchFrom(params) + "\"" 940 + (CAN_USE_WIX36.fetchFrom(params) ? 941 ">" : " Action=\"createAndRemoveOnUninstall\">")); 942 out.println(" <RegistryValue Name=\"Version\" Value=\"" 943 + VERSION.fetchFrom(params) 944 + "\" Type=\"string\" KeyPath=\"yes\"/>"); 945 out.println(" </RegistryKey>"); 946 out.println(" <RemoveFolder Id=\"RemoveDir" 947 + (id++) + "\" Directory=\"SUBDIR" + i 948 + "\" On=\"uninstall\" />"); 949 out.println("</Component>"); 950 } 951 } 952 953 out.println(" <Directory Id=\"APPLICATIONFOLDER\" Name=\"" 954 + installDirs[installDirs.length - 1] + "\">"); 955 956 // dynamic part 957 walkFileTree(params, WIN_APP_IMAGE.fetchFrom(params), out, " "); 958 959 // closing 960 for (int i = 0; i < installDirs.length; i++) { 961 out.println(" </Directory>"); 962 } 963 out.println(" </Directory>"); 964 965 // for shortcuts 966 if (SHORTCUT_HINT.fetchFrom(params)) { 967 out.println(" <Directory Id=\"DesktopFolder\" />"); 968 } 969 if (MENU_HINT.fetchFrom(params)) { 970 out.println(" <Directory Id=\"ProgramMenuFolder\">"); 971 out.println(" <Directory Id=\"ProgramMenuDir\" Name=\"" 972 + MENU_GROUP.fetchFrom(params) + "\">"); 973 out.println(" <Component Id=\"comp" + (compId++) + "\"" 974 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 975 + " Win64=\"yes\"" 976 + ">"); 977 out.println(" <RemoveFolder Id=\"ProgramMenuDir\" " 978 + "On=\"uninstall\" />"); 979 // This has to be under HKCU to make WiX happy. 980 // There are numberous discussions on this amoung WiX users 981 // (if user A installs and user B uninstalls key is left behind) 982 // there are suggested workarounds but none are appealing. 983 // Leave it for now 984 out.println( 985 " <RegistryValue Root=\"HKCU\" Key=\"Software\\" 986 + VENDOR.fetchFrom(params) + "\\" 987 + APP_NAME.fetchFrom(params) 988 + "\" Type=\"string\" Value=\"\" />"); 989 out.println(" </Component>"); 990 out.println(" </Directory>"); 991 out.println(" </Directory>"); 992 } 993 994 out.println(" </Directory>"); 995 996 out.println(" <Feature Id=\"DefaultFeature\" " 997 + "Title=\"Main Feature\" Level=\"1\">"); 998 for (int j = 0; j < compId; j++) { 999 out.println(" <ComponentRef Id=\"comp" + j + "\" />"); 1000 } 1001 // component is defined in the main.wsx 1002 out.println( 1003 " <ComponentRef Id=\"CleanupMainApplicationFolder\" />"); 1004 out.println(" </Feature>"); 1005 out.println("</Include>"); 1006 1007 } 1008 } 1009 1010 private File getConfig_ProjectFile(Map<String, ? super Object> params) { 1011 return new File(CONFIG_ROOT.fetchFrom(params), 1012 APP_NAME.fetchFrom(params) + ".wxs"); 1013 } 1014 1015 private Map<String, String> prepareWiXConfig( 1016 Map<String, ? super Object> params) throws IOException { 1017 prepareContentList(params); 1018 prepareIconsFile(params); 1019 return prepareMainProjectFile(params); 1020 } 1021 1022 private final static String MSI_PROJECT_CONTENT_FILE = "bundle.wxi"; 1023 1024 private File buildMSI(Map<String, ? super Object> params, 1025 Map<String, String> wixVars, File outdir) 1026 throws IOException { 1027 File tmpDir = new File(TEMP_ROOT.fetchFrom(params), "tmp"); 1028 File candleOut = new File( 1029 tmpDir, APP_NAME.fetchFrom(params) + ".wixobj"); 1030 File msiOut = new File( 1031 outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi"); 1032 1033 Log.verbose(MessageFormat.format(I18N.getString( 1034 "message.preparing-msi-config"), msiOut.getAbsolutePath())); 1035 1036 msiOut.getParentFile().mkdirs(); 1037 1038 List<String> commandLine = new ArrayList<>(Arrays.asList( 1039 getCandlePath(), 1040 "-nologo", 1041 getConfig_ProjectFile(params).getAbsolutePath(), 1042 "-ext", "WixUtilExtension", 1043 "-out", candleOut.getAbsolutePath())); 1044 for(Map.Entry<String, String> wixVar: wixVars.entrySet()) { 1045 String v = "-d" + wixVar.getKey() + "=" + wixVar.getValue(); 1046 commandLine.add(v); 1047 } 1048 ProcessBuilder pb = new ProcessBuilder(commandLine); 1049 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1050 IOUtils.exec(pb); 1051 1052 Log.verbose(MessageFormat.format(I18N.getString( 1053 "message.generating-msi"), msiOut.getAbsolutePath())); 1054 1055 boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null); 1056 boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params); 1057 1058 commandLine = new ArrayList<>(); 1059 1060 commandLine.add(getLightPath()); 1061 1062 commandLine.add("-nologo"); 1063 commandLine.add("-spdb"); 1064 if (!MSI_SYSTEM_WIDE.fetchFrom(params)) { 1065 commandLine.add("-sice:ICE91"); 1066 } 1067 commandLine.add(candleOut.getAbsolutePath()); 1068 commandLine.add("-ext"); 1069 commandLine.add("WixUtilExtension"); 1070 if (enableLicenseUI || enableInstalldirUI) { 1071 commandLine.add("-ext"); 1072 commandLine.add("WixUIExtension"); 1073 } 1074 1075 commandLine.add("-loc"); 1076 commandLine.add(new File(CONFIG_ROOT.fetchFrom(params), I18N.getString( 1077 "resource.wxl-file-name")).getAbsolutePath()); 1078 1079 // Only needed if we using CA dll, so Wix can find it 1080 if (enableInstalldirUI) { 1081 commandLine.add("-b"); 1082 commandLine.add(CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 1083 } 1084 1085 commandLine.add("-out"); 1086 commandLine.add(msiOut.getAbsolutePath()); 1087 1088 // create .msi 1089 pb = new ProcessBuilder(commandLine); 1090 1091 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1092 IOUtils.exec(pb); 1093 1094 candleOut.delete(); 1095 IOUtils.deleteRecursive(tmpDir); 1096 1097 return msiOut; 1098 } 1099 1100 public static void ensureByMutationFileIsRTF(File f) { 1101 if (f == null || !f.isFile()) return; 1102 1103 try { 1104 boolean existingLicenseIsRTF = false; 1105 1106 try (FileInputStream fin = new FileInputStream(f)) { 1107 byte[] firstBits = new byte[7]; 1108 1109 if (fin.read(firstBits) == firstBits.length) { 1110 String header = new String(firstBits); 1111 existingLicenseIsRTF = "{\\rtf1\\".equals(header); 1112 } 1113 } 1114 1115 if (!existingLicenseIsRTF) { 1116 List<String> oldLicense = Files.readAllLines(f.toPath()); 1117 try (Writer w = Files.newBufferedWriter( 1118 f.toPath(), Charset.forName("Windows-1252"))) { 1119 w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033" 1120 + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n" 1121 + "\\viewkind4\\uc1\\pard\\sa200\\sl276" 1122 + "\\slmult1\\lang9\\fs20 "); 1123 oldLicense.forEach(l -> { 1124 try { 1125 for (char c : l.toCharArray()) { 1126 // 0x00 <= ch < 0x20 Escaped (\'hh) 1127 // 0x20 <= ch < 0x80 Raw(non - escaped) char 1128 // 0x80 <= ch <= 0xFF Escaped(\ 'hh) 1129 // 0x5C, 0x7B, 0x7D (special RTF characters 1130 // \,{,})Escaped(\'hh) 1131 // ch > 0xff Escaped (\\ud###?) 1132 if (c < 0x10) { 1133 w.write("\\'0"); 1134 w.write(Integer.toHexString(c)); 1135 } else if (c > 0xff) { 1136 w.write("\\ud"); 1137 w.write(Integer.toString(c)); 1138 // \\uc1 is in the header and in effect 1139 // so we trail with a replacement char if 1140 // the font lacks that character - '?' 1141 w.write("?"); 1142 } else if ((c < 0x20) || (c >= 0x80) || 1143 (c == 0x5C) || (c == 0x7B) || 1144 (c == 0x7D)) { 1145 w.write("\\'"); 1146 w.write(Integer.toHexString(c)); 1147 } else { 1148 w.write(c); 1149 } 1150 } 1151 // blank lines are interpreted as paragraph breaks 1152 if (l.length() < 1) { 1153 w.write("\\par"); 1154 } else { 1155 w.write(" "); 1156 } 1157 w.write("\r\n"); 1158 } catch (IOException e) { 1159 Log.verbose(e); 1160 } 1161 }); 1162 w.write("}\r\n"); 1163 } 1164 } 1165 } catch (IOException e) { 1166 Log.verbose(e); 1167 } 1168 1169 } 1170 }