1 /* 2 * Copyright (c) 2012, 2015, 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 com.oracle.tools.packager.windows; 27 28 import com.oracle.tools.packager.*; 29 import com.oracle.tools.packager.ConfigException; 30 import com.oracle.tools.packager.UnsupportedPlatformException; 31 import com.sun.javafx.tools.packager.bundlers.BundleParams; 32 33 import java.io.*; 34 import java.text.MessageFormat; 35 import java.util.*; 36 import java.util.regex.Matcher; 37 import java.util.regex.Pattern; 38 39 import static com.oracle.tools.packager.StandardBundlerParam.DESCRIPTION; 40 import static com.oracle.tools.packager.StandardBundlerParam.RUN_AT_STARTUP; 41 import static com.oracle.tools.packager.StandardBundlerParam.SERVICE_HINT; 42 import static com.oracle.tools.packager.StandardBundlerParam.START_ON_INSTALL; 43 import static com.oracle.tools.packager.StandardBundlerParam.STOP_ON_UNINSTALL; 44 import static com.oracle.tools.packager.windows.WindowsBundlerParam.*; 45 46 public class WinMsiBundler extends AbstractBundler { 47 48 private static final ResourceBundle I18N = 49 ResourceBundle.getBundle(WinMsiBundler.class.getName()); 50 51 public static final BundlerParamInfo<WinAppBundler> APP_BUNDLER = new WindowsBundlerParam<>( 52 I18N.getString("param.app-bundler.name"), 53 I18N.getString("param.app-bundler.description"), 54 "win.app.bundler", 55 WinAppBundler.class, 56 params -> new WinAppBundler(), 57 null); 58 59 public static final BundlerParamInfo<WinServiceBundler> SERVICE_BUNDLER = new WindowsBundlerParam<>( 60 I18N.getString("param.service-bundler.name"), 61 I18N.getString("param.service-bundler.description"), 62 "win.service.bundler", 63 WinServiceBundler.class, 64 params -> new WinServiceBundler(), 65 null); 66 67 public static final BundlerParamInfo<Boolean> CAN_USE_WIX36 = new WindowsBundlerParam<>( 68 I18N.getString("param.can-use-wix36.name"), 69 I18N.getString("param.can-use-wix36.description"), 70 "win.msi.canUseWix36", 71 Boolean.class, 72 params -> false, 73 (s, p) -> Boolean.valueOf(s)); 74 75 public static final BundlerParamInfo<File> CONFIG_ROOT = new WindowsBundlerParam<>( 76 I18N.getString("param.config-root.name"), 77 I18N.getString("param.config-root.description"), 78 "configRoot", 79 File.class, 80 params -> { 81 File imagesRoot = new File(BUILD_ROOT.fetchFrom(params), "windows"); 82 imagesRoot.mkdirs(); 83 return imagesRoot; 84 }, 85 (s, p) -> null); 86 87 public static final BundlerParamInfo<File> MSI_IMAGE_DIR = new WindowsBundlerParam<>( 88 I18N.getString("param.image-dir.name"), 89 I18N.getString("param.image-dir.description"), 90 "win.msi.imageDir", 91 File.class, 92 params -> { 93 File imagesRoot = IMAGES_ROOT.fetchFrom(params); 94 if (!imagesRoot.exists()) imagesRoot.mkdirs(); 95 return new File(imagesRoot, "win-msi.image"); 96 }, 97 (s, p) -> null); 98 99 public static final BundlerParamInfo<File> WIN_APP_IMAGE = new WindowsBundlerParam<>( 100 I18N.getString("param.app-dir.name"), 101 I18N.getString("param.app-dir.description"), 102 "win.app.image", 103 File.class, 104 null, 105 (s, p) -> null); 106 107 public static final StandardBundlerParam<Boolean> MSI_SYSTEM_WIDE = 108 new StandardBundlerParam<>( 109 I18N.getString("param.system-wide.name"), 110 I18N.getString("param.system-wide.description"), 111 "win.msi." + BundleParams.PARAM_SYSTEM_WIDE, 112 Boolean.class, 113 params -> params.containsKey(SYSTEM_WIDE.getID()) 114 ? SYSTEM_WIDE.fetchFrom(params) 115 : true, // MSIs default to system wide 116 (s, p) -> (s == null || "null".equalsIgnoreCase(s))? null : Boolean.valueOf(s) // valueOf(null) is false, and we actually do want null 117 ); 118 119 120 public static final StandardBundlerParam<String> PRODUCT_VERSION = 121 new StandardBundlerParam<>( 122 I18N.getString("param.product-version.name"), 123 I18N.getString("param.product-version.description"), 124 "win.msi.productVersion", 125 String.class, 126 VERSION::fetchFrom, 127 (s, p) -> s 128 ); 129 130 public static final BundlerParamInfo<UUID> UPGRADE_UUID = new WindowsBundlerParam<>( 131 I18N.getString("param.upgrade-uuid.name"), 132 I18N.getString("param.upgrade-uuid.description"), 133 "win.msi.upgradeUUID", 134 UUID.class, 135 params -> UUID.randomUUID(), // TODO check to see if identifier is a valid UUID during default 136 (s, p) -> UUID.fromString(s)); 137 138 private static final String TOOL_CANDLE = "candle.exe"; 139 private static final String TOOL_LIGHT = "light.exe"; 140 // autodetect just v3.7, v3.8, and 3.9 141 private static final String AUTODETECT_DIRS = ";C:\\Program Files (x86)\\WiX Toolset v3.9\\bin;C:\\Program Files\\WiX Toolset v3.9\\bin;C:\\Program Files (x86)\\WiX Toolset v3.8\\bin;C:\\Program Files\\WiX Toolset v3.8\\bin;C:\\Program Files (x86)\\WiX Toolset v3.7\\bin;C:\\Program Files\\WiX Toolset v3.7\\bin"; 142 143 public static final BundlerParamInfo<String> TOOL_CANDLE_EXECUTABLE = new WindowsBundlerParam<>( 144 I18N.getString("param.candle-path.name"), 145 I18N.getString("param.candle-path.description"), 146 "win.msi.candle.exe", 147 String.class, 148 params -> { 149 for (String dirString : (System.getenv("PATH") + AUTODETECT_DIRS).split(";")) { 150 File f = new File(dirString.replace("\"", ""), TOOL_CANDLE); 151 if (f.isFile()) { 152 return f.toString(); 153 } 154 } 155 return null; 156 }, 157 null); 158 159 public static final BundlerParamInfo<String> TOOL_LIGHT_EXECUTABLE = new WindowsBundlerParam<>( 160 I18N.getString("param.light-path.name"), 161 I18N.getString("param.light-path.descrption"), 162 "win.msi.light.exe", 163 String.class, 164 params -> { 165 for (String dirString : (System.getenv("PATH") + AUTODETECT_DIRS).split(";")) { 166 File f = new File(dirString.replace("\"", ""), TOOL_LIGHT); 167 if (f.isFile()) { 168 return f.toString(); 169 } 170 } 171 return null; 172 }, 173 null); 174 175 public WinMsiBundler() { 176 super(); 177 baseResourceLoader = WinResources.class; 178 } 179 180 181 @Override 182 public String getName() { 183 return I18N.getString("bundler.name"); 184 } 185 186 @Override 187 public String getDescription() { 188 return I18N.getString("bundler.description"); 189 } 190 191 @Override 192 public String getID() { 193 return "msi"; 194 } 195 196 @Override 197 public String getBundleType() { 198 return "INSTALLER"; 199 } 200 201 @Override 202 public Collection<BundlerParamInfo<?>> getBundleParameters() { 203 Collection<BundlerParamInfo<?>> results = new LinkedHashSet<>(); 204 results.addAll(WinAppBundler.getAppBundleParameters()); 205 results.addAll(getMsiBundleParameters()); 206 return results; 207 } 208 209 public static Collection<BundlerParamInfo<?>> getMsiBundleParameters() { 210 return Arrays.asList( 211 DESCRIPTION, 212 MENU_GROUP, 213 MENU_HINT, 214 PRODUCT_VERSION, 215 // RUN_AT_STARTUP, 216 SHORTCUT_HINT, 217 // SERVICE_HINT, 218 // START_ON_INSTALL, 219 // STOP_ON_UNINSTALL, 220 SYSTEM_WIDE, 221 //UPGRADE_UUID, 222 VENDOR, 223 LICENSE_FILE, 224 INSTALLDIR_CHOOSER 225 ); 226 } 227 228 @Override 229 public File execute(Map<String, ? super Object> params, File outputParentDir) { 230 return bundle(params, outputParentDir); 231 } 232 233 static class VersionExtractor extends PrintStream { 234 double version = 0f; 235 236 public VersionExtractor() { 237 super(new ByteArrayOutputStream()); 238 } 239 240 double getVersion() { 241 if (version == 0f) { 242 String content = new String(((ByteArrayOutputStream) out).toByteArray()); 243 Pattern pattern = Pattern.compile("version (\\d+.\\d+)"); 244 Matcher matcher = pattern.matcher(content); 245 if (matcher.find()) { 246 String v = matcher.group(1); 247 version = new Double(v); 248 } 249 } 250 return version; 251 } 252 } 253 254 private static double findToolVersion(String toolName) { 255 try { 256 if (toolName == null || "".equals(toolName)) return 0f; 257 258 ProcessBuilder pb = new ProcessBuilder( 259 toolName, 260 "/?"); 261 VersionExtractor ve = new VersionExtractor(); 262 IOUtils.exec(pb, Log.isDebug(), true, ve); //not interested in the output 263 double version = ve.getVersion(); 264 Log.verbose(MessageFormat.format(I18N.getString("message.tool-version"), toolName, version)); 265 return version; 266 } catch (Exception e) { 267 if (Log.isDebug()) { 268 Log.verbose(e); 269 } 270 return 0f; 271 } 272 } 273 274 @Override 275 public boolean validate(Map<String, ? super Object> p) throws UnsupportedPlatformException, ConfigException { 276 try { 277 if (p == null) throw new ConfigException( 278 I18N.getString("error.parameters-null"), 279 I18N.getString("error.parameters-null.advice")); 280 281 //run basic validation to ensure requirements are met 282 //we are not interested in return code, only possible exception 283 APP_BUNDLER.fetchFrom(p).doValidate(p); 284 285 if (SERVICE_HINT.fetchFrom(p)) { 286 SERVICE_BUNDLER.fetchFrom(p).validate(p); 287 } 288 289 double candleVersion = findToolVersion(TOOL_CANDLE_EXECUTABLE.fetchFrom(p)); 290 double lightVersion = findToolVersion(TOOL_LIGHT_EXECUTABLE.fetchFrom(p)); 291 292 //WiX 3.0+ is required 293 double minVersion = 3.0f; 294 boolean bad = false; 295 296 if (candleVersion < minVersion) { 297 Log.verbose(MessageFormat.format(I18N.getString("message.wrong-tool-version"), TOOL_CANDLE, candleVersion, minVersion)); 298 bad = true; 299 } 300 if (lightVersion < minVersion) { 301 Log.verbose(MessageFormat.format(I18N.getString("message.wrong-tool-version"), TOOL_LIGHT, lightVersion, minVersion)); 302 bad = true; 303 } 304 305 if (bad){ 306 throw new ConfigException( 307 I18N.getString("error.no-wix-tools"), 308 I18N.getString("error.no-wix-tools.advice")); 309 } 310 311 if (lightVersion >= 3.6f) { 312 Log.verbose(I18N.getString("message.use-wix36-features")); 313 p.put(CAN_USE_WIX36.getID(), Boolean.TRUE); 314 } 315 316 /********* validate bundle parameters *************/ 317 318 String version = PRODUCT_VERSION.fetchFrom(p); 319 if (!isVersionStringValid(version)) { 320 throw new ConfigException( 321 MessageFormat.format(I18N.getString("error.version-string-wrong-format"), version), 322 MessageFormat.format(I18N.getString("error.version-string-wrong-format.advice"), PRODUCT_VERSION.getID())); 323 } 324 325 // only one mime type per association, at least one file extension 326 List<Map<String, ? super Object>> associations = FILE_ASSOCIATIONS.fetchFrom(p); 327 if (associations != null) { 328 for (int i = 0; i < associations.size(); i++) { 329 Map<String, ? super Object> assoc = associations.get(i); 330 List<String> mimes = FA_CONTENT_TYPE.fetchFrom(assoc); 331 if (mimes.size() > 1) { 332 throw new ConfigException( 333 MessageFormat.format(I18N.getString("error.too-many-content-types-for-file-association"), i), 334 I18N.getString("error.too-many-content-types-for-file-association.advice")); 335 } 336 } 337 } 338 339 // validate license file, if used, exists in the proper place 340 if (p.containsKey(LICENSE_FILE.getID())) { 341 List<RelativeFileSet> appResourcesList = APP_RESOURCES_LIST.fetchFrom(p); 342 for (String license : LICENSE_FILE.fetchFrom(p)) { 343 boolean found = false; 344 for (RelativeFileSet appResources : appResourcesList) { 345 found = found || appResources.contains(license); 346 } 347 if (!found) { 348 throw new ConfigException( 349 I18N.getString("error.license-missing"), 350 MessageFormat.format(I18N.getString("error.license-missing.advice"), 351 license)); 352 } 353 } 354 } 355 356 return true; 357 } catch (RuntimeException re) { 358 if (re.getCause() instanceof ConfigException) { 359 throw (ConfigException) re.getCause(); 360 } else { 361 throw new ConfigException(re); 362 } 363 } 364 } 365 366 //http://msdn.microsoft.com/en-us/library/aa370859%28v=VS.85%29.aspx 367 //The format of the string is as follows: 368 // major.minor.build 369 //The first field is the major version and has a maximum value of 255. 370 //The second field is the minor version and has a maximum value of 255. 371 //The third field is called the build version or the update version and 372 // has a maximum value of 65,535. 373 static boolean isVersionStringValid(String v) { 374 if (v == null) { 375 return true; 376 } 377 378 String p[] = v.split("\\."); 379 if (p.length > 3) { 380 Log.verbose(I18N.getString("message.version-string-too-many-components")); 381 return false; 382 } 383 384 try { 385 int val = Integer.parseInt(p[0]); 386 if (val < 0 || val > 255) { 387 Log.verbose(I18N.getString("error.version-string-major-out-of-range")); 388 return false; 389 } 390 if (p.length > 1) { 391 val = Integer.parseInt(p[1]); 392 if (val < 0 || val > 255) { 393 Log.verbose(I18N.getString("error.version-string-minor-out-of-range")); 394 return false; 395 } 396 } 397 if (p.length > 2) { 398 val = Integer.parseInt(p[2]); 399 if (val < 0 || val > 65535) { 400 Log.verbose(I18N.getString("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> p) throws IOException { 414 File bundleRoot = MSI_IMAGE_DIR.fetchFrom(p); 415 File appDir = APP_BUNDLER.fetchFrom(p).doBundle(p, bundleRoot, true); 416 p.put(WIN_APP_IMAGE.getID(), appDir); 417 418 if (SERVICE_HINT.fetchFrom(p) && appDir != null) { 419 // just copies the service launcher to the app root folder 420 appDir = SERVICE_BUNDLER.fetchFrom(p).doBundle(p, appDir, true); 421 } 422 423 List<String> licenseFiles = LICENSE_FILE.fetchFrom(p); 424 if (licenseFiles != null) { 425 //need to copy license file to the root of win.app.image 426 outerLoop: 427 for (RelativeFileSet rfs : APP_RESOURCES_LIST.fetchFrom(p)) { 428 for (String s : licenseFiles) { 429 if (rfs.contains(s)) { 430 File lfile = new File(rfs.getBaseDirectory(), s); 431 IOUtils.copyFile(lfile, new File(appDir, lfile.getName())); 432 break outerLoop; 433 } 434 } 435 } 436 } 437 438 // copy file association icons 439 List<Map<String, ? super Object>> fileAssociations = FILE_ASSOCIATIONS.fetchFrom(p); 440 for (Map<String, ? super Object> fileAssociation : fileAssociations) { 441 File icon = FA_ICON.fetchFrom(fileAssociation); //TODO FA_ICON_ICO 442 if (icon == null) { 443 continue; 444 } 445 446 File faIconFile = new File(appDir, icon.getName()); 447 448 if (icon.exists()) { 449 try { 450 IOUtils.copyFile(icon, faIconFile); 451 } catch (IOException e) { 452 e.printStackTrace(); 453 } 454 } 455 } 456 457 return appDir != null; 458 } 459 460 public File bundle(Map<String, ? super Object> p, File outdir) { 461 if (!outdir.isDirectory() && !outdir.mkdirs()) { 462 throw new RuntimeException(MessageFormat.format(I18N.getString("error.cannot-create-output-dir"), outdir.getAbsolutePath())); 463 } 464 if (!outdir.canWrite()) { 465 throw new RuntimeException(MessageFormat.format(I18N.getString("error.cannot-write-to-output-dir"), outdir.getAbsolutePath())); 466 } 467 468 // validate we have valid tools before continuing 469 String light = TOOL_LIGHT_EXECUTABLE.fetchFrom(p); 470 String candle = TOOL_CANDLE_EXECUTABLE.fetchFrom(p); 471 if (light == null || !new File(light).isFile() || 472 candle == null || !new File(candle).isFile()) { 473 Log.info(I18N.getString("error.no-wix-tools")); 474 Log.info(MessageFormat.format(I18N.getString("message.light-file-string"), light)); 475 Log.info(MessageFormat.format(I18N.getString("message.candle-file-string"), candle)); 476 return null; 477 } 478 479 File imageDir = MSI_IMAGE_DIR.fetchFrom(p); 480 try { 481 imageDir.mkdirs(); 482 483 boolean menuShortcut = MENU_HINT.fetchFrom(p); 484 boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(p); 485 if (!menuShortcut && !desktopShortcut) { 486 //both can not be false - user will not find the app 487 Log.verbose(I18N.getString("message.one-shortcut-required")); 488 p.put(MENU_HINT.getID(), true); 489 } 490 491 if (prepareProto(p) && prepareWiXConfig(p) 492 && prepareBasicProjectConfig(p)) { 493 File configScriptSrc = getConfig_Script(p); 494 if (configScriptSrc.exists()) { 495 //we need to be running post script in the image folder 496 497 // NOTE: Would it be better to generate it to the image folder 498 // and save only if "verbose" is requested? 499 500 // for now we replicate it 501 File configScript = new File(imageDir, configScriptSrc.getName()); 502 IOUtils.copyFile(configScriptSrc, configScript); 503 Log.info(MessageFormat.format(I18N.getString("message.running-wsh-script"), configScript.getAbsolutePath())); 504 IOUtils.run("wscript", configScript, VERBOSE.fetchFrom(p)); 505 } 506 return buildMSI(p, outdir); 507 } 508 return null; 509 } catch (IOException ex) { 510 Log.verbose(ex); 511 return null; 512 } finally { 513 try { 514 if (imageDir != null && !Log.isDebug()) { 515 IOUtils.deleteRecursive(imageDir); 516 } else if (imageDir != null) { 517 Log.info(MessageFormat.format(I18N.getString("message.debug-working-directory"), imageDir.getAbsolutePath())); 518 } 519 if (VERBOSE.fetchFrom(p)) { 520 Log.info(MessageFormat.format(I18N.getString("message.config-save-location"), CONFIG_ROOT.fetchFrom(p).getAbsolutePath())); 521 } else { 522 cleanupConfigFiles(p); 523 } 524 } catch (FileNotFoundException ex) { 525 //noinspection ReturnInsideFinallyBlock 526 return null; 527 } 528 } 529 } 530 531 protected void cleanupConfigFiles(Map<String, ? super Object> params) { 532 if (getConfig_ProjectFile(params) != null) { 533 getConfig_ProjectFile(params).delete(); 534 } 535 if (getConfig_Script(params) != null) { 536 getConfig_Script(params).delete(); 537 } 538 } 539 540 //name of post-image script 541 private File getConfig_Script(Map<String, ? super Object> params) { 542 return new File(CONFIG_ROOT.fetchFrom(params), 543 APP_FS_NAME.fetchFrom(params) + "-post-image.wsf"); 544 } 545 546 private boolean prepareBasicProjectConfig(Map<String, ? super Object> params) throws IOException { 547 fetchResource(WinAppBundler.WIN_BUNDLER_PREFIX + getConfig_Script(params).getName(), 548 I18N.getString("resource.post-install-script"), 549 (String) null, 550 getConfig_Script(params), 551 VERBOSE.fetchFrom(params), 552 DROP_IN_RESOURCES_ROOT.fetchFrom(params)); 553 return true; 554 } 555 556 private String relativePath(File basedir, File file) { 557 return file.getAbsolutePath().substring( 558 basedir.getAbsolutePath().length() + 1); 559 } 560 561 boolean prepareMainProjectFile(Map<String, ? super Object> params) throws IOException { 562 Map<String, String> data = new HashMap<>(); 563 564 UUID productGUID = UUID.randomUUID(); 565 566 Log.verbose(MessageFormat.format(I18N.getString("message.generated-product-guid"), productGUID.toString())); 567 568 //we use random GUID for product itself but 569 // user provided for upgrade guid 570 // Upgrade guid is important to decide whether it is upgrade of installed 571 // app. I.e. we need it to be the same for 2 different versions of app if possible 572 data.put("PRODUCT_GUID", productGUID.toString()); 573 data.put("PRODUCT_UPGRADE_GUID", UPGRADE_UUID.fetchFrom(params).toString()); 574 575 data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params)); 576 data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params)); 577 data.put("APPLICATION_VENDOR", VENDOR.fetchFrom(params)); 578 data.put("APPLICATION_VERSION", PRODUCT_VERSION.fetchFrom(params)); 579 580 //WinAppBundler will add application folder again => step out 581 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 582 File launcher = new File(imageRootDir, WinAppBundler.getLauncherName(params)); 583 584 String launcherPath = relativePath(imageRootDir, launcher); 585 data.put("APPLICATION_LAUNCHER", launcherPath); 586 587 String iconPath = launcherPath.replace(".exe", ".ico"); 588 589 data.put("APPLICATION_ICON", iconPath); 590 591 data.put("REGISTRY_ROOT", getRegistryRoot(params)); 592 593 boolean canUseWix36Features = CAN_USE_WIX36.fetchFrom(params); 594 data.put("WIX36_ONLY_START", 595 canUseWix36Features ? "" : "<!--"); 596 data.put("WIX36_ONLY_END", 597 canUseWix36Features ? "" : "-->"); 598 599 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 600 data.put("INSTALL_SCOPE", "perMachine"); 601 } else { 602 data.put("INSTALL_SCOPE", "perUser"); 603 } 604 605 if (BIT_ARCH_64.fetchFrom(params)) { 606 data.put("PLATFORM", "x64"); 607 data.put("WIN64", "yes"); 608 } else { 609 data.put("PLATFORM", "x86"); 610 data.put("WIN64", "no"); 611 } 612 613 data.put("UI_BLOCK", getUIBlock(params)); 614 615 List<Map<String, ? super Object>> secondaryLaunchers = SECONDARY_LAUNCHERS.fetchFrom(params); 616 617 StringBuilder secondaryLauncherIcons = new StringBuilder(); 618 for (int i = 0; i < secondaryLaunchers.size(); i++) { 619 Map<String, ? super Object> sl = secondaryLaunchers.get(i); 620 // <Icon Id="DesktopIcon.exe" SourceFile="APPLICATION_ICON" /> 621 if (SHORTCUT_HINT.fetchFrom(sl) || MENU_HINT.fetchFrom(sl)) { 622 File secondaryLauncher = new File(imageRootDir, WinAppBundler.getLauncherName(sl)); 623 String secondaryLauncherPath = relativePath(imageRootDir, secondaryLauncher); 624 String secondaryLauncherIconPath = secondaryLauncherPath.replace(".exe", ".ico"); 625 626 secondaryLauncherIcons.append(" <Icon Id=\"Launcher"); 627 secondaryLauncherIcons.append(i); 628 secondaryLauncherIcons.append(".exe\" SourceFile=\""); 629 secondaryLauncherIcons.append(secondaryLauncherIconPath); 630 secondaryLauncherIcons.append("\" />\r\n"); 631 } 632 } 633 data.put("SECONDARY_LAUNCHER_ICONS", secondaryLauncherIcons.toString()); 634 635 Writer w = new BufferedWriter(new FileWriter(getConfig_ProjectFile(params))); 636 w.write(preprocessTextResource( 637 WinAppBundler.WIN_BUNDLER_PREFIX + getConfig_ProjectFile(params).getName(), 638 I18N.getString("resource.wix-config-file"), 639 MSI_PROJECT_TEMPLATE, data, VERBOSE.fetchFrom(params), 640 DROP_IN_RESOURCES_ROOT.fetchFrom(params))); 641 w.close(); 642 return true; 643 } 644 private int id; 645 private int compId; 646 private final static String LAUNCHER_ID = "LauncherId"; 647 private final static String LAUNCHER_SVC_ID = "LauncherSvcId"; 648 649 /** 650 * Overrides the dialog sequence in built-in dialog set "WixUI_InstallDir" 651 * to exclude license dialog 652 */ 653 private static final String TWEAK_FOR_EXCLUDING_LICENSE = 654 " <Publish Dialog=\"WelcomeDlg\" Control=\"Next\"" + 655 " Event=\"NewDialog\" Value=\"InstallDirDlg\" Order=\"2\"> 1" + 656 " </Publish>\n" + 657 " <Publish Dialog=\"InstallDirDlg\" Control=\"Back\"" + 658 " Event=\"NewDialog\" Value=\"WelcomeDlg\" Order=\"2\"> 1" + 659 " </Publish>\n"; 660 661 /** 662 * Creates UI element using WiX built-in dialog sets - WixUI_InstallDir/WixUI_Minimal. 663 * The dialog sets are the closest to what we want to implement. 664 * 665 * WixUI_Minimal for license dialog only 666 * WixUI_InstallDir for installdir dialog only or for both installdir/license dialogs 667 */ 668 private String getUIBlock(Map<String, ? super Object> params) { 669 String uiBlock = " <UI/>\n"; // UI-less element 670 671 if (INSTALLDIR_CHOOSER.fetchFrom(params)) { 672 boolean enableTweakForExcludingLicense = (getLicenseFile(params) == null); 673 uiBlock = 674 " <UI>\n" + 675 " <Property Id=\"WIXUI_INSTALLDIR\" Value=\"APPLICATIONFOLDER\" />\n" + 676 " <UIRef Id=\"WixUI_InstallDir\" />\n" + 677 (enableTweakForExcludingLicense ? TWEAK_FOR_EXCLUDING_LICENSE : "") + 678 " </UI>\n"; 679 } else if (getLicenseFile(params) != null) { 680 uiBlock = 681 " <UI>\n" + 682 " <UIRef Id=\"WixUI_Minimal\" />\n" + 683 " </UI>\n"; 684 } 685 686 return uiBlock; 687 } 688 689 private void walkFileTree(Map<String, ? super Object> params, File root, PrintStream out, String prefix) { 690 List<File> dirs = new ArrayList<>(); 691 List<File> files = new ArrayList<>(); 692 693 if (!root.isDirectory()) { 694 throw new RuntimeException( 695 MessageFormat.format(I18N.getString("error.cannot-walk-directory"), root.getAbsolutePath())); 696 } 697 698 //sort to files and dirs 699 File[] children = root.listFiles(); 700 if (children != null) { 701 for (File f : children) { 702 if (f.isDirectory()) { 703 dirs.add(f); 704 } else { 705 files.add(f); 706 } 707 } 708 } 709 710 //have files => need to output component 711 out.println(prefix + " <Component Id=\"comp" + (compId++) + "\" DiskId=\"1\"" 712 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 713 + (BIT_ARCH_64.fetchFrom(params) ? " Win64=\"yes\"" : "") + ">"); 714 out.println(prefix + " <CreateFolder/>"); 715 out.println(prefix + " <RemoveFolder Id=\"RemoveDir" + (id++) + "\" On=\"uninstall\" />"); 716 717 boolean needRegistryKey = !MSI_SYSTEM_WIDE.fetchFrom(params); 718 File imageRootDir = WIN_APP_IMAGE.fetchFrom(params); 719 File launcherFile = new File(imageRootDir, WinAppBundler.getLauncherName(params)); 720 File launcherSvcFile = WinServiceBundler.getLauncherSvc( 721 imageRootDir, params); 722 723 //Find out if we need to use registry. We need it if 724 // - we doing user level install as file can not serve as KeyPath 725 // - if we adding shortcut in this component 726 727 for (File f: files) { 728 boolean isLauncher = f.equals(launcherFile); 729 if (isLauncher) { 730 needRegistryKey = true; 731 } 732 } 733 734 if (needRegistryKey) { 735 //has to be under HKCU to make WiX happy 736 out.println(prefix + " <RegistryKey Root=\"HKCU\" " 737 + " Key=\"Software\\" + VENDOR.fetchFrom(params) + "\\" 738 + APP_NAME.fetchFrom(params) + "\"" 739 + (CAN_USE_WIX36.fetchFrom(params) 740 ? ">" : " Action=\"createAndRemoveOnUninstall\">")); 741 out.println(prefix + " <RegistryValue Name=\"Version\" Value=\"" 742 + VERSION.fetchFrom(params) + "\" Type=\"string\" KeyPath=\"yes\"/>"); 743 out.println(prefix + " </RegistryKey>"); 744 } 745 746 boolean menuShortcut = MENU_HINT.fetchFrom(params); 747 boolean desktopShortcut = SHORTCUT_HINT.fetchFrom(params); 748 749 Map<String, String> idToFileMap = new TreeMap<>(); 750 boolean launcherSet = false; 751 752 for (File f : files) { 753 boolean isLauncher = f.equals(launcherFile); 754 boolean isLauncherSvc = f.equals(launcherSvcFile); 755 756 // skip executable for service, will be covered by new component entry 757 if (isLauncherSvc) { 758 continue; 759 } 760 launcherSet = launcherSet || isLauncher; 761 762 boolean doShortcuts = isLauncher && (menuShortcut || desktopShortcut); 763 764 String thisFileId = isLauncher ? LAUNCHER_ID : ("FileId" + (id++)); 765 idToFileMap.put(f.getName(), thisFileId); 766 767 out.println(prefix + " <File Id=\"" + 768 thisFileId + "\"" 769 + " Name=\"" + f.getName() + "\" " 770 + " Source=\"" + relativePath(imageRootDir, f) + "\"" 771 + (BIT_ARCH_64.fetchFrom(params) ? " ProcessorArchitecture=\"x64\"" : "") + ">"); 772 if (doShortcuts && desktopShortcut) { 773 out.println(prefix + " <Shortcut Id=\"desktopShortcut\" Directory=\"DesktopFolder\"" 774 + " Name=\"" + APP_NAME.fetchFrom(params) + "\" WorkingDirectory=\"INSTALLDIR\"" 775 + " Advertise=\"no\" Icon=\"DesktopIcon.exe\" IconIndex=\"0\" />"); 776 } 777 if (doShortcuts && menuShortcut) { 778 out.println(prefix + " <Shortcut Id=\"ExeShortcut\" Directory=\"ProgramMenuDir\"" 779 + " Name=\"" + APP_NAME.fetchFrom(params) 780 + "\" Advertise=\"no\" Icon=\"StartMenuIcon.exe\" IconIndex=\"0\" />"); 781 } 782 783 List<Map<String, ? super Object>> secondaryLaunchers = SECONDARY_LAUNCHERS.fetchFrom(params); 784 for (int i = 0; i < secondaryLaunchers.size(); i++) { 785 Map<String, ? super Object> sl = secondaryLaunchers.get(i); 786 File secondaryLauncherFile = new File(imageRootDir, WinAppBundler.getLauncherName(sl)); 787 if (f.equals(secondaryLauncherFile)) { 788 if (SHORTCUT_HINT.fetchFrom(sl)) { 789 out.println(prefix + " <Shortcut Id=\"desktopShortcut" + i + "\" Directory=\"DesktopFolder\"" 790 + " Name=\"" + APP_NAME.fetchFrom(sl) + "\" WorkingDirectory=\"INSTALLDIR\"" 791 + " Advertise=\"no\" Icon=\"Launcher" + i + ".exe\" IconIndex=\"0\" />"); 792 } 793 if (MENU_HINT.fetchFrom(sl)) { 794 out.println(prefix + " <Shortcut Id=\"ExeShortcut" + i + "\" Directory=\"ProgramMenuDir\"" 795 + " Name=\"" + APP_NAME.fetchFrom(sl) 796 + "\" Advertise=\"no\" Icon=\"Launcher" + i + ".exe\" IconIndex=\"0\" />"); 797 //Should we allow different menu groups? Not for now. 798 } 799 } 800 } 801 out.println(prefix + " </File>"); 802 } 803 804 if (launcherSet) { 805 List<Map<String, ? super Object>> fileAssociations = FILE_ASSOCIATIONS.fetchFrom(params); 806 String regName = APP_REGISTRY_NAME.fetchFrom(params); 807 Set<String> defaultedMimes = new TreeSet<String>(); 808 int count = 0; 809 for (int i = 0; i < fileAssociations.size(); i++) { 810 Map<String, ? super Object> fileAssociation = fileAssociations.get(i); 811 String description = FA_DESCRIPTION.fetchFrom(fileAssociation); 812 List<String> extensions = FA_EXTENSIONS.fetchFrom(fileAssociation); 813 List<String> mimeTypes = FA_CONTENT_TYPE.fetchFrom(fileAssociation); 814 File icon = FA_ICON.fetchFrom(fileAssociation); //TODO FA_ICON_ICO 815 816 String mime = (mimeTypes == null || mimeTypes.isEmpty()) ? null : mimeTypes.get(0); 817 818 if (extensions == null) { 819 Log.info(I18N.getString("message.creating-association-with-null-extension")); 820 821 String entryName = regName + "File"; 822 if (count > 0) { 823 entryName += "." + count; 824 } 825 count++; 826 out.print(prefix + " <ProgId Id='" + entryName + "' Description='" + description + "'"); 827 if (icon != null && icon.exists()) { 828 out.print(" Icon='" + idToFileMap.get(icon.getName()) + "' IconIndex='0'"); 829 } 830 out.println(" />"); 831 } else { 832 for (String ext : extensions) { 833 String entryName = regName + "File"; 834 if (count > 0) { 835 entryName += "." + count; 836 } 837 count++; 838 839 out.print(prefix + " <ProgId Id='" + entryName + "' Description='" + description + "'"); 840 if (icon != null && icon.exists()) { 841 out.print(" Icon='" + idToFileMap.get(icon.getName()) + "' IconIndex='0'"); 842 } 843 out.println(">"); 844 845 if (extensions == null) { 846 Log.info(I18N.getString("message.creating-association-with-null-extension")); 847 } else { 848 out.print(prefix + " <Extension Id='" + ext + "' Advertise='no'"); 849 if (mime == null) { 850 out.println(">"); 851 } else { 852 out.println(" ContentType='" + mime + "'>"); 853 if (!defaultedMimes.contains(mime)) { 854 out.println(prefix + " <MIME ContentType='" + mime + "' Default='yes' />"); 855 defaultedMimes.add(mime); 856 } 857 } 858 out.println(prefix + " <Verb Id='open' Command='Open' TargetFile='" + LAUNCHER_ID + "' Argument='\"%1\"' />"); 859 out.println(prefix + " </Extension>"); 860 } 861 out.println(prefix + " </ProgId>"); 862 } 863 } 864 } 865 } 866 867 out.println(prefix + " </Component>"); 868 869 // Two components cannot share the same key path value. 870 // We already have HKCU created with key path set and 871 // we need to create separate component for ServiceInstall element 872 // to ensure that key path is also set to the service executable. 873 // 874 // http://wixtoolset.org/documentation/manual/v3/xsd/wix/serviceinstall.html 875 876 boolean needServiceEntries = false; 877 for (File f: files) { 878 boolean isLauncherSvc = f.equals(launcherSvcFile); 879 if (isLauncherSvc && SERVICE_HINT.fetchFrom(params)) { 880 needServiceEntries = true; 881 } 882 } 883 884 if (needServiceEntries) { 885 out.println(prefix + " <Component Id=\"comp" + (compId++) + "\" DiskId=\"1\"" 886 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 887 + (BIT_ARCH_64.fetchFrom(params) ? " Win64=\"yes\"" : "") + ">"); 888 out.println(" <CreateFolder/>"); 889 out.println(" <RemoveFolder Id=\"RemoveDir" + (id++) + "\" On=\"uninstall\" />"); 890 891 out.println(prefix + " <File Id=\"" + LAUNCHER_SVC_ID + "\"" 892 + " Name=\"" + launcherSvcFile.getName() + "\" " 893 + " Source=\"" + relativePath(imageRootDir, launcherSvcFile) + "\"" 894 + (BIT_ARCH_64.fetchFrom(params) ? " ProcessorArchitecture=\"x64\"" : "") 895 + " KeyPath=\"yes\">"); 896 out.println(prefix + " </File>"); 897 out.println(prefix + " <ServiceInstall Id=\"" + APP_FS_NAME.fetchFrom(params) + "\"" 898 + " Name=\"" + APP_NAME.fetchFrom(params) + "\"" 899 + " Description=\"" + DESCRIPTION.fetchFrom(params) + "\"" 900 + " ErrorControl=\"normal\"" 901 + " Start=\"" + (RUN_AT_STARTUP.fetchFrom(params) ? "auto" : "demand") + "\"" 902 + " Type=\"ownProcess\" Vital=\"yes\" Account=\"LocalSystem\"" 903 + " Arguments='-mainExe \"" + launcherFile.getName() + "\"'/>"); 904 905 out.println(prefix + " <ServiceControl Id=\""+ APP_FS_NAME.fetchFrom(params) + "\"" 906 + " Name=\"" + APP_NAME.fetchFrom(params) + "\"" 907 + (START_ON_INSTALL.fetchFrom(params) ? " Start=\"install\"" : "") 908 + (STOP_ON_UNINSTALL.fetchFrom(params) ? " Stop=\"uninstall\"" : "") 909 + " Remove=\"uninstall\"" 910 + " Wait=\"yes\" />"); 911 912 out.println(prefix + " </Component>"); 913 } 914 915 for (File d : dirs) { 916 out.println(prefix + " <Directory Id=\"dirid" + (id++) 917 + "\" Name=\"" + d.getName() + "\">"); 918 walkFileTree(params, d, out, prefix + " "); 919 out.println(prefix + " </Directory>"); 920 } 921 } 922 923 String getRegistryRoot(Map<String, ? super Object> params) { 924 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 925 return "HKLM"; 926 } else { 927 return "HKCU"; 928 } 929 } 930 931 boolean prepareContentList(Map<String, ? super Object> params) throws FileNotFoundException { 932 File f = new File(CONFIG_ROOT.fetchFrom(params), MSI_PROJECT_CONTENT_FILE); 933 PrintStream out = new PrintStream(f); 934 935 //opening 936 out.println("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"); 937 out.println("<Include>"); 938 939 out.println(" <Directory Id=\"TARGETDIR\" Name=\"SourceDir\">"); 940 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 941 //install to programfiles 942 if (BIT_ARCH_64.fetchFrom(params)) { 943 out.println(" <Directory Id=\"ProgramFiles64Folder\" Name=\"PFiles\">"); 944 } else { 945 out.println(" <Directory Id=\"ProgramFilesFolder\" Name=\"PFiles\">"); 946 } 947 } else { 948 //install to user folder 949 out.println(" <Directory Name=\"AppData\" Id=\"LocalAppDataFolder\">"); 950 } 951 out.println(" <Directory Id=\"APPLICATIONFOLDER\" Name=\"" 952 + APP_NAME.fetchFrom(params) + "\">"); 953 954 //dynamic part 955 id = 0; 956 compId = 0; //reset counters 957 walkFileTree(params, WIN_APP_IMAGE.fetchFrom(params), out, " "); 958 959 //closing 960 out.println(" </Directory>"); 961 out.println(" </Directory>"); 962 963 //for shortcuts 964 if (SHORTCUT_HINT.fetchFrom(params)) { 965 out.println(" <Directory Id=\"DesktopFolder\" />"); 966 } 967 if (MENU_HINT.fetchFrom(params)) { 968 out.println(" <Directory Id=\"ProgramMenuFolder\">"); 969 out.println(" <Directory Id=\"ProgramMenuDir\" Name=\"" + MENU_GROUP.fetchFrom(params) + "\">"); 970 out.println(" <Component Id=\"comp" + (compId++) + "\"" 971 + " Guid=\"" + UUID.randomUUID().toString() + "\"" 972 + (BIT_ARCH_64.fetchFrom(params) ? " Win64=\"yes\"" : "") + ">"); 973 out.println(" <RemoveFolder Id=\"ProgramMenuDir\" On=\"uninstall\" />"); 974 //This has to be under HKCU to make WiX happy. 975 //There are numberous discussions on this amoung WiX users 976 // (if user A installs and user B uninstalls then key is left behind) 977 //and there are suggested workarounds but none of them are appealing. 978 //Leave it for now 979 out.println(" <RegistryValue Root=\"HKCU\" Key=\"Software\\" 980 + VENDOR.fetchFrom(params) + "\\" + APP_NAME.fetchFrom(params) 981 + "\" Type=\"string\" Value=\"\" />"); 982 out.println(" </Component>"); 983 out.println(" </Directory>"); 984 out.println(" </Directory>"); 985 } 986 987 out.println(" </Directory>"); 988 989 out.println(" <Feature Id=\"DefaultFeature\" Title=\"Main Feature\" Level=\"1\">"); 990 for (int j = 0; j < compId; j++) { 991 out.println(" <ComponentRef Id=\"comp" + j + "\" />"); 992 } 993 //component is defined in the template.wsx 994 out.println(" <ComponentRef Id=\"CleanupMainApplicationFolder\" />"); 995 out.println(" </Feature>"); 996 out.println("</Include>"); 997 998 out.close(); 999 return true; 1000 } 1001 1002 private File getConfig_ProjectFile(Map<String, ? super Object> params) { 1003 return new File(CONFIG_ROOT.fetchFrom(params), APP_NAME.fetchFrom(params) + ".wxs"); 1004 } 1005 1006 private String getLicenseFile(Map<String, ? super Object> params) { 1007 List<String> licenseFiles = LICENSE_FILE.fetchFrom(params); 1008 if (licenseFiles == null || licenseFiles.isEmpty()) { 1009 return null; 1010 } else { 1011 return licenseFiles.get(0); 1012 } 1013 } 1014 1015 private boolean prepareWiXConfig(Map<String, ? super Object> params) throws IOException { 1016 return prepareMainProjectFile(params) && prepareContentList(params); 1017 1018 } 1019 private final static String MSI_PROJECT_TEMPLATE = "template.wxs"; 1020 private final static String MSI_PROJECT_CONTENT_FILE = "bundle.wxi"; 1021 1022 private File buildMSI(Map<String, ? super Object> params, File outdir) throws IOException { 1023 File tmpDir = new File(BUILD_ROOT.fetchFrom(params), "tmp"); 1024 File candleOut = new File(tmpDir, APP_NAME.fetchFrom(params) +".wixobj"); 1025 File msiOut = new File(outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi"); 1026 1027 Log.verbose(MessageFormat.format(I18N.getString("message.preparing-msi-config"), msiOut.getAbsolutePath())); 1028 1029 msiOut.getParentFile().mkdirs(); 1030 1031 //run candle 1032 ProcessBuilder pb = new ProcessBuilder( 1033 TOOL_CANDLE_EXECUTABLE.fetchFrom(params), 1034 "-nologo", 1035 getConfig_ProjectFile(params).getAbsolutePath(), 1036 "-ext", "WixUtilExtension", 1037 "-out", candleOut.getAbsolutePath()); 1038 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1039 IOUtils.exec(pb, VERBOSE.fetchFrom(params)); 1040 1041 Log.verbose(MessageFormat.format(I18N.getString("message.generating-msi"), msiOut.getAbsolutePath())); 1042 1043 boolean enableLicenseUI = (getLicenseFile(params) != null); 1044 boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params); 1045 1046 List<String> commandLine = new ArrayList<String>(); 1047 1048 commandLine.add(TOOL_LIGHT_EXECUTABLE.fetchFrom(params)); 1049 if (enableLicenseUI) { 1050 commandLine.add("-dWixUILicenseRtf="+getLicenseFile(params)); 1051 } 1052 commandLine.add("-nologo"); 1053 commandLine.add("-spdb"); 1054 commandLine.add("-sice:60"); //ignore warnings due to "missing launcguage info" (ICE60) 1055 commandLine.add(candleOut.getAbsolutePath()); 1056 commandLine.add("-ext"); 1057 commandLine.add("WixUtilExtension"); 1058 if (enableLicenseUI || enableInstalldirUI) { 1059 commandLine.add("-ext"); 1060 commandLine.add("WixUIExtension.dll"); 1061 } 1062 commandLine.add("-out"); 1063 commandLine.add(msiOut.getAbsolutePath()); 1064 1065 //create .msi 1066 pb = new ProcessBuilder(commandLine); 1067 1068 pb = pb.directory(WIN_APP_IMAGE.fetchFrom(params)); 1069 IOUtils.exec(pb, VERBOSE.fetchFrom(params)); 1070 1071 candleOut.delete(); 1072 IOUtils.deleteRecursive(tmpDir); 1073 1074 return msiOut; 1075 } 1076 }