--- /dev/null 2019-11-20 11:02:14.000000000 -0500 +++ new/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/WinMsiBundler.java 2019-11-20 11:02:12.659490100 -0500 @@ -0,0 +1,580 @@ +/* + * 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(); + +}