/*
* 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.File;
import java.io.FileInputStream;
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.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static jdk.incubator.jpackage.internal.OverridableResource.createResource;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.APP_NAME;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.CONFIG_ROOT;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.DESCRIPTION;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.FA_CONTENT_TYPE;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.FILE_ASSOCIATIONS;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.LICENSE_FILE;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.TEMP_ROOT;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.VENDOR;
import static jdk.incubator.jpackage.internal.StandardBundlerParam.VERSION;
import static jdk.incubator.jpackage.internal.WindowsBundlerParam.INSTALLDIR_CHOOSER;
import static jdk.incubator.jpackage.internal.WindowsBundlerParam.INSTALLER_FILE_NAME;
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.incubator.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.incubator.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<WinAppBundler> APP_BUNDLER =
new WindowsBundlerParam<>(
"win.app.bundler",
WinAppBundler.class,
params -> new WinAppBundler(),
null);
public static final BundlerParamInfo<File> 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<File> WIN_APP_IMAGE =
new WindowsBundlerParam<>(
"win.app.image",
File.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 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<String, ? super Object> 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<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 {
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<Map<String, ? super Object>> associations =
FILE_ASSOCIATIONS.fetchFrom(params);
if (associations != null) {
for (int i = 0; i < associations.size(); i++) {
Map<String, ? super Object> assoc = associations.get(i);
List<String> 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<String, ? super Object> 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<String, ? super Object> 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<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, outdir);
} catch (IOException ex) {
Log.verbose(ex);
throw new PackagerException(ex);
}
}
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("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<String, ? super Object> params,
Map<String, String> 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);
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<String> 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<WixTool, WixTool.ToolInfo> wixToolset;
private WixSourcesBuilder wixSourcesBuilder = new WixSourcesBuilder();
}