diff --git a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/WinMsiBundler.java b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/WinMsiBundler.java --- a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/WinMsiBundler.java +++ b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/WinMsiBundler.java @@ -30,26 +30,42 @@ 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.Paths; +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.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.LICENSE_FILE; +import static jdk.incubator.jpackage.internal.StandardBundlerParam.RESOURCE_DIR; 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 org.w3c.dom.Document; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; /** * WinMsiBundler @@ -416,7 +432,7 @@ } } - // Copy l10n files. + // Copy standard l10n files. for (String loc : Arrays.asList("en", "ja", "zh_CN")) { String fname = "MsiInstallerStrings_" + loc + ".wxl"; try (InputStream is = OverridableResource.readDefault(fname)) { @@ -470,9 +486,23 @@ wixPipeline.addLightOptions("-ext", "WixUIExtension"); } - wixPipeline.addLightOptions("-loc", - CONFIG_ROOT.fetchFrom(params).resolve(I18N.getString( - "resource.wxl-file-name")).toAbsolutePath().toString()); + final Path primaryWxlFile = CONFIG_ROOT.fetchFrom(params).resolve( + I18N.getString("resource.wxl-file-name")).toAbsolutePath(); + + wixPipeline.addLightOptions("-loc", primaryWxlFile.toString()); + + List 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 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) { @@ -485,6 +515,52 @@ return msiOut; } + private static List getCustomWxlFiles(Map 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; diff --git a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources.properties b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources.properties --- a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources.properties +++ b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources.properties @@ -48,6 +48,8 @@ error.version-swap=Failed to update version information for {0} error.invalid-envvar=Invalid value of {0} environment variable error.lock-resource=Failed to lock: {0} +error.read-wix-l10n-file=Failed to parse {0} file +error.extract-culture-from-wix-l10n-file=Failed to read value of culture from {0} file message.icon-not-ico=The specified icon "{0}" is not an ICO file and will not be used. The default icon will be used in it's place. message.potential.windows.defender.issue=Warning: Windows Defender may prevent jpackage from functioning. If there is an issue, it can be addressed by either disabling realtime monitoring, or adding an exclusion for the directory "{0}". diff --git a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_ja.properties b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_ja.properties --- a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_ja.properties +++ b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_ja.properties @@ -48,6 +48,8 @@ error.version-swap={0}\u306E\u30D0\u30FC\u30B8\u30E7\u30F3\u60C5\u5831\u306E\u66F4\u65B0\u306B\u5931\u6557\u3057\u307E\u3057\u305F error.invalid-envvar={0}\u74B0\u5883\u5909\u6570\u306E\u5024\u304C\u7121\u52B9\u3067\u3059 error.lock-resource=\u30ED\u30C3\u30AF\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {0} +error.read-wix-l10n-file=Failed to parse {0} file +error.extract-culture-from-wix-l10n-file=Failed to read value of culture from {0} file message.icon-not-ico=\u6307\u5B9A\u3057\u305F\u30A2\u30A4\u30B3\u30F3"{0}"\u306FICO\u30D5\u30A1\u30A4\u30EB\u3067\u306F\u306A\u304F\u3001\u4F7F\u7528\u3055\u308C\u307E\u305B\u3093\u3002\u30C7\u30D5\u30A9\u30EB\u30C8\u30FB\u30A2\u30A4\u30B3\u30F3\u304C\u305D\u306E\u4F4D\u7F6E\u306B\u4F7F\u7528\u3055\u308C\u307E\u3059\u3002 message.potential.windows.defender.issue=\u8B66\u544A: Windows Defender\u304C\u539F\u56E0\u3067jpackage\u304C\u6A5F\u80FD\u3057\u306A\u3044\u3053\u3068\u304C\u3042\u308A\u307E\u3059\u3002\u554F\u984C\u304C\u767A\u751F\u3057\u305F\u5834\u5408\u306F\u3001\u30EA\u30A2\u30EB\u30BF\u30A4\u30E0\u30FB\u30E2\u30CB\u30BF\u30EA\u30F3\u30B0\u3092\u7121\u52B9\u306B\u3059\u308B\u304B\u3001\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA"{0}"\u306E\u9664\u5916\u3092\u8FFD\u52A0\u3059\u308B\u3053\u3068\u306B\u3088\u308A\u3001\u554F\u984C\u306B\u5BFE\u51E6\u3067\u304D\u307E\u3059\u3002 diff --git a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_zh_CN.properties b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_zh_CN.properties --- a/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_zh_CN.properties +++ b/src/jdk.incubator.jpackage/windows/classes/jdk/incubator/jpackage/internal/resources/WinResources_zh_CN.properties @@ -48,6 +48,8 @@ error.version-swap=\u65E0\u6CD5\u66F4\u65B0 {0} \u7684\u7248\u672C\u4FE1\u606F error.invalid-envvar={0} \u73AF\u5883\u53D8\u91CF\u7684\u503C\u65E0\u6548 error.lock-resource=\u65E0\u6CD5\u9501\u5B9A\uFF1A{0} +error.read-wix-l10n-file=Failed to parse {0} file +error.extract-culture-from-wix-l10n-file=Failed to read value of culture from {0} file message.icon-not-ico=\u6307\u5B9A\u7684\u56FE\u6807 "{0}" \u4E0D\u662F ICO \u6587\u4EF6, \u4E0D\u4F1A\u4F7F\u7528\u3002\u5C06\u4F7F\u7528\u9ED8\u8BA4\u56FE\u6807\u4EE3\u66FF\u3002 message.potential.windows.defender.issue=\u8B66\u544A\uFF1AWindows Defender \u53EF\u80FD\u4F1A\u963B\u6B62 jpackage \u6B63\u5E38\u5DE5\u4F5C\u3002\u5982\u679C\u5B58\u5728\u95EE\u9898\uFF0C\u53EF\u4EE5\u901A\u8FC7\u7981\u7528\u5B9E\u65F6\u76D1\u89C6\u6216\u8005\u4E3A\u76EE\u5F55 "{0}" \u6DFB\u52A0\u6392\u9664\u9879\u6765\u89E3\u51B3\u3002 diff --git a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java --- a/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java +++ b/test/jdk/tools/jpackage/helpers/jdk/jpackage/test/TKit.java @@ -244,14 +244,14 @@ return v.subpath(0, v.getNameCount()); } - public static void createTextFile(Path propsFilename, Collection lines) { - createTextFile(propsFilename, lines.stream()); + public static void createTextFile(Path filename, Collection lines) { + createTextFile(filename, lines.stream()); } - public static void createTextFile(Path propsFilename, Stream lines) { + public static void createTextFile(Path filename, Stream lines) { trace(String.format("Create [%s] text file...", - propsFilename.toAbsolutePath().normalize())); - ThrowingRunnable.toRunnable(() -> Files.write(propsFilename, + filename.toAbsolutePath().normalize())); + ThrowingRunnable.toRunnable(() -> Files.write(filename, lines.peek(TKit::trace).collect(Collectors.toList()))).run(); trace("Done"); } diff --git a/test/jdk/tools/jpackage/windows/WinL10nTest.java b/test/jdk/tools/jpackage/windows/WinL10nTest.java new file mode 100644 --- /dev/null +++ b/test/jdk/tools/jpackage/windows/WinL10nTest.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 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. + * + * 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. + */ + +import java.io.IOException; +import java.nio.file.Path; +import jdk.jpackage.test.TKit; +import jdk.jpackage.test.PackageTest; +import jdk.jpackage.test.PackageType; +import jdk.jpackage.test.Annotations.Test; +import jdk.jpackage.test.Annotations.Parameters; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.Collectors; +import jdk.jpackage.test.Executor; + +/* + * @test + * @summary Custom l10n of msi installers in jpackage + * @library ../helpers + * @key jpackagePlatformPackage + * @requires (jpackage.test.SQETest == null) + * @build jdk.jpackage.test.* + * @requires (os.family == "windows") + * @modules jdk.incubator.jpackage/jdk.incubator.jpackage.internal + * @compile WinL10nTest.java + * @run main/othervm/timeout=360 -Xmx512m jdk.jpackage.test.Main + * --jpt-run=WinL10nTest + */ + +public class WinL10nTest { + + public WinL10nTest(WixFileInitializer wxlFileInitializers[], + String expectedCulture, String expectedErrorMessage) { + this.wxlFileInitializers = wxlFileInitializers; + this.expectedCulture = expectedCulture; + this.expectedErrorMessage = expectedErrorMessage; + } + + @Parameters + public static List data() { + return List.of(new Object[][]{ + {null, "en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("a.wxl", "en-us") + }, "en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("a.wxl", "fr") + }, "fr;en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("a.wxl", "fr"), + WixFileInitializer.create("b.wxl", "fr") + }, "fr;en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("a.wxl", "it"), + WixFileInitializer.create("b.wxl", "fr") + }, "it;fr;en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("c.wxl", "it"), + WixFileInitializer.create("b.wxl", "fr") + }, "fr;it;en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("a.wxl", "fr"), + WixFileInitializer.create("b.wxl", "it"), + WixFileInitializer.create("c.wxl", "fr"), + WixFileInitializer.create("d.wxl", "it") + }, "fr;it;en-us", null}, + {new WixFileInitializer[] { + WixFileInitializer.create("c.wxl", "it"), + WixFileInitializer.createMalformed("b.wxl") + }, null, null} + }); + } + + private final static Stream getLightCommandLine( + Executor.Result result) { + return result.getOutput().stream() + .filter(s -> s.contains("Running")) + .filter(s -> s.contains("light.exe")) + .filter(s -> !s.contains("/?")); + } + + @Test + public void test() throws IOException { + + final boolean allWxlFilesValid; + if (wxlFileInitializers != null) { + allWxlFilesValid = Stream.of(wxlFileInitializers).allMatch( + WixFileInitializer::isValid); + } else { + allWxlFilesValid = true; + } + + PackageTest test = new PackageTest() + .forTypes(PackageType.WINDOWS) + .configureHelloApp() + .addInitializer(cmd -> { + // 1. Set fake run time to save time by skipping jlink step of jpackage. + // 2. Instruct test to save jpackage output. + cmd.setFakeRuntime().saveConsoleOutput(true); + }) + .addBundleVerifier((cmd, result) -> { + if (expectedCulture != null) { + TKit.assertTextStream("-cultures:" + expectedCulture).apply( + getLightCommandLine(result)); + } + + if (expectedErrorMessage != null) { + TKit.assertTextStream(expectedErrorMessage) + .apply(result.getOutput().stream()); + } + + if (wxlFileInitializers != null) { + if (allWxlFilesValid) { + for (var v : wxlFileInitializers) { + v.createCmdOutputVerifier(resourceDir).apply( + getLightCommandLine(result)); + } + } else { + Stream.of(wxlFileInitializers) + .filter(Predicate.not(WixFileInitializer::isValid)) + .forEach(v -> v.createCmdOutputVerifier( + resourceDir).apply(result.getOutput().stream())); + TKit.assertFalse( + getLightCommandLine(result).findAny().isPresent(), + "Check light.exe was not invoked"); + } + } + }); + + if (wxlFileInitializers != null) { + test.addInitializer(cmd -> { + resourceDir = TKit.createTempDirectory("resources"); + + cmd.addArguments("--resource-dir", resourceDir); + + for (var v : wxlFileInitializers) { + v.apply(resourceDir); + } + }); + } + + if (expectedErrorMessage != null || !allWxlFilesValid) { + test.setExpectedExitCode(1); + } + + test.run(); + } + + final private WixFileInitializer wxlFileInitializers[]; + final private String expectedCulture; + final private String expectedErrorMessage; + private Path resourceDir; + + private static class WixFileInitializer { + static WixFileInitializer create(String name, String culture) { + return new WixFileInitializer(name, culture); + } + + static WixFileInitializer createMalformed(String name) { + return new WixFileInitializer(name, null) { + @Override + public void apply(Path root) throws IOException { + TKit.createTextFile(root.resolve(name), List.of( + "", + "")); + } + + @Override + public String toString() { + return String.format("name=%s; malformed xml", name); + } + + @Override + boolean isValid() { + return false; + } + + @Override + TKit.TextStreamVerifier createCmdOutputVerifier(Path root) { + return TKit.assertTextStream(String.format( + "Failed to parse %s file", + root.resolve("b.wxl").toAbsolutePath())); + } + }; + } + + private WixFileInitializer(String name, String culture) { + this.name = name; + this.culture = culture; + } + + void apply(Path root) throws IOException { + TKit.createTextFile(root.resolve(name), List.of( + "", + culture == null ? "" : "")); + } + + TKit.TextStreamVerifier createCmdOutputVerifier(Path root) { + return TKit.assertTextStream( + root.resolve(name).toAbsolutePath().toString()); + } + + boolean isValid() { + return true; + } + + @Override + public String toString() { + return String.format("name=%s; culture=%s", name, culture); + } + + private final String name; + private final String culture; + } +}