| /* | |
| * 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; | |
| } |