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.incubator.jpackage.internal; 27 28 import java.io.*; 29 import java.nio.charset.Charset; 30 import java.nio.charset.StandardCharsets; 31 import java.nio.file.Files; 32 import java.nio.file.Path; 33 import java.nio.file.Paths; 34 import java.text.MessageFormat; 35 import java.util.*; 36 import java.util.regex.Pattern; 37 import java.util.stream.Collectors; 38 import java.util.stream.Stream; 39 import javax.xml.stream.XMLOutputFactory; 40 import javax.xml.stream.XMLStreamException; 41 import javax.xml.stream.XMLStreamWriter; 42 import static jdk.incubator.jpackage.internal.OverridableResource.createResource; 43 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*; 44 45 import static jdk.incubator.jpackage.internal.WindowsBundlerParam.*; 46 47 /** 48 * WinMsiBundler 49 * 50 * Produces .msi installer from application image. Uses WiX Toolkit to build 51 * .msi installer. 52 * <p> 53 * {@link #execute} method creates a number of source files with the description 54 * of installer to be processed by WiX tools. Generated source files are stored 55 * in "config" subdirectory next to "app" subdirectory in the root work 56 * directory. The following WiX source files are generated: 57 * <ul> 58 * <li>main.wxs. Main source file with the installer description 59 * <li>bundle.wxf. Source file with application and Java run-time directory tree 60 * description. 61 * </ul> 62 * <p> 63 * main.wxs file is a copy of main.wxs resource from 64 * jdk.incubator.jpackage.internal.resources package. It is parametrized with the 65 * following WiX variables: 66 * <ul> 67 * <li>JpAppName. Name of the application. Set to the value of --name command 68 * line option 69 * <li>JpAppVersion. Version of the application. Set to the value of 70 * --app-version command line option 71 * <li>JpAppVendor. Vendor of the application. Set to the value of --vendor 72 * command line option 73 * <li>JpAppDescription. Description of the application. Set to the value of 74 * --description command line option 75 * <li>JpProductCode. Set to product code UUID of the application. Random value 76 * generated by jpackage every time {@link #execute} method is called 77 * <li>JpProductUpgradeCode. Set to upgrade code UUID of the application. Random 78 * value generated by jpackage every time {@link #execute} method is called if 79 * --win-upgrade-uuid command line option is not specified. Otherwise this 80 * variable is set to the value of --win-upgrade-uuid command line option 81 * <li>JpAllowDowngrades. Set to "yes" if --win-upgrade-uuid command line option 82 * was specified. Undefined otherwise 83 * <li>JpLicenseRtf. Set to the value of --license-file command line option. 84 * Undefined is --license-file command line option was not specified 85 * <li>JpInstallDirChooser. Set to "yes" if --win-dir-chooser command line 86 * option was specified. Undefined otherwise 87 * <li>JpConfigDir. Absolute path to the directory with generated WiX source 88 * files. 89 * <li>JpIsSystemWide. Set to "yes" if --win-per-user-install command line 90 * option was not specified. Undefined otherwise 91 * </ul> 92 */ 93 public class WinMsiBundler extends AbstractBundler { 94 95 public static final BundlerParamInfo<WinAppBundler> APP_BUNDLER = 96 new WindowsBundlerParam<>( 97 "win.app.bundler", 98 WinAppBundler.class, 99 params -> new WinAppBundler(), 100 null); 101 102 public static final BundlerParamInfo<File> MSI_IMAGE_DIR = 103 new WindowsBundlerParam<>( 104 "win.msi.imageDir", 105 File.class, 106 params -> { 107 File imagesRoot = IMAGES_ROOT.fetchFrom(params); 108 if (!imagesRoot.exists()) imagesRoot.mkdirs(); 109 return new File(imagesRoot, "win-msi.image"); 110 }, 111 (s, p) -> null); 112 113 public static final BundlerParamInfo<File> WIN_APP_IMAGE = 114 new WindowsBundlerParam<>( 115 "win.app.image", 116 File.class, 117 null, 118 (s, p) -> null); 119 120 public static final StandardBundlerParam<Boolean> MSI_SYSTEM_WIDE = 121 new StandardBundlerParam<>( 122 Arguments.CLIOptions.WIN_PER_USER_INSTALLATION.getId(), 123 Boolean.class, 124 params -> true, // MSIs default to system wide 125 // valueOf(null) is false, 126 // and we actually do want null 127 (s, p) -> (s == null || "null".equalsIgnoreCase(s))? null 128 : Boolean.valueOf(s) 129 ); 130 131 132 public static final StandardBundlerParam<String> PRODUCT_VERSION = 133 new StandardBundlerParam<>( 134 "win.msi.productVersion", 135 String.class, 136 VERSION::fetchFrom, 137 (s, p) -> s 138 ); 139 140 private static final BundlerParamInfo<String> UPGRADE_UUID = 141 new WindowsBundlerParam<>( 142 Arguments.CLIOptions.WIN_UPGRADE_UUID.getId(), 143 String.class, 144 null, 145 (s, p) -> s); 146 147 @Override 148 public String getName() { 149 return I18N.getString("msi.bundler.name"); 150 } 151 152 @Override 153 public String getID() { 154 return "msi"; 155 } 156 157 @Override 158 public String getBundleType() { 159 return "INSTALLER"; 160 } 161 162 @Override 163 public File execute(Map<String, ? super Object> params, 164 File outputParentDir) throws PackagerException { 165 return bundle(params, outputParentDir); 166 } 167 168 @Override 169 public boolean supported(boolean platformInstaller) { 170 try { 171 if (wixToolset == null) { 172 wixToolset = WixTool.toolset(); 173 } 174 return true; 175 } catch (ConfigException ce) { 176 Log.error(ce.getMessage()); 177 if (ce.getAdvice() != null) { 178 Log.error(ce.getAdvice()); 179 } 180 } catch (Exception e) { 181 Log.error(e.getMessage()); 182 } 183 return false; 184 } 185 186 @Override 187 public boolean isDefault() { 188 return false; 189 } 190 191 private static UUID getUpgradeCode(Map<String, ? super Object> params) { 192 String upgradeCode = UPGRADE_UUID.fetchFrom(params); 193 if (upgradeCode != null) { 194 return UUID.fromString(upgradeCode); 195 } 196 return createNameUUID("UpgradeCode", params, List.of(VENDOR, APP_NAME)); 197 } 198 199 private static UUID getProductCode(Map<String, ? super Object> params) { 200 return createNameUUID("ProductCode", params, List.of(VENDOR, APP_NAME, 201 VERSION)); 202 } 203 204 private static UUID createNameUUID(String prefix, 205 Map<String, ? super Object> params, 206 List<StandardBundlerParam<String>> components) { 207 String key = Stream.concat(Stream.of(prefix), components.stream().map( 208 c -> c.fetchFrom(params))).collect(Collectors.joining("/")); 209 return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)); 210 } 211 212 @Override 213 public boolean validate(Map<String, ? super Object> params) 214 throws ConfigException { 215 try { 216 if (wixToolset == null) { 217 wixToolset = WixTool.toolset(); 218 } 219 220 try { 221 getUpgradeCode(params); 222 } catch (IllegalArgumentException ex) { 223 throw new ConfigException(ex); 224 } 225 226 for (var toolInfo: wixToolset.values()) { 227 Log.verbose(MessageFormat.format(I18N.getString( 228 "message.tool-version"), toolInfo.path.getFileName(), 229 toolInfo.version)); 230 } 231 232 wixSourcesBuilder.setWixVersion(wixToolset.get(WixTool.Light).version); 233 234 wixSourcesBuilder.logWixFeatures(); 235 236 /********* validate bundle parameters *************/ 237 238 String version = PRODUCT_VERSION.fetchFrom(params); 239 if (!isVersionStringValid(version)) { 240 throw new ConfigException( 241 MessageFormat.format(I18N.getString( 242 "error.version-string-wrong-format"), version), 243 MessageFormat.format(I18N.getString( 244 "error.version-string-wrong-format.advice"), 245 PRODUCT_VERSION.getID())); 246 } 247 248 // only one mime type per association, at least one file extension 249 List<Map<String, ? super Object>> associations = 250 FILE_ASSOCIATIONS.fetchFrom(params); 251 if (associations != null) { 252 for (int i = 0; i < associations.size(); i++) { 253 Map<String, ? super Object> assoc = associations.get(i); 254 List<String> mimes = FA_CONTENT_TYPE.fetchFrom(assoc); 255 if (mimes.size() > 1) { 256 throw new ConfigException(MessageFormat.format( 257 I18N.getString("error.too-many-content-types-for-file-association"), i), 258 I18N.getString("error.too-many-content-types-for-file-association.advice")); 259 } 260 } 261 } 262 263 return true; 264 } catch (RuntimeException re) { 265 if (re.getCause() instanceof ConfigException) { 266 throw (ConfigException) re.getCause(); 267 } else { 268 throw new ConfigException(re); 269 } 270 } 271 } 272 273 // https://msdn.microsoft.com/en-us/library/aa370859%28v=VS.85%29.aspx 274 // The format of the string is as follows: 275 // major.minor.build 276 // The first field is the major version and has a maximum value of 255. 277 // The second field is the minor version and has a maximum value of 255. 278 // The third field is called the build version or the update version and 279 // has a maximum value of 65,535. 280 static boolean isVersionStringValid(String v) { 281 if (v == null) { 282 return true; 283 } 284 285 String p[] = v.split("\\."); 286 if (p.length > 3) { 287 Log.verbose(I18N.getString( 288 "message.version-string-too-many-components")); 289 return false; 290 } 291 292 try { 293 int val = Integer.parseInt(p[0]); 294 if (val < 0 || val > 255) { 295 Log.verbose(I18N.getString( 296 "error.version-string-major-out-of-range")); 297 return false; 298 } 299 if (p.length > 1) { 300 val = Integer.parseInt(p[1]); 301 if (val < 0 || val > 255) { 302 Log.verbose(I18N.getString( 303 "error.version-string-minor-out-of-range")); 304 return false; 305 } 306 } 307 if (p.length > 2) { 308 val = Integer.parseInt(p[2]); 309 if (val < 0 || val > 65535) { 310 Log.verbose(I18N.getString( 311 "error.version-string-build-out-of-range")); 312 return false; 313 } 314 } 315 } catch (NumberFormatException ne) { 316 Log.verbose(I18N.getString("error.version-string-part-not-number")); 317 Log.verbose(ne); 318 return false; 319 } 320 321 return true; 322 } 323 324 private void prepareProto(Map<String, ? super Object> params) 325 throws PackagerException, IOException { 326 File appImage = StandardBundlerParam.getPredefinedAppImage(params); 327 File appDir = null; 328 329 // we either have an application image or need to build one 330 if (appImage != null) { 331 appDir = new File(MSI_IMAGE_DIR.fetchFrom(params), 332 APP_NAME.fetchFrom(params)); 333 // copy everything from appImage dir into appDir/name 334 IOUtils.copyRecursive(appImage.toPath(), appDir.toPath()); 335 } else { 336 appDir = APP_BUNDLER.fetchFrom(params).doBundle(params, 337 MSI_IMAGE_DIR.fetchFrom(params), true); 338 } 339 340 params.put(WIN_APP_IMAGE.getID(), appDir); 341 342 String licenseFile = LICENSE_FILE.fetchFrom(params); 343 if (licenseFile != null) { 344 // need to copy license file to the working directory 345 // and convert to rtf if needed 346 File lfile = new File(licenseFile); 347 File destFile = new File(CONFIG_ROOT.fetchFrom(params), 348 lfile.getName()); 349 350 IOUtils.copyFile(lfile, destFile); 351 destFile.setWritable(true); 352 ensureByMutationFileIsRTF(destFile); 353 } 354 } 355 356 public File bundle(Map<String, ? super Object> params, File outdir) 357 throws PackagerException { 358 359 IOUtils.writableOutputDir(outdir.toPath()); 360 361 Path imageDir = MSI_IMAGE_DIR.fetchFrom(params).toPath(); 362 try { 363 Files.createDirectories(imageDir); 364 365 prepareProto(params); 366 367 wixSourcesBuilder 368 .initFromParams(WIN_APP_IMAGE.fetchFrom(params).toPath(), params) 369 .createMainFragment(CONFIG_ROOT.fetchFrom(params).toPath().resolve( 370 "bundle.wxf")); 371 372 Map<String, String> wixVars = prepareMainProjectFile(params); 373 374 new ScriptRunner() 375 .setDirectory(imageDir) 376 .setResourceCategoryId("resource.post-app-image-script") 377 .setScriptNameSuffix("post-image") 378 .setEnvironmentVariable("JpAppImageDir", imageDir.toAbsolutePath().toString()) 379 .run(params); 380 381 return buildMSI(params, wixVars, outdir); 382 } catch (IOException ex) { 383 Log.verbose(ex); 384 throw new PackagerException(ex); 385 } 386 } 387 388 Map<String, String> prepareMainProjectFile( 389 Map<String, ? super Object> params) throws IOException { 390 Map<String, String> data = new HashMap<>(); 391 392 final UUID productCode = getProductCode(params); 393 final UUID upgradeCode = getUpgradeCode(params); 394 395 data.put("JpProductCode", productCode.toString()); 396 data.put("JpProductUpgradeCode", upgradeCode.toString()); 397 398 Log.verbose(MessageFormat.format(I18N.getString("message.product-code"), 399 productCode)); 400 Log.verbose(MessageFormat.format(I18N.getString("message.upgrade-code"), 401 upgradeCode)); 402 403 data.put("JpAllowUpgrades", "yes"); 404 405 data.put("JpAppName", APP_NAME.fetchFrom(params)); 406 data.put("JpAppDescription", DESCRIPTION.fetchFrom(params)); 407 data.put("JpAppVendor", VENDOR.fetchFrom(params)); 408 data.put("JpAppVersion", PRODUCT_VERSION.fetchFrom(params)); 409 410 final Path configDir = CONFIG_ROOT.fetchFrom(params).toPath(); 411 412 data.put("JpConfigDir", configDir.toAbsolutePath().toString()); 413 414 if (MSI_SYSTEM_WIDE.fetchFrom(params)) { 415 data.put("JpIsSystemWide", "yes"); 416 } 417 418 String licenseFile = LICENSE_FILE.fetchFrom(params); 419 if (licenseFile != null) { 420 String lname = new File(licenseFile).getName(); 421 File destFile = new File(CONFIG_ROOT.fetchFrom(params), lname); 422 data.put("JpLicenseRtf", destFile.getAbsolutePath()); 423 } 424 425 // Copy CA dll to include with installer 426 if (INSTALLDIR_CHOOSER.fetchFrom(params)) { 427 data.put("JpInstallDirChooser", "yes"); 428 String fname = "wixhelper.dll"; 429 try (InputStream is = OverridableResource.readDefault(fname)) { 430 Files.copy(is, Paths.get( 431 CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), 432 fname)); 433 } 434 } 435 436 // Copy l10n files. 437 for (String loc : Arrays.asList("en", "ja", "zh_CN")) { 438 String fname = "MsiInstallerStrings_" + loc + ".wxl"; 439 try (InputStream is = OverridableResource.readDefault(fname)) { 440 Files.copy(is, Paths.get( 441 CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), 442 fname)); 443 } 444 } 445 446 createResource("main.wxs", params) 447 .setCategory(I18N.getString("resource.main-wix-file")) 448 .saveToFile(configDir.resolve("main.wxs")); 449 450 createResource("overrides.wxi", params) 451 .setCategory(I18N.getString("resource.overrides-wix-file")) 452 .saveToFile(configDir.resolve("overrides.wxi")); 453 454 return data; 455 } 456 457 private File buildMSI(Map<String, ? super Object> params, 458 Map<String, String> wixVars, File outdir) 459 throws IOException { 460 461 File msiOut = new File( 462 outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi"); 463 464 Log.verbose(MessageFormat.format(I18N.getString( 465 "message.preparing-msi-config"), msiOut.getAbsolutePath())); 466 467 WixPipeline wixPipeline = new WixPipeline() 468 .setToolset(wixToolset.entrySet().stream().collect( 469 Collectors.toMap( 470 entry -> entry.getKey(), 471 entry -> entry.getValue().path))) 472 .setWixObjDir(TEMP_ROOT.fetchFrom(params).toPath().resolve("wixobj")) 473 .setWorkDir(WIN_APP_IMAGE.fetchFrom(params).toPath()) 474 .addSource(CONFIG_ROOT.fetchFrom(params).toPath().resolve("main.wxs"), wixVars) 475 .addSource(CONFIG_ROOT.fetchFrom(params).toPath().resolve("bundle.wxf"), null); 476 477 Log.verbose(MessageFormat.format(I18N.getString( 478 "message.generating-msi"), msiOut.getAbsolutePath())); 479 480 boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null); 481 boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params); 482 483 List<String> lightArgs = new ArrayList<>(); 484 485 if (!MSI_SYSTEM_WIDE.fetchFrom(params)) { 486 wixPipeline.addLightOptions("-sice:ICE91"); 487 } 488 if (enableLicenseUI || enableInstalldirUI) { 489 wixPipeline.addLightOptions("-ext", "WixUIExtension"); 490 } 491 492 wixPipeline.addLightOptions("-loc", 493 CONFIG_ROOT.fetchFrom(params).toPath().resolve(I18N.getString( 494 "resource.wxl-file-name")).toAbsolutePath().toString()); 495 496 // Only needed if we using CA dll, so Wix can find it 497 if (enableInstalldirUI) { 498 wixPipeline.addLightOptions("-b", CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); 499 } 500 501 wixPipeline.buildMsi(msiOut.toPath().toAbsolutePath()); 502 503 return msiOut; 504 } 505 506 public static void ensureByMutationFileIsRTF(File f) { 507 if (f == null || !f.isFile()) return; 508 509 try { 510 boolean existingLicenseIsRTF = false; 511 512 try (FileInputStream fin = new FileInputStream(f)) { 513 byte[] firstBits = new byte[7]; 514 515 if (fin.read(firstBits) == firstBits.length) { 516 String header = new String(firstBits); 517 existingLicenseIsRTF = "{\\rtf1\\".equals(header); 518 } 519 } 520 521 if (!existingLicenseIsRTF) { 522 List<String> oldLicense = Files.readAllLines(f.toPath()); 523 try (Writer w = Files.newBufferedWriter( 524 f.toPath(), Charset.forName("Windows-1252"))) { 525 w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033" 526 + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n" 527 + "\\viewkind4\\uc1\\pard\\sa200\\sl276" 528 + "\\slmult1\\lang9\\fs20 "); 529 oldLicense.forEach(l -> { 530 try { 531 for (char c : l.toCharArray()) { 532 // 0x00 <= ch < 0x20 Escaped (\'hh) 533 // 0x20 <= ch < 0x80 Raw(non - escaped) char 534 // 0x80 <= ch <= 0xFF Escaped(\ 'hh) 535 // 0x5C, 0x7B, 0x7D (special RTF characters 536 // \,{,})Escaped(\'hh) 537 // ch > 0xff Escaped (\\ud###?) 538 if (c < 0x10) { 539 w.write("\\'0"); 540 w.write(Integer.toHexString(c)); 541 } else if (c > 0xff) { 542 w.write("\\ud"); 543 w.write(Integer.toString(c)); 544 // \\uc1 is in the header and in effect 545 // so we trail with a replacement char if 546 // the font lacks that character - '?' 547 w.write("?"); 548 } else if ((c < 0x20) || (c >= 0x80) || 549 (c == 0x5C) || (c == 0x7B) || 550 (c == 0x7D)) { 551 w.write("\\'"); 552 w.write(Integer.toHexString(c)); 553 } else { 554 w.write(c); 555 } 556 } 557 // blank lines are interpreted as paragraph breaks 558 if (l.length() < 1) { 559 w.write("\\par"); 560 } else { 561 w.write(" "); 562 } 563 w.write("\r\n"); 564 } catch (IOException e) { 565 Log.verbose(e); 566 } 567 }); 568 w.write("}\r\n"); 569 } 570 } 571 } catch (IOException e) { 572 Log.verbose(e); 573 } 574 575 } 576 577 private Map<WixTool, WixTool.ToolInfo> wixToolset; 578 private WixSourcesBuilder wixSourcesBuilder = new WixSourcesBuilder(); 579 580 }