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