blob: 9bd0c679146e735e0ccfae22f9914d74e7b37569 [file]
/*
* Copyright (c) 2012, 2025, 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 static jdk.jpackage.internal.model.ConfigException.rethrowConfigException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.ArrayList;
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.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 jdk.jpackage.internal.PackagingPipeline.PackageBuildEnv;
import jdk.jpackage.internal.model.AppImageLayout;
import jdk.jpackage.internal.model.ApplicationLayout;
import jdk.jpackage.internal.model.ConfigException;
import jdk.jpackage.internal.model.Package;
import jdk.jpackage.internal.model.PackagerException;
import jdk.jpackage.internal.model.RuntimeLayout;
import jdk.jpackage.internal.model.WinMsiPackage;
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.
* <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.
* <li>ui.wxf. Source file with UI description of the installer.
* </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>JpAllowUpgrades. Set to "yes", but all that matters is it is defined.
* <li>JpAllowDowngrades. Defined for application installers, and undefined for
* Java runtime installers.
* <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
* <li>JpAppSizeKb. Set to estimated size of the application in kilobytes
* <li>JpHelpURL. Set to value of --win-help-url command line option if it
* was specified. Undefined otherwise
* <li>JpAboutURL. Set to value of --about-url command line option if it
* was specified. Undefined otherwise
* <li>JpUpdateURL. Set to value of --win-update-url command line option if it
* was specified. Undefined otherwise
* </ul>
*
* <p>
* ui.wxf file is generated based on --license-file, --win-shortcut-prompt,
* --win-dir-chooser command line options. It is parametrized with the following
* WiX variables:
* <ul>
* <li>JpLicenseRtf. Set to the value of --license-file command line option.
* Undefined if --license-file command line option was not specified
* </ul>
*/
public class WinMsiBundler extends AbstractBundler {
public WinMsiBundler() {
wixFragments = Stream.of(
Map.entry("bundle.wxf", new WixAppImageFragmentBuilder()),
Map.entry("ui.wxf", new WixUiFragmentBuilder()),
Map.entry("os-condition.wxf", OSVersionCondition.createWixFragmentBuilder())
).<WixFragmentBuilder>map(e -> {
e.getValue().setOutputFileName(e.getKey());
return e.getValue();
}).toList();
}
@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.createToolset();
}
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;
}
@Override
public boolean validate(Map<String, ? super Object> params)
throws ConfigException {
try {
// Order is important!
WinFromParams.APPLICATION.fetchFrom(params);
BuildEnvFromParams.BUILD_ENV.fetchFrom(params);
if (wixToolset == null) {
wixToolset = WixTool.createToolset();
}
for (var tool : wixToolset.getType().getTools()) {
Log.verbose(I18N.format("message.tool-version",
wixToolset.getToolPath(tool).getFileName(),
wixToolset.getVersion()));
}
wixFragments.forEach(wixFragment -> wixFragment.setWixVersion(wixToolset.getVersion(),
wixToolset.getType()));
wixFragments.stream().map(WixFragmentBuilder::getLoggableWixFeatures).flatMap(
List::stream).distinct().toList().forEach(Log::verbose);
return true;
} catch (RuntimeException re) {
throw rethrowConfigException(re);
}
}
private void prepareProto(Package pkg, BuildEnv env, AppImageLayout appImageLayout) throws
PackagerException, IOException {
// Configure installer icon
if (appImageLayout instanceof RuntimeLayout runtimeLayout) {
// 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 = runtimeLayout.runtimeDirectory().resolve(Path.of("bin", "java.exe"));
} else if (appImageLayout instanceof ApplicationLayout appLayout) {
installerIcon = appLayout.launchersDirectory().resolve(
pkg.app().mainLauncher().orElseThrow().executableNameWithSuffix());
}
installerIcon = installerIcon.toAbsolutePath();
pkg.licenseFile().ifPresent(licenseFile -> {
// need to copy license file to the working directory
// and convert to rtf if needed
Path destFile = env.configDir().resolve(licenseFile.getFileName());
try {
IOUtils.copyFile(licenseFile, destFile);
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
destFile.toFile().setWritable(true);
ensureByMutationFileIsRTF(destFile);
});
}
@Override
public Path execute(Map<String, ? super Object> params,
Path outputParentDir) throws PackagerException {
IOUtils.writableOutputDir(outputParentDir);
// Order is important!
var pkg = WinFromParams.MSI_PACKAGE.fetchFrom(params);
var env = BuildEnvFromParams.BUILD_ENV.fetchFrom(params);
WinPackagingPipeline.build()
.excludeDirFromCopying(outputParentDir)
.task(PackagingPipeline.PackageTaskID.CREATE_CONFIG_FILES)
.packageAction(this::prepareConfigFiles)
.add()
.task(PackagingPipeline.PackageTaskID.CREATE_PACKAGE_FILE)
.packageAction(this::buildPackage)
.add()
.create().execute(env, pkg, outputParentDir);
return outputParentDir.resolve(pkg.packageFileNameWithSuffix()).toAbsolutePath();
}
private void prepareConfigFiles(PackageBuildEnv<WinMsiPackage, AppImageLayout> env) throws PackagerException, IOException {
prepareProto(env.pkg(), env.env(), env.resolvedLayout());
for (var wixFragment : wixFragments) {
wixFragment.initFromParams(env.env(), env.pkg());
wixFragment.addFilesToConfigRoot();
}
final var msiOut = env.outputDir().resolve(env.pkg().packageFileNameWithSuffix());
Log.verbose(I18N.format("message.preparing-msi-config", msiOut.toAbsolutePath()));
final var wixVars = createWixVars(env);
final var wixObjDir = env.env().buildRoot().resolve("wixobj");
final var configDir = env.env().configDir();
final var wixPipelineBuilder = WixPipeline.build()
.setWixObjDir(wixObjDir)
.setWorkDir(env.env().appImageDir())
.addSource(configDir.resolve("main.wxs"), wixVars);
for (var wixFragment : wixFragments) {
wixFragment.configureWixPipeline(wixPipelineBuilder);
}
switch (wixToolset.getType()) {
case Wix3 -> {
wixPipelineBuilder.addLightOptions("-sice:ICE27");
if (!env.pkg().isSystemWideInstall()) {
wixPipelineBuilder.addLightOptions("-sice:ICE91");
}
}
case Wix4 -> {
}
default -> {
throw new IllegalArgumentException();
}
}
var primaryWxlFiles = Stream.of("de", "en", "ja", "zh_CN").map(loc -> {
return configDir.resolve("MsiInstallerStrings_" + loc + ".wxl");
}).toList();
var wixResources = new WixSourceConverter.ResourceGroup(wixToolset.getType());
// Copy standard l10n files.
for (var path : primaryWxlFiles) {
var name = path.getFileName().toString();
wixResources.addResource(env.env().createResource(name).setPublicName(name).setCategory(
I18N.getString("resource.wxl-file")), path);
}
wixResources.addResource(env.env().createResource("main.wxs").setPublicName("main.wxs").
setCategory(I18N.getString("resource.main-wix-file")), configDir.resolve("main.wxs"));
wixResources.addResource(env.env().createResource("overrides.wxi").setPublicName(
"overrides.wxi").setCategory(I18N.getString("resource.overrides-wix-file")),
configDir.resolve("overrides.wxi"));
// Filter out custom l10n files that were already used to
// override primary l10n files. Ignore case filename comparison,
// both lists are expected to be short.
List<Path> customWxlFiles = env.env().resourceDir()
.map(WinMsiBundler::getWxlFilesFromDir)
.orElseGet(Collections::emptyList)
.stream()
.filter(custom -> primaryWxlFiles.stream().noneMatch(primary ->
primary.getFileName().toString().equalsIgnoreCase(
custom.getFileName().toString())))
.peek(custom -> Log.verbose(I18N.format(
"message.using-custom-resource", String.format("[%s]",
I18N.getString("resource.wxl-file")),
custom.getFileName()))).toList();
// Copy custom l10n files.
for (var path : customWxlFiles) {
var name = path.getFileName().toString();
wixResources.addResource(env.env().createResource(name).setPublicName(name).
setSourceOrder(OverridableResource.Source.ResourceDir).setCategory(I18N.
getString("resource.wxl-file")), configDir.resolve(name));
}
// Save all WiX resources into config dir.
wixResources.saveResources();
// All l10n files are supplied to WiX with "-loc", but only
// Cultures from custom files and a single primary Culture are
// included into "-cultures" list
for (var wxl : primaryWxlFiles) {
wixPipelineBuilder.addLightOptions("-loc", wxl.toString());
}
List<String> cultures = new ArrayList<>();
for (var wxl : customWxlFiles) {
wxl = configDir.resolve(wxl.getFileName());
wixPipelineBuilder.addLightOptions("-loc", wxl.toString());
cultures.add(getCultureFromWxlFile(wxl));
}
// Append a primary culture bases on runtime locale.
final Path primaryWxlFile = configDir.resolve(
I18N.getString("resource.wxl-file-name"));
cultures.add(getCultureFromWxlFile(primaryWxlFile));
// Build ordered list of unique cultures.
Set<String> uniqueCultures = new LinkedHashSet<>();
uniqueCultures.addAll(cultures);
switch (wixToolset.getType()) {
case Wix3 -> {
wixPipelineBuilder.addLightOptions(uniqueCultures.stream().collect(Collectors.joining(";",
"-cultures:", "")));
}
case Wix4 -> {
uniqueCultures.forEach(culture -> {
wixPipelineBuilder.addLightOptions("-culture", culture);
});
}
default -> {
throw new IllegalArgumentException();
}
}
Files.createDirectories(wixObjDir);
wixPipeline = wixPipelineBuilder.create(wixToolset);
}
private void buildPackage(PackageBuildEnv<WinMsiPackage, AppImageLayout> env) throws PackagerException, IOException {
final var msiOut = env.outputDir().resolve(env.pkg().packageFileNameWithSuffix());
Log.verbose(I18N.format("message.generating-msi", msiOut.toAbsolutePath()));
wixPipeline.buildMsi(msiOut.toAbsolutePath());
}
private Map<String, String> createWixVars(PackageBuildEnv<WinMsiPackage, AppImageLayout> env) throws IOException {
Map<String, String> data = new HashMap<>();
final var pkg = env.pkg();
data.put("JpProductCode", pkg.productCode().toString());
data.put("JpProductUpgradeCode", pkg.upgradeCode().toString());
Log.verbose(I18N.format("message.product-code", pkg.productCode()));
Log.verbose(I18N.format("message.upgrade-code", pkg.upgradeCode()));
data.put("JpAllowUpgrades", "yes");
if (!pkg.isRuntimeInstaller()) {
data.put("JpAllowDowngrades", "yes");
}
data.put("JpAppName", pkg.packageName());
data.put("JpAppDescription", pkg.description());
data.put("JpAppVendor", pkg.app().vendor());
data.put("JpAppVersion", pkg.version());
if (Files.exists(installerIcon)) {
data.put("JpIcon", installerIcon.toString());
}
pkg.helpURL().ifPresent(value -> {
data.put("JpHelpURL", value);
});
pkg.updateURL().ifPresent(value -> {
data.put("JpUpdateURL", value);
});
pkg.aboutURL().ifPresent(value -> {
data.put("JpAboutURL", value);
});
data.put("JpAppSizeKb", Long.toString(AppImageLayout.toPathGroup(
env.resolvedLayout()).sizeInBytes() >> 10));
data.put("JpConfigDir", env.env().configDir().toAbsolutePath().toString());
if (pkg.isSystemWideInstall()) {
data.put("JpIsSystemWide", "yes");
}
return data;
}
private static List<Path> getWxlFilesFromDir(Path dir) {
final String glob = "glob:**/*.wxl";
final PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher(
glob);
try (var walk = Files.walk(dir, 1)) {
return walk
.filter(Files::isReadable)
.filter(pathMatcher::matches)
.sorted((a, b) -> a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString()))
.toList();
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
private static String getCultureFromWxlFile(Path wxlPath) {
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(I18N.format(
"error.extract-culture-from-wix-l10n-file",
wxlPath.toAbsolutePath().normalize()));
}
return nodes.item(0).getNodeValue();
} catch (XPathExpressionException | ParserConfigurationException
| SAXException ex) {
throw new UncheckedIOException(new IOException(
I18N.format("error.read-wix-l10n-file", wxlPath.toAbsolutePath().normalize()), ex));
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
private static void ensureByMutationFileIsRTF(Path f) {
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 WixToolset wixToolset;
private WixPipeline wixPipeline;
private final List<WixFragmentBuilder> wixFragments;
}