/* * Copyright (c) 2012, 2019, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ package jdk.incubator.jpackage.internal; import java.io.*; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.MessageFormat; import java.util.*; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; import static jdk.incubator.jpackage.internal.OverridableResource.createResource; import static jdk.incubator.jpackage.internal.StandardBundlerParam.*; import static jdk.incubator.jpackage.internal.WindowsBundlerParam.*; /** * WinMsiBundler * * Produces .msi installer from application image. Uses WiX Toolkit to build * .msi installer. *

* {@link #execute} method creates a number of source files with the description * of installer to be processed by WiX tools. Generated source files are stored * in "config" subdirectory next to "app" subdirectory in the root work * directory. The following WiX source files are generated: *

*

* main.wxs file is a copy of main.wxs resource from * jdk.incubator.jpackage.internal.resources package. It is parametrized with the * following WiX variables: *

*/ public class WinMsiBundler extends AbstractBundler { public static final BundlerParamInfo APP_BUNDLER = new WindowsBundlerParam<>( "win.app.bundler", WinAppBundler.class, params -> new WinAppBundler(), null); public static final BundlerParamInfo MSI_IMAGE_DIR = new WindowsBundlerParam<>( "win.msi.imageDir", File.class, params -> { File imagesRoot = IMAGES_ROOT.fetchFrom(params); if (!imagesRoot.exists()) imagesRoot.mkdirs(); return new File(imagesRoot, "win-msi.image"); }, (s, p) -> null); public static final BundlerParamInfo WIN_APP_IMAGE = new WindowsBundlerParam<>( "win.app.image", File.class, null, (s, p) -> null); public static final StandardBundlerParam MSI_SYSTEM_WIDE = new StandardBundlerParam<>( Arguments.CLIOptions.WIN_PER_USER_INSTALLATION.getId(), Boolean.class, params -> true, // MSIs default to system wide // valueOf(null) is false, // and we actually do want null (s, p) -> (s == null || "null".equalsIgnoreCase(s))? null : Boolean.valueOf(s) ); public static final StandardBundlerParam PRODUCT_VERSION = new StandardBundlerParam<>( "win.msi.productVersion", String.class, VERSION::fetchFrom, (s, p) -> s ); private static final BundlerParamInfo UPGRADE_UUID = new WindowsBundlerParam<>( Arguments.CLIOptions.WIN_UPGRADE_UUID.getId(), String.class, null, (s, p) -> s); @Override public String getName() { return I18N.getString("msi.bundler.name"); } @Override public String getID() { return "msi"; } @Override public String getBundleType() { return "INSTALLER"; } @Override public File execute(Map params, File outputParentDir) throws PackagerException { return bundle(params, outputParentDir); } @Override public boolean supported(boolean platformInstaller) { try { if (wixToolset == null) { wixToolset = WixTool.toolset(); } return true; } catch (ConfigException ce) { Log.error(ce.getMessage()); if (ce.getAdvice() != null) { Log.error(ce.getAdvice()); } } catch (Exception e) { Log.error(e.getMessage()); } return false; } @Override public boolean isDefault() { return false; } private static UUID getUpgradeCode(Map params) { String upgradeCode = UPGRADE_UUID.fetchFrom(params); if (upgradeCode != null) { return UUID.fromString(upgradeCode); } return createNameUUID("UpgradeCode", params, List.of(VENDOR, APP_NAME)); } private static UUID getProductCode(Map params) { return createNameUUID("ProductCode", params, List.of(VENDOR, APP_NAME, VERSION)); } private static UUID createNameUUID(String prefix, Map params, List> components) { String key = Stream.concat(Stream.of(prefix), components.stream().map( c -> c.fetchFrom(params))).collect(Collectors.joining("/")); return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)); } @Override public boolean validate(Map params) throws ConfigException { try { if (wixToolset == null) { wixToolset = WixTool.toolset(); } try { getUpgradeCode(params); } catch (IllegalArgumentException ex) { throw new ConfigException(ex); } for (var toolInfo: wixToolset.values()) { Log.verbose(MessageFormat.format(I18N.getString( "message.tool-version"), toolInfo.path.getFileName(), toolInfo.version)); } wixSourcesBuilder.setWixVersion(wixToolset.get(WixTool.Light).version); wixSourcesBuilder.logWixFeatures(); /********* validate bundle parameters *************/ String version = PRODUCT_VERSION.fetchFrom(params); if (!isVersionStringValid(version)) { throw new ConfigException( MessageFormat.format(I18N.getString( "error.version-string-wrong-format"), version), MessageFormat.format(I18N.getString( "error.version-string-wrong-format.advice"), PRODUCT_VERSION.getID())); } // only one mime type per association, at least one file extension List> associations = FILE_ASSOCIATIONS.fetchFrom(params); if (associations != null) { for (int i = 0; i < associations.size(); i++) { Map assoc = associations.get(i); List mimes = FA_CONTENT_TYPE.fetchFrom(assoc); if (mimes.size() > 1) { throw new ConfigException(MessageFormat.format( I18N.getString("error.too-many-content-types-for-file-association"), i), I18N.getString("error.too-many-content-types-for-file-association.advice")); } } } return true; } catch (RuntimeException re) { if (re.getCause() instanceof ConfigException) { throw (ConfigException) re.getCause(); } else { throw new ConfigException(re); } } } // https://msdn.microsoft.com/en-us/library/aa370859%28v=VS.85%29.aspx // The format of the string is as follows: // major.minor.build // The first field is the major version and has a maximum value of 255. // The second field is the minor version and has a maximum value of 255. // The third field is called the build version or the update version and // has a maximum value of 65,535. static boolean isVersionStringValid(String v) { if (v == null) { return true; } String p[] = v.split("\\."); if (p.length > 3) { Log.verbose(I18N.getString( "message.version-string-too-many-components")); return false; } try { int val = Integer.parseInt(p[0]); if (val < 0 || val > 255) { Log.verbose(I18N.getString( "error.version-string-major-out-of-range")); return false; } if (p.length > 1) { val = Integer.parseInt(p[1]); if (val < 0 || val > 255) { Log.verbose(I18N.getString( "error.version-string-minor-out-of-range")); return false; } } if (p.length > 2) { val = Integer.parseInt(p[2]); if (val < 0 || val > 65535) { Log.verbose(I18N.getString( "error.version-string-build-out-of-range")); return false; } } } catch (NumberFormatException ne) { Log.verbose(I18N.getString("error.version-string-part-not-number")); Log.verbose(ne); return false; } return true; } private void prepareProto(Map params) throws PackagerException, IOException { File appImage = StandardBundlerParam.getPredefinedAppImage(params); File appDir = null; // we either have an application image or need to build one if (appImage != null) { appDir = new File(MSI_IMAGE_DIR.fetchFrom(params), APP_NAME.fetchFrom(params)); // copy everything from appImage dir into appDir/name IOUtils.copyRecursive(appImage.toPath(), appDir.toPath()); } else { appDir = APP_BUNDLER.fetchFrom(params).doBundle(params, MSI_IMAGE_DIR.fetchFrom(params), true); } params.put(WIN_APP_IMAGE.getID(), appDir); String licenseFile = LICENSE_FILE.fetchFrom(params); if (licenseFile != null) { // need to copy license file to the working directory // and convert to rtf if needed File lfile = new File(licenseFile); File destFile = new File(CONFIG_ROOT.fetchFrom(params), lfile.getName()); IOUtils.copyFile(lfile, destFile); destFile.setWritable(true); ensureByMutationFileIsRTF(destFile); } } public File bundle(Map params, File outdir) throws PackagerException { IOUtils.writableOutputDir(outdir.toPath()); Path imageDir = MSI_IMAGE_DIR.fetchFrom(params).toPath(); try { Files.createDirectories(imageDir); prepareProto(params); wixSourcesBuilder .initFromParams(WIN_APP_IMAGE.fetchFrom(params).toPath(), params) .createMainFragment(CONFIG_ROOT.fetchFrom(params).toPath().resolve( "bundle.wxf")); Map wixVars = prepareMainProjectFile(params); new ScriptRunner() .setDirectory(imageDir) .setResourceCategoryId("resource.post-app-image-script") .setScriptNameSuffix("post-image") .setEnvironmentVariable("JpAppImageDir", imageDir.toAbsolutePath().toString()) .run(params); return buildMSI(params, wixVars, outdir); } catch (IOException ex) { Log.verbose(ex); throw new PackagerException(ex); } } Map prepareMainProjectFile( Map params) throws IOException { Map data = new HashMap<>(); final UUID productCode = getProductCode(params); final UUID upgradeCode = getUpgradeCode(params); data.put("JpProductCode", productCode.toString()); data.put("JpProductUpgradeCode", upgradeCode.toString()); Log.verbose(MessageFormat.format(I18N.getString("message.product-code"), productCode)); Log.verbose(MessageFormat.format(I18N.getString("message.upgrade-code"), upgradeCode)); data.put("JpAllowUpgrades", "yes"); data.put("JpAppName", APP_NAME.fetchFrom(params)); data.put("JpAppDescription", DESCRIPTION.fetchFrom(params)); data.put("JpAppVendor", VENDOR.fetchFrom(params)); data.put("JpAppVersion", PRODUCT_VERSION.fetchFrom(params)); final Path configDir = CONFIG_ROOT.fetchFrom(params).toPath(); data.put("JpConfigDir", configDir.toAbsolutePath().toString()); if (MSI_SYSTEM_WIDE.fetchFrom(params)) { data.put("JpIsSystemWide", "yes"); } String licenseFile = LICENSE_FILE.fetchFrom(params); if (licenseFile != null) { String lname = new File(licenseFile).getName(); File destFile = new File(CONFIG_ROOT.fetchFrom(params), lname); data.put("JpLicenseRtf", destFile.getAbsolutePath()); } // Copy CA dll to include with installer if (INSTALLDIR_CHOOSER.fetchFrom(params)) { data.put("JpInstallDirChooser", "yes"); String fname = "wixhelper.dll"; try (InputStream is = OverridableResource.readDefault(fname)) { Files.copy(is, Paths.get( CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), fname)); } } // Copy l10n files. for (String loc : Arrays.asList("en", "ja", "zh_CN")) { String fname = "MsiInstallerStrings_" + loc + ".wxl"; try (InputStream is = OverridableResource.readDefault(fname)) { Files.copy(is, Paths.get( CONFIG_ROOT.fetchFrom(params).getAbsolutePath(), fname)); } } createResource("main.wxs", params) .setCategory(I18N.getString("resource.main-wix-file")) .saveToFile(configDir.resolve("main.wxs")); createResource("overrides.wxi", params) .setCategory(I18N.getString("resource.overrides-wix-file")) .saveToFile(configDir.resolve("overrides.wxi")); return data; } private File buildMSI(Map params, Map wixVars, File outdir) throws IOException { File msiOut = new File( outdir, INSTALLER_FILE_NAME.fetchFrom(params) + ".msi"); Log.verbose(MessageFormat.format(I18N.getString( "message.preparing-msi-config"), msiOut.getAbsolutePath())); WixPipeline wixPipeline = new WixPipeline() .setToolset(wixToolset.entrySet().stream().collect( Collectors.toMap( entry -> entry.getKey(), entry -> entry.getValue().path))) .setWixObjDir(TEMP_ROOT.fetchFrom(params).toPath().resolve("wixobj")) .setWorkDir(WIN_APP_IMAGE.fetchFrom(params).toPath()) .addSource(CONFIG_ROOT.fetchFrom(params).toPath().resolve("main.wxs"), wixVars) .addSource(CONFIG_ROOT.fetchFrom(params).toPath().resolve("bundle.wxf"), null); Log.verbose(MessageFormat.format(I18N.getString( "message.generating-msi"), msiOut.getAbsolutePath())); boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null); boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params); List lightArgs = new ArrayList<>(); if (!MSI_SYSTEM_WIDE.fetchFrom(params)) { wixPipeline.addLightOptions("-sice:ICE91"); } if (enableLicenseUI || enableInstalldirUI) { wixPipeline.addLightOptions("-ext", "WixUIExtension"); } wixPipeline.addLightOptions("-loc", CONFIG_ROOT.fetchFrom(params).toPath().resolve(I18N.getString( "resource.wxl-file-name")).toAbsolutePath().toString()); // Only needed if we using CA dll, so Wix can find it if (enableInstalldirUI) { wixPipeline.addLightOptions("-b", CONFIG_ROOT.fetchFrom(params).getAbsolutePath()); } wixPipeline.buildMsi(msiOut.toPath().toAbsolutePath()); return msiOut; } public static void ensureByMutationFileIsRTF(File f) { if (f == null || !f.isFile()) return; try { boolean existingLicenseIsRTF = false; try (FileInputStream fin = new FileInputStream(f)) { byte[] firstBits = new byte[7]; if (fin.read(firstBits) == firstBits.length) { String header = new String(firstBits); existingLicenseIsRTF = "{\\rtf1\\".equals(header); } } if (!existingLicenseIsRTF) { List oldLicense = Files.readAllLines(f.toPath()); try (Writer w = Files.newBufferedWriter( f.toPath(), Charset.forName("Windows-1252"))) { w.write("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033" + "{\\fonttbl{\\f0\\fnil\\fcharset0 Arial;}}\n" + "\\viewkind4\\uc1\\pard\\sa200\\sl276" + "\\slmult1\\lang9\\fs20 "); oldLicense.forEach(l -> { try { for (char c : l.toCharArray()) { // 0x00 <= ch < 0x20 Escaped (\'hh) // 0x20 <= ch < 0x80 Raw(non - escaped) char // 0x80 <= ch <= 0xFF Escaped(\ 'hh) // 0x5C, 0x7B, 0x7D (special RTF characters // \,{,})Escaped(\'hh) // ch > 0xff Escaped (\\ud###?) if (c < 0x10) { w.write("\\'0"); w.write(Integer.toHexString(c)); } else if (c > 0xff) { w.write("\\ud"); w.write(Integer.toString(c)); // \\uc1 is in the header and in effect // so we trail with a replacement char if // the font lacks that character - '?' w.write("?"); } else if ((c < 0x20) || (c >= 0x80) || (c == 0x5C) || (c == 0x7B) || (c == 0x7D)) { w.write("\\'"); w.write(Integer.toHexString(c)); } else { w.write(c); } } // blank lines are interpreted as paragraph breaks if (l.length() < 1) { w.write("\\par"); } else { w.write(" "); } w.write("\r\n"); } catch (IOException e) { Log.verbose(e); } }); w.write("}\r\n"); } } } catch (IOException e) { Log.verbose(e); } } private Map wixToolset; private WixSourcesBuilder wixSourcesBuilder = new WixSourcesBuilder(); }