/*
* Copyright (c) 2012, 2020, 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.jpackage.internal;
import java.io.IOException;
import java.io.InputStream;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import static jdk.jpackage.internal.OverridableResource.createResource;
import static jdk.jpackage.internal.StandardBundlerParam.APP_NAME;
import static jdk.jpackage.internal.StandardBundlerParam.INSTALLER_NAME;
import static jdk.jpackage.internal.StandardBundlerParam.CONFIG_ROOT;
import static jdk.jpackage.internal.StandardBundlerParam.DESCRIPTION;
import static jdk.jpackage.internal.StandardBundlerParam.LICENSE_FILE;
import static jdk.jpackage.internal.StandardBundlerParam.RESOURCE_DIR;
import static jdk.jpackage.internal.StandardBundlerParam.TEMP_ROOT;
import static jdk.jpackage.internal.StandardBundlerParam.VENDOR;
import static jdk.jpackage.internal.StandardBundlerParam.VERSION;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
WinMsiBundler
Produces .msi installer from application image. Uses WiX Toolkit to build
.msi installer.
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. Main source file with the installer description
- bundle.wxf. Source file with application and Java run-time directory tree
description.
main.wxs file is a copy of main.wxs resource from
jdk.jpackage.internal.resources package. It is parametrized with the
following WiX variables:
- JpAppName. Name of the application. Set to the value of --name command
line option
- JpAppVersion. Version of the application. Set to the value of
--app-version command line option
- JpAppVendor. Vendor of the application. Set to the value of --vendor
command line option
- JpAppDescription. Description of the application. Set to the value of
--description command line option
- JpProductCode. Set to product code UUID of the application. Random value generated by jpackage every time
execute
method is called - JpProductUpgradeCode. Set to upgrade code UUID of the application. Random value generated by jpackage every time
execute
method is called if --win-upgrade-uuid command line option is not specified. Otherwise this variable is set to the value of --win-upgrade-uuid command line option - JpAllowDowngrades. Set to "yes" if --win-upgrade-uuid command line option
was specified. Undefined otherwise
- JpLicenseRtf. Set to the value of --license-file command line option.
Undefined is --license-file command line option was not specified
- JpInstallDirChooser. Set to "yes" if --win-dir-chooser command line
option was specified. Undefined otherwise
- JpConfigDir. Absolute path to the directory with generated WiX source
files.
- JpIsSystemWide. Set to "yes" if --win-per-user-install command line
option was not specified. Undefined otherwise
/**
* WinMsiBundler
*
* Produces .msi installer from application image. Uses WiX Toolkit to build
* .msi installer.
* <p>
* {@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:
* <ul>
* <li>main.wxs. Main source file with the installer description
* <li>bundle.wxf. Source file with application and Java run-time directory tree
* description.
* </ul>
* <p>
* main.wxs file is a copy of main.wxs resource from
* jdk.jpackage.internal.resources package. It is parametrized with the
* following WiX variables:
* <ul>
* <li>JpAppName. Name of the application. Set to the value of --name command
* line option
* <li>JpAppVersion. Version of the application. Set to the value of
* --app-version command line option
* <li>JpAppVendor. Vendor of the application. Set to the value of --vendor
* command line option
* <li>JpAppDescription. Description of the application. Set to the value of
* --description command line option
* <li>JpProductCode. Set to product code UUID of the application. Random value
* generated by jpackage every time {@link #execute} method is called
* <li>JpProductUpgradeCode. Set to upgrade code UUID of the application. Random
* value generated by jpackage every time {@link #execute} method is called if
* --win-upgrade-uuid command line option is not specified. Otherwise this
* variable is set to the value of --win-upgrade-uuid command line option
* <li>JpAllowDowngrades. Set to "yes" if --win-upgrade-uuid command line option
* was specified. Undefined otherwise
* <li>JpLicenseRtf. Set to the value of --license-file command line option.
* Undefined is --license-file command line option was not specified
* <li>JpInstallDirChooser. Set to "yes" if --win-dir-chooser command line
* option was specified. Undefined otherwise
* <li>JpConfigDir. Absolute path to the directory with generated WiX source
* files.
* <li>JpIsSystemWide. Set to "yes" if --win-per-user-install command line
* option was not specified. Undefined otherwise
* </ul>
*/
public class WinMsiBundler extends AbstractBundler {
public static final BundlerParamInfo<Path> MSI_IMAGE_DIR =
new StandardBundlerParam<>(
"win.msi.imageDir",
Path.class,
params -> {
Path imagesRoot = IMAGES_ROOT.fetchFrom(params);
if (!Files.exists(imagesRoot)) {
try {
Files.createDirectories(imagesRoot);
} catch (IOException ioe) {
return null;
}
}
return imagesRoot.resolve("win-msi.image");
},
(s, p) -> null);
public static final BundlerParamInfo<Path> WIN_APP_IMAGE =
new StandardBundlerParam<>(
"win.app.image",
Path.class,
null,
(s, p) -> null);
public static final StandardBundlerParam<Boolean> 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<String> PRODUCT_VERSION =
new StandardBundlerParam<>(
"win.msi.productVersion",
String.class,
VERSION::fetchFrom,
(s, p) -> s
);
private static final BundlerParamInfo<String> UPGRADE_UUID =
new StandardBundlerParam<>(
Arguments.CLIOptions.WIN_UPGRADE_UUID.getId(),
String.class,
null,
(s, p) -> s);
private static final BundlerParamInfo<String> INSTALLER_FILE_NAME =
new StandardBundlerParam<> (
"win.installerName",
String.class,
params -> {
String nm = INSTALLER_NAME.fetchFrom(params);
if (nm == null) return null;
String version = VERSION.fetchFrom(params);
if (version == null) {
return nm;
} else {
return nm + "-" + version;
}
},
(s, p) -> s);
private static final BundlerParamInfo<Boolean> INSTALLDIR_CHOOSER =
new StandardBundlerParam<> (
Arguments.CLIOptions.WIN_DIR_CHOOSER.getId(),
Boolean.class,
params -> Boolean.FALSE,
(s, p) -> Boolean.valueOf(s)
);
public WinMsiBundler() {
appImageBundler = new WinAppBundler().setDependentTask(true);
}
@Override
public String getName() {
return I18N.getString("msi.bundler.name");
}
@Override
public String getID() {
return "msi";
}
@Override
public String getBundleType() {
return "INSTALLER";
}
@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<String, ? super Object> 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<String, ? super Object> params) {
return createNameUUID("ProductCode", params, List.of(VENDOR, APP_NAME,
VERSION));
}
private static UUID createNameUUID(String prefix,
Map<String, ? super Object> params,
List<StandardBundlerParam<String>> 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<String, ? super Object> params)
throws ConfigException {
try {
appImageBundler.validate(params);
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 *************/
try {
String version = PRODUCT_VERSION.fetchFrom(params);
MsiVersion.of(version);
} catch (IllegalArgumentException ex) {
throw new ConfigException(ex.getMessage(), I18N.getString(
"error.version-string-wrong-format.advice"), ex);
}
FileAssociation.verify(FileAssociation.fetchFrom(params));
return true;
} catch (RuntimeException re) {
if (re.getCause() instanceof ConfigException) {
throw (ConfigException) re.getCause();
} else {
throw new ConfigException(re);
}
}
}
private void prepareProto(Map<String, ? super Object> params)
throws PackagerException, IOException {
Path appImage = StandardBundlerParam.getPredefinedAppImage(params);
String appName = APP_NAME.fetchFrom(params);
Path appDir;
if (appName == null) {
// Can happen when no name is given, and using a foreign app-image
throw new PackagerException("error.no.name");
}
// we either have an application image or need to build one
if (appImage != null) {
appDir = MSI_IMAGE_DIR.fetchFrom(params).resolve(appName);
// copy everything from appImage dir into appDir/name
IOUtils.copyRecursive(appImage, appDir);
} else {
appDir = appImageBundler.execute(params, MSI_IMAGE_DIR.fetchFrom(
params));
}
// Configure installer icon
if (StandardBundlerParam.isRuntimeInstaller(params)) {
// Use icon from java launcher.
// Assume java.exe exists in Java Runtime being packed.
// Ignore custom icon if any as we don't want to copy anything in
// Java Runtime image.
installerIcon = ApplicationLayout.javaRuntime()
.resolveAt(appDir)
.runtimeDirectory()
.resolve(Path.of("bin", "java.exe"));
} else {
installerIcon = ApplicationLayout.windowsAppImage()
.resolveAt(appDir)
.launchersDirectory()
.resolve(appName + ".exe");
}
installerIcon = installerIcon.toAbsolutePath();
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
Path lfile = Path.of(licenseFile);
Path destFile = CONFIG_ROOT.fetchFrom(params)
.resolve(lfile.getFileName());
IOUtils.copyFile(lfile, destFile);
destFile.toFile().setWritable(true);
ensureByMutationFileIsRTF(destFile);
}
}
@Override
public Path execute(Map<String, ? super Object> params,
Path outputParentDir) throws PackagerException {
IOUtils.writableOutputDir(outputParentDir);
Path imageDir = MSI_IMAGE_DIR.fetchFrom(params);
try {
Files.createDirectories(imageDir);
prepareProto(params);
wixSourcesBuilder
.initFromParams(WIN_APP_IMAGE.fetchFrom(params), params)
.createMainFragment(CONFIG_ROOT.fetchFrom(params).resolve(
"bundle.wxf"));
Map<String, String> 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, outputParentDir);
} catch (IOException ex) {
Log.verbose(ex);
throw new PackagerException(ex);
}
}
private Map<String, String> prepareMainProjectFile(
Map<String, ? super Object> params) throws IOException {
Map<String, String> 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("JpAllowDowngrades", "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));
if (Files.exists(installerIcon)) {
data.put("JpIcon", installerIcon.toString());
}
final Path configDir = CONFIG_ROOT.fetchFrom(params);
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 = IOUtils.getFileName(Path.of(licenseFile)).toString();
Path destFile = CONFIG_ROOT.fetchFrom(params).resolve(lname);
data.put("JpLicenseRtf", destFile.toAbsolutePath().toString());
}
// 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, CONFIG_ROOT.fetchFrom(params).resolve(fname));
}
}
// Copy standard 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, CONFIG_ROOT.fetchFrom(params).resolve(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 Path buildMSI(Map<String, ? super Object> params,
Map<String, String> wixVars, Path outdir)
throws IOException {
Path msiOut = outdir.resolve(INSTALLER_FILE_NAME.fetchFrom(params) + ".msi");
Log.verbose(MessageFormat.format(I18N.getString(
"message.preparing-msi-config"), msiOut.toAbsolutePath()
.toString()));
WixPipeline wixPipeline = new WixPipeline()
.setToolset(wixToolset.entrySet().stream().collect(
Collectors.toMap(
entry -> entry.getKey(),
entry -> entry.getValue().path)))
.setWixObjDir(TEMP_ROOT.fetchFrom(params).resolve("wixobj"))
.setWorkDir(WIN_APP_IMAGE.fetchFrom(params))
.addSource(CONFIG_ROOT.fetchFrom(params).resolve("main.wxs"), wixVars)
.addSource(CONFIG_ROOT.fetchFrom(params).resolve("bundle.wxf"), null);
Log.verbose(MessageFormat.format(I18N.getString(
"message.generating-msi"), msiOut.toAbsolutePath().toString()));
boolean enableLicenseUI = (LICENSE_FILE.fetchFrom(params) != null);
boolean enableInstalldirUI = INSTALLDIR_CHOOSER.fetchFrom(params);
wixPipeline.addLightOptions("-sice:ICE27");
if (!MSI_SYSTEM_WIDE.fetchFrom(params)) {
wixPipeline.addLightOptions("-sice:ICE91");
}
if (enableLicenseUI || enableInstalldirUI) {
wixPipeline.addLightOptions("-ext", "WixUIExtension");
}
final Path primaryWxlFile = CONFIG_ROOT.fetchFrom(params).resolve(
I18N.getString("resource.wxl-file-name")).toAbsolutePath();
wixPipeline.addLightOptions("-loc", primaryWxlFile.toString());
List<String> cultures = new ArrayList<>();
for (var wxl : getCustomWxlFiles(params)) {
wixPipeline.addLightOptions("-loc", wxl.toAbsolutePath().toString());
cultures.add(getCultureFromWxlFile(wxl));
}
cultures.add(getCultureFromWxlFile(primaryWxlFile));
// Build ordered list of unique cultures.
Set<String> uniqueCultures = new LinkedHashSet<>();
uniqueCultures.addAll(cultures);
wixPipeline.addLightOptions(uniqueCultures.stream().collect(
Collectors.joining(";", "-cultures:", "")));
// Only needed if we using CA dll, so Wix can find it
if (enableInstalldirUI) {
wixPipeline.addLightOptions("-b", CONFIG_ROOT.fetchFrom(params)
.toAbsolutePath().toString());
}
wixPipeline.buildMsi(msiOut.toAbsolutePath());
return msiOut;
}
private static List<Path> getCustomWxlFiles(Map<String, ? super Object> params)
throws IOException {
Path resourceDir = RESOURCE_DIR.fetchFrom(params);
if (resourceDir == null) {
return Collections.emptyList();
}
final String glob = "glob:**/*.wxl";
final PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher(
glob);
try (var walk = Files.walk(resourceDir, 1)) {
return walk
.filter(Files::isReadable)
.filter(pathMatcher::matches)
.sorted((a, b) -> a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString()))
.collect(Collectors.toList());
}
}
private static String getCultureFromWxlFile(Path wxlPath) throws IOException {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(wxlPath.toFile());
XPath xPath = XPathFactory.newInstance().newXPath();
NodeList nodes = (NodeList) xPath.evaluate(
"//WixLocalization/@Culture", doc,
XPathConstants.NODESET);
if (nodes.getLength() != 1) {
throw new IOException(MessageFormat.format(I18N.getString(
"error.extract-culture-from-wix-l10n-file"),
wxlPath.toAbsolutePath()));
}
return nodes.item(0).getNodeValue();
} catch (XPathExpressionException | ParserConfigurationException
| SAXException ex) {
throw new IOException(MessageFormat.format(I18N.getString(
"error.read-wix-l10n-file"), wxlPath.toAbsolutePath()), ex);
}
}
private static void ensureByMutationFileIsRTF(Path f) {
if (f == null || !Files.isRegularFile(f)) return;
try {
boolean existingLicenseIsRTF = false;
try (InputStream fin = Files.newInputStream(f)) {
byte[] firstBits = new byte[7];
if (fin.read(firstBits) == firstBits.length) {
String header = new String(firstBits);
existingLicenseIsRTF = "{\\rtf1\\".equals(header);
}
}
if (!existingLicenseIsRTF) {
List<String> oldLicense = Files.readAllLines(f);
try (Writer w = Files.newBufferedWriter(
f, 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 Path installerIcon;
private Map<WixTool, WixTool.ToolInfo> wixToolset;
private AppImageBundler appImageBundler;
private WixSourcesBuilder wixSourcesBuilder = new WixSourcesBuilder();
}