diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java index 2d0c78c2474..790d5d877aa 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/DesktopIntegration.java @@ -31,6 +31,7 @@ import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -67,7 +68,7 @@ final class DesktopIntegration extends ShellCustomAction { private static final List REPLACEMENT_STRING_IDS = List.of( COMMANDS_INSTALL, COMMANDS_UNINSTALL, SCRIPTS, COMMON_SCRIPTS); - private DesktopIntegration(BuildEnv env, LinuxPackage pkg, LinuxLauncher launcher) throws IOException { + private DesktopIntegration(BuildEnv env, LinuxPackage pkg, LinuxLauncher launcher) { associations = launcher.fileAssociations().stream().map( LinuxFileAssociation::create).toList(); @@ -88,10 +89,14 @@ final class DesktopIntegration extends ShellCustomAction { // This is additional launcher with explicit `no icon` configuration. withDesktopFile = false; } else { - final Path nullPath = null; - if (curIconResource.get().saveToFile(nullPath) != OverridableResource.Source.DefaultResource) { - // This launcher has custom icon configured. - withDesktopFile = true; + try { + if (curIconResource.get().saveToFile((Path)null) != OverridableResource.Source.DefaultResource) { + // This launcher has custom icon configured. + withDesktopFile = true; + } + } catch (IOException ex) { + // Should never happen as `saveToFile((Path)null)` should not perform any actual I/O operations. + throw new UncheckedIOException(ex); } } @@ -135,13 +140,13 @@ final class DesktopIntegration extends ShellCustomAction { return (LinuxLauncher)v; }).filter(l -> { return toRequest(l.shortcut()).orElse(true); - }).map(toFunction(l -> { + }).map(l -> { return new DesktopIntegration(env, pkg, l); - })).toList(); + }).toList(); } } - static ShellCustomAction create(BuildEnv env, Package pkg) throws IOException { + static ShellCustomAction create(BuildEnv env, Package pkg) { if (pkg.isRuntimeInstaller()) { return ShellCustomAction.nop(REPLACEMENT_STRING_IDS); } diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java index d5b369ba23a..9e2ea63cc32 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebBundler.java @@ -25,362 +25,19 @@ package jdk.jpackage.internal; -import jdk.jpackage.internal.model.LinuxPackage; -import jdk.jpackage.internal.model.PackagerException; -import jdk.jpackage.internal.model.ConfigException; -import jdk.jpackage.internal.model.LinuxDebPackage; -import java.io.IOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; - -import java.nio.file.attribute.PosixFilePermission; -import java.nio.file.attribute.PosixFilePermissions; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import jdk.internal.util.OperatingSystem; -import jdk.jpackage.internal.model.AppImageLayout; -import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; -import static jdk.jpackage.internal.model.StandardPackageType.LINUX_DEB; +import jdk.jpackage.internal.model.LinuxDebPackage; +import jdk.jpackage.internal.model.PackagerException; +import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.internal.util.Result; public class LinuxDebBundler extends LinuxPackageBundler { - private static final String TOOL_DPKG_DEB = "dpkg-deb"; - private static final String TOOL_DPKG = "dpkg"; - private static final String TOOL_FAKEROOT = "fakeroot"; - public LinuxDebBundler() { super(LinuxFromParams.DEB_PACKAGE); } - @Override - protected void doValidate(BuildEnv env, LinuxPackage pkg) throws ConfigException { - - // Show warning if license file is missing - if (pkg.licenseFile().isEmpty()) { - Log.verbose(I18N.getString("message.debs-like-licenses")); - } - } - - @Override - protected List getToolValidators() { - return Stream.of(TOOL_DPKG_DEB, TOOL_DPKG, TOOL_FAKEROOT).map( - ToolValidator::new).toList(); - } - - @Override - protected void createConfigFiles(Map replacementData, - BuildEnv env, LinuxPackage pkg) throws IOException { - prepareProjectConfig(replacementData, env, pkg); - adjustPermissionsRecursive(env.appImageDir()); - } - - @Override - protected Path buildPackageBundle(BuildEnv env, LinuxPackage pkg, - Path outputParentDir) throws PackagerException, IOException { - return buildDeb(env, pkg, outputParentDir); - } - - private static final Pattern PACKAGE_NAME_REGEX = Pattern.compile("^(^\\S+):"); - - @Override - protected void initLibProvidersLookup(LibProvidersLookup libProvidersLookup) { - - libProvidersLookup.setPackageLookup(file -> { - Path realPath = file.toRealPath(); - - try { - // Try the real path first as it works better on newer Ubuntu versions - return findProvidingPackages(realPath); - } catch (IOException ex) { - // Try the default path if differ - if (!realPath.toString().equals(file.toString())) { - return findProvidingPackages(file); - } else { - throw ex; - } - } - }); - } - - private static Stream findProvidingPackages(Path file) throws IOException { - // - // `dpkg -S` command does glob pattern lookup. If not the absolute path - // to the file is specified it might return mltiple package names. - // Even for full paths multiple package names can be returned as - // it is OK for multiple packages to provide the same file. `/opt` - // directory is such an example. So we have to deal with multiple - // packages per file situation. - // - // E.g.: `dpkg -S libc.so.6` command reports three packages: - // libc6-x32: /libx32/libc.so.6 - // libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6 - // libc6-i386: /lib32/libc.so.6 - // `:amd64` is architecture suffix and can (should) be dropped. - // Still need to decide what package to choose from three. - // libc6-x32 and libc6-i386 both depend on libc6: - // $ dpkg -s libc6-x32 - // Package: libc6-x32 - // Status: install ok installed - // Priority: optional - // Section: libs - // Installed-Size: 10840 - // Maintainer: Ubuntu Developers - // Architecture: amd64 - // Source: glibc - // Version: 2.23-0ubuntu10 - // Depends: libc6 (= 2.23-0ubuntu10) - // - // We can dive into tracking dependencies, but this would be overly - // complicated. - // - // For simplicity lets consider the following rules: - // 1. If there is one item in `dpkg -S` output, accept it. - // 2. If there are multiple items in `dpkg -S` output and there is at - // least one item with the default arch suffix (DEB_ARCH), - // accept only these items. - // 3. If there are multiple items in `dpkg -S` output and there are - // no with the default arch suffix (DEB_ARCH), accept all items. - // So lets use this heuristics: don't accept packages for whom - // `dpkg -p` command fails. - // 4. Arch suffix should be stripped from accepted package names. - // - - Set archPackages = new HashSet<>(); - Set otherPackages = new HashSet<>(); - - var debArch = LinuxPackageArch.getValue(LINUX_DEB); - - Executor.of(TOOL_DPKG, "-S", file.toString()) - .saveOutput(true).executeExpectSuccess() - .getOutput().forEach(line -> { - Matcher matcher = PACKAGE_NAME_REGEX.matcher(line); - if (matcher.find()) { - String name = matcher.group(1); - if (name.endsWith(":" + debArch)) { - // Strip arch suffix - name = name.substring(0, - name.length() - (debArch.length() + 1)); - archPackages.add(name); - } else { - otherPackages.add(name); - } - } - }); - - if (!archPackages.isEmpty()) { - return archPackages.stream(); - } - return otherPackages.stream(); - } - - @Override - protected List verifyOutputBundle(BuildEnv env, LinuxPackage pkg, - Path packageBundle) { - List errors = new ArrayList<>(); - - String controlFileName = "control"; - - List properties = List.of( - new PackageProperty("Package", pkg.packageName(), - "APPLICATION_PACKAGE", controlFileName), - new PackageProperty("Version", ((LinuxDebPackage)pkg).versionWithRelease(), - "APPLICATION_VERSION_WITH_RELEASE", - controlFileName), - new PackageProperty("Architecture", pkg.arch(), "APPLICATION_ARCH", controlFileName)); - - List cmdline = new ArrayList<>(List.of(TOOL_DPKG_DEB, "-f", - packageBundle.toString())); - properties.forEach(property -> cmdline.add(property.name)); - try { - Map actualValues = Executor.of(cmdline.toArray(String[]::new)) - .saveOutput(true) - .executeExpectSuccess() - .getOutput().stream() - .map(line -> line.split(":\\s+", 2)) - .collect(Collectors.toMap( - components -> components[0], - components -> components[1])); - properties.forEach(property -> errors.add(property.verifyValue( - actualValues.get(property.name)))); - } catch (IOException ex) { - // Ignore error as it is not critical. Just report it. - Log.verbose(ex); - } - - return errors; - } - - /* - * set permissions with a string like "rwxr-xr-x" - * - * This cannot be directly backport to 22u which is built with 1.6 - */ - private void setPermissions(Path file, String permissions) { - Set filePermissions = - PosixFilePermissions.fromString(permissions); - try { - if (Files.exists(file)) { - Files.setPosixFilePermissions(file, filePermissions); - } - } catch (IOException ex) { - Log.error(ex.getMessage()); - Log.verbose(ex); - } - - } - - public static boolean isDebian() { - // we are just going to run "dpkg -s coreutils" and assume Debian - // or deritive if no error is returned. - try { - Executor.of(TOOL_DPKG, "-s", "coreutils").executeExpectSuccess(); - return true; - } catch (IOException e) { - // just fall thru - } - return false; - } - - private void adjustPermissionsRecursive(Path dir) throws IOException { - Files.walkFileTree(dir, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, - BasicFileAttributes attrs) - throws IOException { - if (file.endsWith(".so") || !Files.isExecutable(file)) { - setPermissions(file, "rw-r--r--"); - } else if (Files.isExecutable(file)) { - setPermissions(file, "rwxr-xr-x"); - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException e) - throws IOException { - if (e == null) { - setPermissions(dir, "rwxr-xr-x"); - return FileVisitResult.CONTINUE; - } else { - // directory iteration failed - throw e; - } - } - }); - } - - private class DebianFile { - - DebianFile(Path dstFilePath, String comment) { - this.dstFilePath = dstFilePath; - this.comment = comment; - } - - DebianFile setExecutable() { - permissions = "rwxr-xr-x"; - return this; - } - - void create(Map data, Function resourceFactory) - throws IOException { - resourceFactory.apply("template." + dstFilePath.getFileName().toString()) - .setCategory(I18N.getString(comment)) - .setSubstitutionData(data) - .saveToFile(dstFilePath); - if (permissions != null) { - setPermissions(dstFilePath, permissions); - } - } - - private final Path dstFilePath; - private final String comment; - private String permissions; - } - - private void prepareProjectConfig(Map data, BuildEnv env, LinuxPackage pkg) throws IOException { - - Path configDir = env.appImageDir().resolve("DEBIAN"); - List debianFiles = new ArrayList<>(); - debianFiles.add(new DebianFile( - configDir.resolve("control"), - "resource.deb-control-file")); - debianFiles.add(new DebianFile( - configDir.resolve("preinst"), - "resource.deb-preinstall-script").setExecutable()); - debianFiles.add(new DebianFile( - configDir.resolve("prerm"), - "resource.deb-prerm-script").setExecutable()); - debianFiles.add(new DebianFile( - configDir.resolve("postinst"), - "resource.deb-postinstall-script").setExecutable()); - debianFiles.add(new DebianFile( - configDir.resolve("postrm"), - "resource.deb-postrm-script").setExecutable()); - - ((LinuxDebPackage)pkg).relativeCopyrightFilePath().ifPresent(copyrightFile -> { - debianFiles.add(new DebianFile(env.appImageDir().resolve(copyrightFile), - "resource.copyright-file")); - }); - - for (DebianFile debianFile : debianFiles) { - debianFile.create(data, env::createResource); - } - } - - @Override - protected Map createReplacementData(BuildEnv env, LinuxPackage pkg) throws IOException { - Map data = new HashMap<>(); - - String licenseText = pkg.licenseFile().map(toFunction(Files::readString)).orElse("Unknown"); - - data.put("APPLICATION_MAINTAINER", ((LinuxDebPackage) pkg).maintainer()); - data.put("APPLICATION_SECTION", pkg.category().orElseThrow()); - data.put("APPLICATION_COPYRIGHT", pkg.app().copyright()); - data.put("APPLICATION_LICENSE_TEXT", licenseText); - data.put("APPLICATION_ARCH", pkg.arch()); - data.put("APPLICATION_INSTALLED_SIZE", Long.toString( - AppImageLayout.toPathGroup(env.appImageLayout()).sizeInBytes() >> 10)); - data.put("APPLICATION_HOMEPAGE", pkg.aboutURL().map( - value -> "Homepage: " + value).orElse("")); - data.put("APPLICATION_VERSION_WITH_RELEASE", ((LinuxDebPackage) pkg).versionWithRelease()); - - return data; - } - - private Path buildDeb(BuildEnv env, LinuxPackage pkg, Path outdir) throws IOException { - Path outFile = outdir.resolve(pkg.packageFileNameWithSuffix()); - Log.verbose(I18N.format("message.outputting-to-location", outFile.toAbsolutePath())); - - List cmdline = new ArrayList<>(); - cmdline.addAll(List.of(TOOL_FAKEROOT, TOOL_DPKG_DEB)); - if (Log.isVerbose()) { - cmdline.add("--verbose"); - } - cmdline.addAll(List.of("-b", env.appImageDir().toString(), - outFile.toAbsolutePath().toString())); - - // run dpkg - RetryExecutor.retryOnKnownErrorMessage( - "semop(1): encountered an error: Invalid argument").execute( - cmdline.toArray(String[]::new)); - - Log.verbose(I18N.format("message.output-to-location", outFile.toAbsolutePath())); - - return outFile; - } - @Override public String getName() { return I18N.getString("deb.bundler.name"); @@ -392,12 +49,28 @@ public class LinuxDebBundler extends LinuxPackageBundler { } @Override - public boolean supported(boolean runtimeInstaller) { - return OperatingSystem.isLinux() && (new ToolValidator(TOOL_DPKG_DEB).validate() == null); + public Path execute(Map params, Path outputParentDir) throws PackagerException { + + return Packager.build().outputDir(outputParentDir) + .pkg(LinuxFromParams.DEB_PACKAGE.fetchFrom(params)) + .env(BuildEnvFromParams.BUILD_ENV.fetchFrom(params)) + .pipelineBuilderMutatorFactory((env, pkg, outputDir) -> { + return new LinuxDebPackager(env, pkg, outputDir, sysEnv.orElseThrow()); + }).execute(LinuxPackagingPipeline.build()); + } + + @Override + protected Result sysEnv() { + return sysEnv; } @Override public boolean isDefault() { - return isDebian(); + return sysEnv.value() + .map(LinuxSystemEnvironment::nativePackageType) + .map(StandardPackageType.LINUX_DEB::equals) + .orElse(false); } + + private final Result sysEnv = LinuxDebSystemEnvironment.create(SYS_ENV); } diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java new file mode 100644 index 00000000000..64a0368e9a0 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebPackager.java @@ -0,0 +1,344 @@ +/* + * 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.StandardPackageType.LINUX_DEB; +import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; +import jdk.jpackage.internal.PackagingPipeline.PrimaryTaskID; +import jdk.jpackage.internal.model.AppImageLayout; +import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.LinuxDebPackage; + +final class LinuxDebPackager extends LinuxPackager { + + LinuxDebPackager(BuildEnv env, LinuxDebPackage pkg, Path outputDir, LinuxDebSystemEnvironment sysEnv) { + super(env, pkg, outputDir, sysEnv); + this.sysEnv = Objects.requireNonNull(sysEnv); + } + + @Override + protected void createConfigFiles(Map replacementData) throws IOException { + prepareProjectConfig(replacementData); + adjustPermissionsRecursive(); + } + + @Override + protected void initLibProvidersLookup(LibProvidersLookup libProvidersLookup) { + + libProvidersLookup.setPackageLookup(file -> { + Path realPath = file.toRealPath(); + + try { + // Try the real path first as it works better on newer Ubuntu versions + return findProvidingPackages(realPath, sysEnv.dpkg()); + } catch (IOException ex) { + // Try the default path if differ + if (!realPath.equals(file)) { + return findProvidingPackages(file, sysEnv.dpkg()); + } else { + throw ex; + } + } + }); + } + + @Override + protected List findErrorsInOutputPackage() throws IOException { + List errors = new ArrayList<>(); + + var controlFileName = "control"; + + List properties = List.of( + new PackageProperty("Package", pkg.packageName(), + "APPLICATION_PACKAGE", controlFileName), + new PackageProperty("Version", pkg.versionWithRelease(), + "APPLICATION_VERSION_WITH_RELEASE", + controlFileName), + new PackageProperty("Architecture", pkg.arch(), "APPLICATION_ARCH", controlFileName)); + + List cmdline = new ArrayList<>(List.of( + sysEnv.dpkgdeb().toString(), "-f", outputPackageFile().toString())); + + properties.forEach(property -> cmdline.add(property.name)); + + Map actualValues = Executor.of(cmdline.toArray(String[]::new)) + .saveOutput(true) + .executeExpectSuccess() + .getOutput().stream() + .map(line -> line.split(":\\s+", 2)) + .collect(Collectors.toMap( + components -> components[0], + components -> components[1])); + + for (var property : properties) { + Optional.ofNullable(property.verifyValue(actualValues.get(property.name))).ifPresent(errors::add); + } + + return errors; + } + + @Override + protected Map createReplacementData() throws IOException { + Map data = new HashMap<>(); + + String licenseText = pkg.licenseFile().map(toFunction(Files::readString)).orElse("Unknown"); + + data.put("APPLICATION_MAINTAINER", pkg.maintainer()); + data.put("APPLICATION_SECTION", pkg.category().orElseThrow()); + data.put("APPLICATION_COPYRIGHT", pkg.app().copyright()); + data.put("APPLICATION_LICENSE_TEXT", licenseText); + data.put("APPLICATION_ARCH", pkg.arch()); + data.put("APPLICATION_INSTALLED_SIZE", Long.toString( + AppImageLayout.toPathGroup(env.appImageLayout()).sizeInBytes() >> 10)); + data.put("APPLICATION_HOMEPAGE", pkg.aboutURL().map( + value -> "Homepage: " + value).orElse("")); + data.put("APPLICATION_VERSION_WITH_RELEASE", pkg.versionWithRelease()); + + return data; + } + + @Override + protected void buildPackage() throws IOException { + + Path debFile = outputPackageFile(); + + Log.verbose(I18N.format("message.outputting-to-location", debFile.toAbsolutePath())); + + List cmdline = new ArrayList<>(); + Stream.of(sysEnv.fakeroot(), sysEnv.dpkgdeb()).map(Path::toString).forEach(cmdline::add); + if (Log.isVerbose()) { + cmdline.add("--verbose"); + } + cmdline.addAll(List.of("-b", env.appImageDir().toString(), debFile.toAbsolutePath().toString())); + + // run dpkg + RetryExecutor.retryOnKnownErrorMessage( + "semop(1): encountered an error: Invalid argument").execute( + cmdline.toArray(String[]::new)); + + Log.verbose(I18N.format("message.output-to-location", debFile.toAbsolutePath())); + } + + @Override + public void accept(PackagingPipeline.Builder pipelineBuilder) { + super.accept(pipelineBuilder); + + // Build deb config files after app image contents are ready because + // it calculates the size of the image and saves the value in one of the config files. + pipelineBuilder.configuredTasks().filter(task -> { + return PackageTaskID.CREATE_CONFIG_FILES.equals(task.task()); + }).findFirst().orElseThrow() + .addDependencies(PrimaryTaskID.BUILD_APPLICATION_IMAGE, PrimaryTaskID.COPY_APP_IMAGE) + .add(); + } + + private void adjustPermissionsRecursive() throws IOException { + Files.walkFileTree(env.appImageDir(), new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (file.endsWith(".so") || !Files.isExecutable(file)) { + Files.setPosixFilePermissions(file, SO_PERMISSIONS); + } else if (Files.isExecutable(file)) { + Files.setPosixFilePermissions(file, EXECUTABLE_PERMISSIONS); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException { + if (e == null) { + Files.setPosixFilePermissions(dir, FOLDER_PERMISSIONS); + return FileVisitResult.CONTINUE; + } else { + // directory iteration failed + throw e; + } + } + }); + } + + private void prepareProjectConfig(Map data) throws IOException { + + Path configDir = env.appImageDir().resolve("DEBIAN"); + List debianFiles = new ArrayList<>(); + debianFiles.add(new DebianFile( + configDir.resolve("control"), + "resource.deb-control-file")); + debianFiles.add(new DebianFile( + configDir.resolve("preinst"), + "resource.deb-preinstall-script").setExecutable()); + debianFiles.add(new DebianFile( + configDir.resolve("prerm"), + "resource.deb-prerm-script").setExecutable()); + debianFiles.add(new DebianFile( + configDir.resolve("postinst"), + "resource.deb-postinstall-script").setExecutable()); + debianFiles.add(new DebianFile( + configDir.resolve("postrm"), + "resource.deb-postrm-script").setExecutable()); + + pkg.relativeCopyrightFilePath().ifPresent(copyrightFile -> { + debianFiles.add(new DebianFile(env.appImageDir().resolve(copyrightFile), + "resource.copyright-file")); + }); + + for (DebianFile debianFile : debianFiles) { + debianFile.create(data, env::createResource); + } + } + + private static Stream findProvidingPackages(Path file, Path dpkg) throws IOException { + // + // `dpkg -S` command does glob pattern lookup. If not the absolute path + // to the file is specified it might return mltiple package names. + // Even for full paths multiple package names can be returned as + // it is OK for multiple packages to provide the same file. `/opt` + // directory is such an example. So we have to deal with multiple + // packages per file situation. + // + // E.g.: `dpkg -S libc.so.6` command reports three packages: + // libc6-x32: /libx32/libc.so.6 + // libc6:amd64: /lib/x86_64-linux-gnu/libc.so.6 + // libc6-i386: /lib32/libc.so.6 + // `:amd64` is architecture suffix and can (should) be dropped. + // Still need to decide what package to choose from three. + // libc6-x32 and libc6-i386 both depend on libc6: + // $ dpkg -s libc6-x32 + // Package: libc6-x32 + // Status: install ok installed + // Priority: optional + // Section: libs + // Installed-Size: 10840 + // Maintainer: Ubuntu Developers + // Architecture: amd64 + // Source: glibc + // Version: 2.23-0ubuntu10 + // Depends: libc6 (= 2.23-0ubuntu10) + // + // We can dive into tracking dependencies, but this would be overly + // complicated. + // + // For simplicity lets consider the following rules: + // 1. If there is one item in `dpkg -S` output, accept it. + // 2. If there are multiple items in `dpkg -S` output and there is at + // least one item with the default arch suffix (DEB_ARCH), + // accept only these items. + // 3. If there are multiple items in `dpkg -S` output and there are + // no with the default arch suffix (DEB_ARCH), accept all items. + // So lets use this heuristics: don't accept packages for whom + // `dpkg -p` command fails. + // 4. Arch suffix should be stripped from accepted package names. + // + + Set archPackages = new HashSet<>(); + Set otherPackages = new HashSet<>(); + + var debArch = LinuxPackageArch.getValue(LINUX_DEB); + + Executor.of(dpkg.toString(), "-S", file.toString()) + .saveOutput(true).executeExpectSuccess() + .getOutput().forEach(line -> { + Matcher matcher = PACKAGE_NAME_REGEX.matcher(line); + if (matcher.find()) { + String name = matcher.group(1); + if (name.endsWith(":" + debArch)) { + // Strip arch suffix + name = name.substring(0, + name.length() - (debArch.length() + 1)); + archPackages.add(name); + } else { + otherPackages.add(name); + } + } + }); + + if (!archPackages.isEmpty()) { + return archPackages.stream(); + } + return otherPackages.stream(); + } + + + private static final class DebianFile { + + DebianFile(Path dstFilePath, String comment) { + this.dstFilePath = Objects.requireNonNull(dstFilePath); + this.comment = Objects.requireNonNull(comment); + } + + DebianFile setExecutable() { + permissions = EXECUTABLE_PERMISSIONS; + return this; + } + + void create(Map data, Function resourceFactory) + throws IOException { + resourceFactory.apply("template." + dstFilePath.getFileName().toString()) + .setCategory(I18N.getString(comment)) + .setSubstitutionData(data) + .saveToFile(dstFilePath); + if (permissions != null) { + Files.setPosixFilePermissions(dstFilePath, permissions); + } + } + + private final Path dstFilePath; + private final String comment; + private Set permissions; + } + + + private final LinuxDebSystemEnvironment sysEnv; + + private static final Pattern PACKAGE_NAME_REGEX = Pattern.compile("^(^\\S+):"); + + private static final Set EXECUTABLE_PERMISSIONS = PosixFilePermissions.fromString("rwxr-xr-x"); + private static final Set FOLDER_PERMISSIONS = PosixFilePermissions.fromString("rwxr-xr-x"); + private static final Set SO_PERMISSIONS = PosixFilePermissions.fromString("rw-r--r--"); +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java new file mode 100644 index 00000000000..5b5decb7a67 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironment.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 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.LinuxSystemEnvironment.mixin; + +import jdk.jpackage.internal.util.Result; + +public interface LinuxDebSystemEnvironment extends LinuxSystemEnvironment, LinuxDebSystemEnvironmentMixin { + + static Result create(Result base) { + return mixin(LinuxDebSystemEnvironment.class, base, LinuxDebSystemEnvironmentMixin::create); + } +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java new file mode 100644 index 00000000000..8688327b353 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxDebSystemEnvironmentMixin.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 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 java.nio.file.Path; +import java.util.Objects; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.Result; + +public interface LinuxDebSystemEnvironmentMixin { + Path dpkg(); + Path dpkgdeb(); + Path fakeroot(); + + record Stub(Path dpkg, Path dpkgdeb, Path fakeroot) implements LinuxDebSystemEnvironmentMixin { + } + + static Result create() { + final var errors = Stream.of(Internal.TOOL_DPKG_DEB, Internal.TOOL_DPKG, Internal.TOOL_FAKEROOT) + .map(ToolValidator::new) + .map(ToolValidator::validate) + .filter(Objects::nonNull) + .toList(); + if (errors.isEmpty()) { + return Result.ofValue(new Stub(Internal.TOOL_DPKG, Internal.TOOL_DPKG_DEB, Internal.TOOL_FAKEROOT)); + } else { + return Result.ofErrors(errors); + } + } + + static final class Internal { + + private static final Path TOOL_DPKG_DEB = Path.of("dpkg-deb"); + private static final Path TOOL_DPKG = Path.of("dpkg"); + private static final Path TOOL_FAKEROOT = Path.of("fakeroot"); + } +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromParams.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromParams.java index ced77b1aa68..a8d109220dc 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromParams.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxFromParams.java @@ -39,9 +39,10 @@ import java.io.IOException; import java.util.Map; import jdk.jpackage.internal.model.ConfigException; import jdk.jpackage.internal.model.LinuxApplication; +import jdk.jpackage.internal.model.LinuxDebPackage; import jdk.jpackage.internal.model.LinuxLauncher; import jdk.jpackage.internal.model.LinuxLauncherMixin; -import jdk.jpackage.internal.model.LinuxPackage; +import jdk.jpackage.internal.model.LinuxRpmPackage; import jdk.jpackage.internal.model.StandardPackageType; final class LinuxFromParams { @@ -76,7 +77,7 @@ final class LinuxFromParams { return pkgBuilder; } - private static LinuxPackage createLinuxRpmPackage( + private static LinuxRpmPackage createLinuxRpmPackage( Map params) throws ConfigException, IOException { final var superPkgBuilder = createLinuxPackageBuilder(params, LINUX_RPM); @@ -88,7 +89,7 @@ final class LinuxFromParams { return pkgBuilder.create(); } - private static LinuxPackage createLinuxDebPackage( + private static LinuxDebPackage createLinuxDebPackage( Map params) throws ConfigException, IOException { final var superPkgBuilder = createLinuxPackageBuilder(params, LINUX_DEB); @@ -97,16 +98,23 @@ final class LinuxFromParams { MAINTAINER_EMAIL.copyInto(params, pkgBuilder::maintainerEmail); - return pkgBuilder.create(); + final var pkg = pkgBuilder.create(); + + // Show warning if license file is missing + if (pkg.licenseFile().isEmpty()) { + Log.verbose(I18N.getString("message.debs-like-licenses")); + } + + return pkg; } static final BundlerParamInfo APPLICATION = createApplicationBundlerParam( LinuxFromParams::createLinuxApplication); - static final BundlerParamInfo RPM_PACKAGE = createPackageBundlerParam( + static final BundlerParamInfo RPM_PACKAGE = createPackageBundlerParam( LinuxFromParams::createLinuxRpmPackage); - static final BundlerParamInfo DEB_PACKAGE = createPackageBundlerParam( + static final BundlerParamInfo DEB_PACKAGE = createPackageBundlerParam( LinuxFromParams::createLinuxDebPackage); private static final BundlerParamInfo LINUX_SHORTCUT_HINT = createStringBundlerParam( diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java index 742f87b3a9f..b14404d67b1 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxLaunchersAsServices.java @@ -38,7 +38,7 @@ import jdk.jpackage.internal.model.Package; */ public final class LinuxLaunchersAsServices extends UnixLaunchersAsServices { - private LinuxLaunchersAsServices(BuildEnv env, Package pkg) throws IOException { + private LinuxLaunchersAsServices(BuildEnv env, Package pkg) { super(env.appImageDir(), pkg.app(), REQUIRED_PACKAGES, launcher -> { return new LauncherImpl(env, pkg, launcher); }); @@ -58,7 +58,7 @@ public final class LinuxLaunchersAsServices extends UnixLaunchersAsServices { return data; } - static ShellCustomAction create(BuildEnv env, Package pkg) throws IOException { + static ShellCustomAction create(BuildEnv env, Package pkg) { if (pkg.isRuntimeInstaller()) { return ShellCustomAction.nop(LINUX_REPLACEMENT_STRING_IDS); } diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java index 2f3e6da1e69..1f674c0be11 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackageBundler.java @@ -24,33 +24,16 @@ */ package jdk.jpackage.internal; -import java.io.IOException; -import java.nio.file.Path; -import java.text.MessageFormat; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Predicate; -import java.util.stream.Stream; -import jdk.jpackage.internal.PackagingPipeline.PackageBuildEnv; -import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; -import jdk.jpackage.internal.PackagingPipeline.PrimaryTaskID; -import jdk.jpackage.internal.model.AppImageLayout; import jdk.jpackage.internal.model.ConfigException; -import jdk.jpackage.internal.model.LinuxDebPackage; import jdk.jpackage.internal.model.LinuxPackage; -import jdk.jpackage.internal.model.Package; -import jdk.jpackage.internal.model.PackagerException; +import jdk.jpackage.internal.util.Result; abstract class LinuxPackageBundler extends AbstractBundler { LinuxPackageBundler(BundlerParamInfo pkgParam) { - this.pkgParam = pkgParam; - customActions = List.of(new CustomActionInstance( - DesktopIntegration::create), new CustomActionInstance( - LinuxLaunchersAsServices::create)); + this.pkgParam = Objects.requireNonNull(pkgParam); } @Override @@ -58,39 +41,32 @@ abstract class LinuxPackageBundler extends AbstractBundler { throws ConfigException { // Order is important! - LinuxPackage pkg = pkgParam.fetchFrom(params); - var env = BuildEnvFromParams.BUILD_ENV.fetchFrom(params); + pkgParam.fetchFrom(params); + BuildEnvFromParams.BUILD_ENV.fetchFrom(params); - for (var validator: getToolValidators()) { - ConfigException ex = validator.validate(); - if (ex != null) { - throw ex; - } + LinuxSystemEnvironment sysEnv; + try { + sysEnv = sysEnv().orElseThrow(); + } catch (RuntimeException ex) { + throw ConfigException.rethrowConfigException(ex); } if (!isDefault()) { - withFindNeededPackages = false; - Log.verbose(MessageFormat.format(I18N.getString( - "message.not-default-bundler-no-dependencies-lookup"), + Log.verbose(I18N.format( + "message.not-default-bundler-no-dependencies-lookup", getName())); - } else { - withFindNeededPackages = LibProvidersLookup.supported(); - if (!withFindNeededPackages) { - final String advice; - if ("deb".equals(getID())) { - advice = "message.deb-ldd-not-available.advice"; - } else { - advice = "message.rpm-ldd-not-available.advice"; - } - // Let user know package dependencies will not be generated. - Log.error(String.format("%s\n%s", I18N.getString( - "message.ldd-not-available"), I18N.getString(advice))); + } else if (!sysEnv.soLookupAvailable()) { + final String advice; + if ("deb".equals(getID())) { + advice = "message.deb-ldd-not-available.advice"; + } else { + advice = "message.rpm-ldd-not-available.advice"; } + // Let user know package dependencies will not be generated. + Log.error(String.format("%s\n%s", I18N.getString( + "message.ldd-not-available"), I18N.getString(advice))); } - // Packaging specific validation - doValidate(env, pkg); - return true; } @@ -100,148 +76,13 @@ abstract class LinuxPackageBundler extends AbstractBundler { } @Override - public final Path execute(Map params, - Path outputParentDir) throws PackagerException { - IOUtils.writableOutputDir(outputParentDir); - - // Order is important! - final LinuxPackage pkg = pkgParam.fetchFrom(params); - final var env = BuildEnvFromParams.BUILD_ENV.fetchFrom(params); - - final var pipelineBuilder = LinuxPackagingPipeline.build() - .excludeDirFromCopying(outputParentDir) - .task(PackageTaskID.CREATE_PACKAGE_FILE) - .packageAction(this::buildPackage) - .add(); - - final var createConfigFilesTaskBuilder = pipelineBuilder - .task(PackageTaskID.CREATE_CONFIG_FILES) - .packageAction(this::buildConfigFiles); - - if (pkg instanceof LinuxDebPackage) { - // Build deb config files after app image contents are ready because - // it calculates the size of the image and saves the value in one of the config files. - createConfigFilesTaskBuilder.addDependencies(PrimaryTaskID.BUILD_APPLICATION_IMAGE, PrimaryTaskID.COPY_APP_IMAGE); - } - - createConfigFilesTaskBuilder.add(); - - pipelineBuilder.create().execute(env, pkg, outputParentDir); - - return outputParentDir.resolve(pkg.packageFileNameWithSuffix()).toAbsolutePath(); + public boolean supported(boolean runtimeInstaller) { + return sysEnv().hasValue(); } - private void buildConfigFiles(PackageBuildEnv env) throws PackagerException, IOException { - for (var ca : customActions) { - ca.init(env.env(), env.pkg()); - } - - Map data = createDefaultReplacementData(env.env(), env.pkg()); - - for (var ca : customActions) { - ShellCustomAction.mergeReplacementData(data, ca.instance.create()); - } - - data.putAll(createReplacementData(env.env(), env.pkg())); - - createConfigFiles(Collections.unmodifiableMap(data), env.env(), env.pkg()); - } - - private void buildPackage(PackageBuildEnv env) throws PackagerException, IOException { - Path packageBundle = buildPackageBundle(env.env(), env.pkg(), env.outputDir()); - - verifyOutputBundle(env.env(), env.pkg(), packageBundle).stream() - .filter(Objects::nonNull) - .forEachOrdered(ex -> { - Log.verbose(ex.getLocalizedMessage()); - Log.verbose(ex.getAdvice()); - }); - } - - private List getListOfNeededPackages(BuildEnv env) throws IOException { - - final List caPackages = customActions.stream() - .map(ca -> ca.instance) - .map(ShellCustomAction::requiredPackages) - .flatMap(List::stream).toList(); - - final List neededLibPackages; - if (withFindNeededPackages) { - LibProvidersLookup lookup = new LibProvidersLookup(); - initLibProvidersLookup(lookup); - - neededLibPackages = lookup.execute(env.appImageDir()); - } else { - neededLibPackages = Collections.emptyList(); - Log.info(I18N.getString("warning.foreign-app-image")); - } - - // Merge all package lists together. - // Filter out empty names, sort and remove duplicates. - List result = Stream.of(caPackages, neededLibPackages).flatMap( - List::stream).filter(Predicate.not(String::isEmpty)).sorted().distinct().toList(); - - Log.verbose(String.format("Required packages: %s", result)); - - return result; - } - - private Map createDefaultReplacementData(BuildEnv env, LinuxPackage pkg) throws IOException { - Map data = new HashMap<>(); - - data.put("APPLICATION_PACKAGE", pkg.packageName()); - data.put("APPLICATION_VENDOR", pkg.app().vendor()); - data.put("APPLICATION_VERSION", pkg.version()); - data.put("APPLICATION_DESCRIPTION", pkg.description()); - - String defaultDeps = String.join(", ", getListOfNeededPackages(env)); - String customDeps = pkg.additionalDependencies().orElse(""); - if (!customDeps.isEmpty() && !defaultDeps.isEmpty()) { - customDeps = ", " + customDeps; - } - data.put("PACKAGE_DEFAULT_DEPENDENCIES", defaultDeps); - data.put("PACKAGE_CUSTOM_DEPENDENCIES", customDeps); - - return data; - } - - protected abstract List verifyOutputBundle( - BuildEnv env, LinuxPackage pkg, Path packageBundle); - - protected abstract void initLibProvidersLookup(LibProvidersLookup libProvidersLookup); - - protected abstract List getToolValidators(); - - protected abstract void doValidate(BuildEnv env, LinuxPackage pkg) - throws ConfigException; - - protected abstract Map createReplacementData( - BuildEnv env, LinuxPackage pkg) throws IOException; - - protected abstract void createConfigFiles( - Map replacementData, - BuildEnv env, LinuxPackage pkg) throws IOException; - - protected abstract Path buildPackageBundle( - BuildEnv env, LinuxPackage pkg, Path outputParentDir) throws - PackagerException, IOException; + protected abstract Result sysEnv(); private final BundlerParamInfo pkgParam; - private boolean withFindNeededPackages; - private final List customActions; - private static final class CustomActionInstance { - - CustomActionInstance(ShellCustomActionFactory factory) { - this.factory = factory; - } - - void init(BuildEnv env, Package pkg) throws IOException { - instance = factory.create(env, pkg); - Objects.requireNonNull(instance); - } - - private final ShellCustomActionFactory factory; - ShellCustomAction instance; - } + static final Result SYS_ENV = LinuxSystemEnvironment.create(); } diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackager.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackager.java new file mode 100644 index 00000000000..806592904d1 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxPackager.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 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 java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Stream; +import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; +import jdk.jpackage.internal.PackagingPipeline.PrimaryTaskID; +import jdk.jpackage.internal.PackagingPipeline.TaskID; +import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.LinuxPackage; +import jdk.jpackage.internal.model.PackagerException; + +abstract class LinuxPackager implements Consumer { + + LinuxPackager(BuildEnv env, T pkg, Path outputDir, LinuxSystemEnvironment sysEnv) { + this.env = Objects.requireNonNull(env); + this.pkg = Objects.requireNonNull(pkg); + this.outputDir = Objects.requireNonNull(outputDir); + this.withRequiredPackagesLookup = sysEnv.soLookupAvailable() && sysEnv.nativePackageType().equals(pkg.type()); + + customActions = List.of( + DesktopIntegration.create(env, pkg), + LinuxLaunchersAsServices.create(env, pkg)); + } + + enum LinuxPackageTaskID implements TaskID { + INIT_REQUIRED_PACKAGES, + VERIFY_PACKAGE + } + + @Override + public void accept(PackagingPipeline.Builder pipelineBuilder) { + pipelineBuilder.excludeDirFromCopying(outputDir) + .task(PackageTaskID.CREATE_CONFIG_FILES) + .action(this::buildConfigFiles) + .add() + .task(LinuxPackageTaskID.INIT_REQUIRED_PACKAGES) + .addDependencies(PrimaryTaskID.BUILD_APPLICATION_IMAGE, PrimaryTaskID.COPY_APP_IMAGE) + .addDependent(PackageTaskID.CREATE_CONFIG_FILES) + .action(this::initRequiredPackages) + .add() + .task(LinuxPackageTaskID.VERIFY_PACKAGE) + .addDependencies(PackageTaskID.CREATE_PACKAGE_FILE) + .addDependent(PrimaryTaskID.PACKAGE) + .action(this::verifyOutputPackage) + .add() + .task(PackageTaskID.CREATE_PACKAGE_FILE) + .action(this::buildPackage) + .add(); + } + + protected final Path outputPackageFile() { + return outputDir.resolve(pkg.packageFileNameWithSuffix()); + } + + protected abstract void buildPackage() throws IOException; + + protected abstract List findErrorsInOutputPackage() throws IOException; + + protected abstract void createConfigFiles(Map replacementData) throws IOException; + + protected abstract Map createReplacementData() throws IOException; + + protected abstract void initLibProvidersLookup(LibProvidersLookup libProvidersLookup); + + private void buildConfigFiles() throws PackagerException, IOException { + + final var data = createDefaultReplacementData(); + + for (var ca : customActions) { + ShellCustomAction.mergeReplacementData(data, ca.create()); + } + + data.putAll(createReplacementData()); + + createConfigFiles(Collections.unmodifiableMap(data)); + } + + private Map createDefaultReplacementData() { + Map data = new HashMap<>(); + + data.put("APPLICATION_PACKAGE", pkg.packageName()); + data.put("APPLICATION_VENDOR", pkg.app().vendor()); + data.put("APPLICATION_VERSION", pkg.version()); + data.put("APPLICATION_DESCRIPTION", pkg.description()); + + String defaultDeps = String.join(", ", requiredPackages); + String customDeps = pkg.additionalDependencies().orElse(""); + if (!customDeps.isEmpty() && !defaultDeps.isEmpty()) { + customDeps = ", " + customDeps; + } + data.put("PACKAGE_DEFAULT_DEPENDENCIES", defaultDeps); + data.put("PACKAGE_CUSTOM_DEPENDENCIES", customDeps); + + return data; + } + + private void initRequiredPackages() throws IOException { + + final List caPackages = customActions.stream() + .map(ShellCustomAction::requiredPackages) + .flatMap(List::stream).toList(); + + final List neededLibPackages; + if (withRequiredPackagesLookup) { + neededLibPackages = findRequiredPackages(); + } else { + neededLibPackages = Collections.emptyList(); + Log.info(I18N.getString("warning.foreign-app-image")); + } + + // Merge all package lists together. + // Filter out empty names, sort and remove duplicates. + Stream.of(caPackages, neededLibPackages) + .flatMap(List::stream) + .filter(Predicate.not(String::isEmpty)) + .sorted().distinct().forEach(requiredPackages::add); + + Log.verbose(String.format("Required packages: %s", requiredPackages)); + } + + private List findRequiredPackages() throws IOException { + var lookup = new LibProvidersLookup(); + initLibProvidersLookup(lookup); + return lookup.execute(env.appImageDir()); + } + + private void verifyOutputPackage() { + final List errors; + try { + errors = findErrorsInOutputPackage(); + } catch (Exception ex) { + // Ignore error as it is not critical. Just report it. + Log.verbose(ex); + return; + } + + for (var ex : errors) { + Log.verbose(ex.getLocalizedMessage()); + if (ex instanceof ConfigException cfgEx) { + Log.verbose(cfgEx.getAdvice()); + } + } + } + + protected final BuildEnv env; + protected final T pkg; + protected final Path outputDir; + private final boolean withRequiredPackagesLookup; + private final List requiredPackages = new ArrayList<>(); + private final List customActions; +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java index 350f70f11bc..2337a286011 100644 --- a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmBundler.java @@ -25,189 +25,20 @@ package jdk.jpackage.internal; -import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import jdk.internal.util.OperatingSystem; -import jdk.jpackage.internal.model.ConfigException; -import jdk.jpackage.internal.model.DottedVersion; -import jdk.jpackage.internal.model.LinuxPackage; import jdk.jpackage.internal.model.LinuxRpmPackage; -import jdk.jpackage.internal.model.Package; import jdk.jpackage.internal.model.PackagerException; +import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.internal.util.Result; -/** - * There are two command line options to configure license information for RPM - * packaging: --linux-rpm-license-type and --license-file. Value of - * --linux-rpm-license-type command line option configures "License:" section - * of RPM spec. Value of --license-file command line option specifies a license - * file to be added to the package. License file is a sort of documentation file - * but it will be installed even if user selects an option to install the - * package without documentation. --linux-rpm-license-type is the primary option - * to set license information. --license-file makes little sense in case of RPM - * packaging. - */ public class LinuxRpmBundler extends LinuxPackageBundler { - private static final String DEFAULT_SPEC_TEMPLATE = "template.spec"; - - public static final String TOOL_RPM = "rpm"; - public static final String TOOL_RPMBUILD = "rpmbuild"; - public static final DottedVersion TOOL_RPMBUILD_MIN_VERSION = DottedVersion.lazy( - "4.10"); - public LinuxRpmBundler() { super(LinuxFromParams.RPM_PACKAGE); } - @Override - protected void doValidate(BuildEnv env, LinuxPackage pkg) throws ConfigException { - } - - private static ToolValidator createRpmbuildToolValidator() { - Pattern pattern = Pattern.compile(" (\\d+\\.\\d+)"); - return new ToolValidator(TOOL_RPMBUILD).setMinimalVersion( - TOOL_RPMBUILD_MIN_VERSION).setVersionParser(lines -> { - String versionString = lines.limit(1).collect( - Collectors.toList()).get(0); - Matcher matcher = pattern.matcher(versionString); - if (matcher.find()) { - return matcher.group(1); - } - return null; - }); - } - - @Override - protected List getToolValidators() { - return List.of(createRpmbuildToolValidator()); - } - - protected void createConfigFiles(Map replacementData, - BuildEnv env, LinuxPackage pkg) throws IOException { - Path specFile = specFile(env, pkg); - - // prepare spec file - env.createResource(DEFAULT_SPEC_TEMPLATE) - .setCategory(I18N.getString("resource.rpm-spec-file")) - .setSubstitutionData(replacementData) - .saveToFile(specFile); - } - - @Override - protected Path buildPackageBundle(BuildEnv env, LinuxPackage pkg, - Path outputParentDir) throws PackagerException, IOException { - return buildRPM(env, pkg, outputParentDir); - } - - private static Path installPrefix(LinuxPackage pkg) { - Path path = pkg.relativeInstallDir(); - if (!pkg.isInstallDirInUsrTree()) { - path = path.getParent(); - } - return Path.of("/").resolve(path); - } - - @Override - protected Map createReplacementData(BuildEnv env, LinuxPackage pkg) throws IOException { - Map data = new HashMap<>(); - - data.put("APPLICATION_RELEASE", pkg.release().orElseThrow()); - data.put("APPLICATION_PREFIX", installPrefix(pkg).toString()); - data.put("APPLICATION_DIRECTORY", Path.of("/").resolve(pkg.relativeInstallDir()).toString()); - data.put("APPLICATION_SUMMARY", pkg.app().name()); - data.put("APPLICATION_LICENSE_TYPE", ((LinuxRpmPackage)pkg).licenseType()); - - String licenseFile = pkg.licenseFile().map(v -> { - return v.toAbsolutePath().normalize().toString(); - }).orElse(null); - data.put("APPLICATION_LICENSE_FILE", licenseFile); - data.put("APPLICATION_GROUP", pkg.category().orElse("")); - - data.put("APPLICATION_URL", pkg.aboutURL().orElse("")); - - return data; - } - - @Override - protected void initLibProvidersLookup(LibProvidersLookup libProvidersLookup) { - libProvidersLookup.setPackageLookup(file -> { - return Executor.of(TOOL_RPM, - "-q", "--queryformat", "%{name}\\n", - "-q", "--whatprovides", file.toString()) - .saveOutput(true).executeExpectSuccess().getOutput().stream(); - }); - } - - @Override - protected List verifyOutputBundle(BuildEnv env, LinuxPackage pkg, - Path packageBundle) { - List errors = new ArrayList<>(); - - String specFileName = specFile(env, pkg).getFileName().toString(); - - try { - List properties = List.of( - new PackageProperty("Name", pkg.packageName(), - "APPLICATION_PACKAGE", specFileName), - new PackageProperty("Version", pkg.version(), - "APPLICATION_VERSION", specFileName), - new PackageProperty("Release", pkg.release().orElseThrow(), - "APPLICATION_RELEASE", specFileName), - new PackageProperty("Arch", pkg.arch(), null, specFileName)); - - List actualValues = Executor.of(TOOL_RPM, "-qp", "--queryformat", - properties.stream().map(entry -> String.format("%%{%s}", - entry.name)).collect(Collectors.joining("\\n")), - packageBundle.toString()).saveOutput(true).executeExpectSuccess().getOutput(); - - Iterator actualValuesIt = actualValues.iterator(); - properties.forEach(property -> errors.add(property.verifyValue( - actualValuesIt.next()))); - } catch (IOException ex) { - // Ignore error as it is not critical. Just report it. - Log.verbose(ex); - } - - return errors; - } - - private Path specFile(BuildEnv env, Package pkg) { - return env.buildRoot().resolve(Path.of("SPECS", pkg.packageName() + ".spec")); - } - - private Path buildRPM(BuildEnv env, Package pkg, Path outdir) throws IOException { - - Path rpmFile = outdir.toAbsolutePath().resolve(pkg.packageFileNameWithSuffix()); - - Log.verbose(I18N.format("message.outputting-bundle-location", rpmFile.getParent())); - - //run rpmbuild - Executor.of(TOOL_RPMBUILD, - "-bb", specFile(env, pkg).toAbsolutePath().toString(), - "--define", String.format("%%_sourcedir %s", - env.appImageDir().toAbsolutePath()), - // save result to output dir - "--define", String.format("%%_rpmdir %s", rpmFile.getParent()), - // do not use other system directories to build as current user - "--define", String.format("%%_topdir %s", - env.buildRoot().toAbsolutePath()), - "--define", String.format("%%_rpmfilename %s", rpmFile.getFileName()) - ).executeExpectSuccess(); - - Log.verbose(I18N.format("message.output-bundle-location", rpmFile.getParent())); - - return rpmFile; - } - @Override public String getName() { return I18N.getString("rpm.bundler.name"); @@ -219,12 +50,28 @@ public class LinuxRpmBundler extends LinuxPackageBundler { } @Override - public boolean supported(boolean runtimeInstaller) { - return OperatingSystem.isLinux() && (createRpmbuildToolValidator().validate() == null); + public Path execute(Map params, Path outputParentDir) throws PackagerException { + + return Packager.build().outputDir(outputParentDir) + .pkg(LinuxFromParams.RPM_PACKAGE.fetchFrom(params)) + .env(BuildEnvFromParams.BUILD_ENV.fetchFrom(params)) + .pipelineBuilderMutatorFactory((env, pkg, outputDir) -> { + return new LinuxRpmPackager(env, pkg, outputDir, sysEnv.orElseThrow()); + }).execute(LinuxPackagingPipeline.build()); + } + + @Override + protected Result sysEnv() { + return sysEnv; } @Override public boolean isDefault() { - return !LinuxDebBundler.isDebian(); + return sysEnv.value() + .map(LinuxSystemEnvironment::nativePackageType) + .map(StandardPackageType.LINUX_RPM::equals) + .orElse(false); } + + private final Result sysEnv = LinuxRpmSystemEnvironment.create(SYS_ENV); } diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmPackager.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmPackager.java new file mode 100644 index 00000000000..60355d0d1a2 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmPackager.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 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 java.util.stream.Collectors.joining; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.LinuxRpmPackage; + + +/** + * There are two command line options to configure license information for RPM + * packaging: --linux-rpm-license-type and --license-file. Value of + * --linux-rpm-license-type command line option configures "License:" section + * of RPM spec. Value of --license-file command line option specifies a license + * file to be added to the package. License file is a sort of documentation file + * but it will be installed even if user selects an option to install the + * package without documentation. --linux-rpm-license-type is the primary option + * to set license information. --license-file makes little sense in case of RPM + * packaging. + */ +final class LinuxRpmPackager extends LinuxPackager { + + LinuxRpmPackager(BuildEnv env, LinuxRpmPackage pkg, Path outputDir, LinuxRpmSystemEnvironment sysEnv) { + super(env, pkg, outputDir, sysEnv); + this.sysEnv = Objects.requireNonNull(sysEnv); + } + + @Override + protected void createConfigFiles(Map replacementData) throws IOException { + Path specFile = specFile(); + + // prepare spec file + env.createResource("template.spec") + .setCategory(I18N.getString("resource.rpm-spec-file")) + .setSubstitutionData(replacementData) + .saveToFile(specFile); + } + + @Override + protected Map createReplacementData() { + Map data = new HashMap<>(); + + data.put("APPLICATION_RELEASE", pkg.release().orElseThrow()); + data.put("APPLICATION_PREFIX", installPrefix().toString()); + data.put("APPLICATION_DIRECTORY", Path.of("/").resolve(pkg.relativeInstallDir()).toString()); + data.put("APPLICATION_SUMMARY", pkg.app().name()); + data.put("APPLICATION_LICENSE_TYPE", pkg.licenseType()); + + String licenseFile = pkg.licenseFile().map(v -> { + return v.toAbsolutePath().normalize().toString(); + }).orElse(null); + data.put("APPLICATION_LICENSE_FILE", licenseFile); + data.put("APPLICATION_GROUP", pkg.category().orElse("")); + + data.put("APPLICATION_URL", pkg.aboutURL().orElse("")); + + return data; + } + + @Override + protected void initLibProvidersLookup(LibProvidersLookup libProvidersLookup) { + libProvidersLookup.setPackageLookup(file -> { + return Executor.of(sysEnv.rpm().toString(), + "-q", "--queryformat", "%{name}\\n", + "-q", "--whatprovides", file.toString() + ).saveOutput(true).executeExpectSuccess().getOutput().stream(); + }); + } + + @Override + protected List findErrorsInOutputPackage() throws IOException { + List errors = new ArrayList<>(); + + var specFileName = specFile().getFileName().toString(); + + var properties = List.of( + new PackageProperty("Name", pkg.packageName(), + "APPLICATION_PACKAGE", specFileName), + new PackageProperty("Version", pkg.version(), + "APPLICATION_VERSION", specFileName), + new PackageProperty("Release", pkg.release().orElseThrow(), + "APPLICATION_RELEASE", specFileName), + new PackageProperty("Arch", pkg.arch(), null, specFileName)); + + var actualValues = Executor.of( + sysEnv.rpm().toString(), + "-qp", + "--queryformat", properties.stream().map(e -> String.format("%%{%s}", e.name)).collect(joining("\\n")), + outputPackageFile().toString() + ).saveOutput(true).executeExpectSuccess().getOutput(); + + for (int i = 0; i != properties.size(); i++) { + Optional.ofNullable(properties.get(i).verifyValue(actualValues.get(i))).ifPresent(errors::add); + } + + return errors; + } + + @Override + protected void buildPackage() throws IOException { + + Path rpmFile = outputPackageFile(); + + Log.verbose(I18N.format("message.outputting-bundle-location", rpmFile.getParent())); + + //run rpmbuild + Executor.of(sysEnv.rpmbuild().toString(), + "-bb", specFile().toAbsolutePath().toString(), + "--define", String.format("%%_sourcedir %s", + env.appImageDir().toAbsolutePath()), + // save result to output dir + "--define", String.format("%%_rpmdir %s", rpmFile.getParent()), + // do not use other system directories to build as current user + "--define", String.format("%%_topdir %s", + env.buildRoot().toAbsolutePath()), + "--define", String.format("%%_rpmfilename %s", rpmFile.getFileName()) + ).executeExpectSuccess(); + + Log.verbose(I18N.format("message.output-bundle-location", rpmFile.getParent())); + } + + private Path installPrefix() { + Path path = pkg.relativeInstallDir(); + if (!pkg.isInstallDirInUsrTree()) { + path = path.getParent(); + } + return Path.of("/").resolve(path); + } + + private Path specFile() { + return env.buildRoot().resolve(Path.of("SPECS", pkg.packageName() + ".spec")); + } + + private final LinuxRpmSystemEnvironment sysEnv; +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java new file mode 100644 index 00000000000..58c10668227 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironment.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 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.LinuxSystemEnvironment.mixin; + +import jdk.jpackage.internal.util.Result; + +public interface LinuxRpmSystemEnvironment extends LinuxSystemEnvironment, LinuxRpmSystemEnvironmentMixin { + + static Result create(Result base) { + return mixin(LinuxRpmSystemEnvironment.class, base, LinuxRpmSystemEnvironmentMixin::create); + } +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java new file mode 100644 index 00000000000..b741495f5ed --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxRpmSystemEnvironmentMixin.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 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 java.nio.file.Path; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import jdk.jpackage.internal.model.DottedVersion; +import jdk.jpackage.internal.util.Result; + +public interface LinuxRpmSystemEnvironmentMixin { + Path rpm(); + Path rpmbuild(); + + record Stub(Path rpm, Path rpmbuild) implements LinuxRpmSystemEnvironmentMixin { + } + + static Result create() { + + final var errors = Stream.of( + Internal.createRpmbuildToolValidator(), + new ToolValidator(Internal.TOOL_RPM) + ).map(ToolValidator::validate).filter(Objects::nonNull).toList(); + + if (errors.isEmpty()) { + return Result.ofValue(new Stub(Internal.TOOL_RPM, Internal.TOOL_RPMBUILD)); + } else { + return Result.ofErrors(errors); + } + } + + static final class Internal { + private static ToolValidator createRpmbuildToolValidator() { + Pattern pattern = Pattern.compile(" (\\d+\\.\\d+)"); + return new ToolValidator(TOOL_RPMBUILD).setMinimalVersion( + TOOL_RPMBUILD_MIN_VERSION).setVersionParser(lines -> { + String versionString = lines.limit(1).findFirst().orElseThrow(); + Matcher matcher = pattern.matcher(versionString); + if (matcher.find()) { + return matcher.group(1); + } + return null; + }); + } + + private static final Path TOOL_RPM = Path.of("rpm"); + private static final Path TOOL_RPMBUILD = Path.of("rpmbuild"); + private static final DottedVersion TOOL_RPMBUILD_MIN_VERSION = DottedVersion.lazy("4.10"); + } +} diff --git a/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java new file mode 100644 index 00000000000..1a70cc938b8 --- /dev/null +++ b/src/jdk.jpackage/linux/classes/jdk/jpackage/internal/LinuxSystemEnvironment.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 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 java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import jdk.jpackage.internal.model.PackageType; +import jdk.jpackage.internal.model.StandardPackageType; +import jdk.jpackage.internal.util.CompositeProxy; +import jdk.jpackage.internal.util.Result; + +public interface LinuxSystemEnvironment extends SystemEnvironment { + boolean soLookupAvailable(); + PackageType nativePackageType(); + + static Result create() { + return detectNativePackageType().map(LinuxSystemEnvironment::create).orElseGet(() -> { + return Result.ofError(new RuntimeException("Unknown native package type")); + }); + } + + static Optional detectNativePackageType() { + if (Internal.isDebian()) { + return Optional.of(StandardPackageType.LINUX_DEB); + } else if (Internal.isRpm()) { + return Optional.of(StandardPackageType.LINUX_RPM); + } else { + return Optional.empty(); + } + } + + static Result create(PackageType nativePackageType) { + return Result.ofValue(new Stub(LibProvidersLookup.supported(), + Objects.requireNonNull(nativePackageType))); + } + + static U createWithMixin(Class type, LinuxSystemEnvironment base, T mixin) { + return CompositeProxy.create(type, base, mixin); + } + + static Result mixin(Class type, + Result base, Supplier> mixinResultSupplier) { + final var mixin = mixinResultSupplier.get(); + + final List errors = new ArrayList<>(); + errors.addAll(base.errors()); + errors.addAll(mixin.errors()); + + if (errors.isEmpty()) { + return Result.ofValue(createWithMixin(type, base.orElseThrow(), mixin.orElseThrow())); + } else { + return Result.ofErrors(errors); + } + } + + record Stub(boolean soLookupAvailable, PackageType nativePackageType) implements LinuxSystemEnvironment { + } + + static final class Internal { + + private static boolean isDebian() { + // we are just going to run "dpkg -s coreutils" and assume Debian + // or derivative if no error is returned. + try { + Executor.of("dpkg", "-s", "coreutils").executeExpectSuccess(); + return true; + } catch (IOException e) { + // just fall thru + return false; + } + } + + private static boolean isRpm() { + // we are just going to run "rpm -q rpm" and assume RPM + // or derivative if no error is returned. + try { + Executor.of("rpm", "-q", "rpm").executeExpectSuccess(); + return true; + } catch (IOException e) { + // just fall thru + return false; + } + } + } +} diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java index d2c72765d7a..0ddb987dbee 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgBundler.java @@ -25,12 +25,14 @@ package jdk.jpackage.internal; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; import java.util.Objects; +import java.util.Optional; import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.MacDmgPackage; import jdk.jpackage.internal.model.PackagerException; +import jdk.jpackage.internal.util.Result; public class MacDmgBundler extends MacBaseInstallerBundler { @@ -70,39 +72,27 @@ public class MacDmgBundler extends MacBaseInstallerBundler { public Path execute(Map params, Path outputParentDir) throws PackagerException { - final var pkg = MacFromParams.DMG_PACKAGE.fetchFrom(params); - var env = MacBuildEnvFromParams.BUILD_ENV.fetchFrom(params); + var pkg = MacFromParams.DMG_PACKAGE.fetchFrom(params); - final var packager = MacDmgPackager.build().outputDir(outputParentDir).pkg(pkg).env(env); + Log.verbose(I18N.format("message.building-dmg", pkg.app().name())); - MacDmgPackager.findSetFileUtility().ifPresent(packager::setFileUtility); - - return packager.execute(); + return Packager.build().outputDir(outputParentDir) + .pkg(pkg) + .env(MacBuildEnvFromParams.BUILD_ENV.fetchFrom(params)) + .pipelineBuilderMutatorFactory((env, _, outputDir) -> { + return new MacDmgPackager(env, pkg, outputDir, sysEnv.orElseThrow()); + }).execute(MacPackagingPipeline.build(Optional.of(pkg))); } @Override public boolean supported(boolean runtimeInstaller) { - return isSupported(); - } - - public static final String[] required = - {"/usr/bin/hdiutil", "/usr/bin/osascript"}; - public static boolean isSupported() { - try { - for (String s : required) { - Path f = Path.of(s); - if (!Files.exists(f) || !Files.isExecutable(f)) { - return false; - } - } - return true; - } catch (Exception e) { - return false; - } + return sysEnv.hasValue(); } @Override public boolean isDefault() { return true; } + + private final Result sysEnv = MacDmgSystemEnvironment.create(); } diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java index eb3ad3a76c8..6e13a2ff0c1 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgPackager.java @@ -36,106 +36,25 @@ import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.stream.Stream; +import java.util.function.Consumer; import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; -import jdk.jpackage.internal.PackagingPipeline.StartupParameters; import jdk.jpackage.internal.PackagingPipeline.TaskID; import jdk.jpackage.internal.model.MacDmgPackage; -import jdk.jpackage.internal.model.PackagerException; import jdk.jpackage.internal.util.FileUtils; import jdk.jpackage.internal.util.PathGroup; -record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path outputDir, Optional setFileUtility) { +record MacDmgPackager(BuildEnv env, MacDmgPackage pkg, Path outputDir, + MacDmgSystemEnvironment sysEnv) implements Consumer { MacDmgPackager { - Objects.requireNonNull(pkg); Objects.requireNonNull(env); - Objects.requireNonNull(hdiutil); + Objects.requireNonNull(pkg); Objects.requireNonNull(outputDir); - Objects.requireNonNull(setFileUtility); + Objects.requireNonNull(sysEnv); } - static Builder build() { - return new Builder(); - } - - static final class Builder extends PackagerBuilder { - - Builder hdiutil(Path v) { - hdiutil = v; - return this; - } - - Builder setFileUtility(Path v) { - setFileUtility = v; - return this; - } - - Path execute() throws PackagerException { - Log.verbose(MessageFormat.format(I18N.getString("message.building-dmg"), - pkg.app().name())); - - IOUtils.writableOutputDir(outputDir); - - return execute(MacPackagingPipeline.build(Optional.of(pkg))); - } - - @Override - protected void configurePackagingPipeline(PackagingPipeline.Builder pipelineBuilder, - StartupParameters startupParameters) { - final var packager = new MacDmgPackager(pkg, startupParameters.packagingEnv(), - validatedHdiutil(), outputDir, Optional.ofNullable(setFileUtility)); - packager.applyToPipeline(pipelineBuilder); - } - - private Path validatedHdiutil() { - return Optional.ofNullable(hdiutil).orElse(HDIUTIL); - } - - private Path hdiutil; - private Path setFileUtility; - } - - // Location of SetFile utility may be different depending on MacOS version - // We look for several known places and if none of them work will - // try to find it - static Optional findSetFileUtility() { - String typicalPaths[] = {"/Developer/Tools/SetFile", - "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"}; - - final var setFilePath = Stream.of(typicalPaths).map(Path::of).filter(Files::isExecutable).findFirst(); - if (setFilePath.isPresent()) { - // Validate SetFile, if Xcode is not installed it will run, but exit with error - // code - try { - if (Executor.of(setFilePath.orElseThrow().toString(), "-h").setQuiet(true).execute() == 0) { - return setFilePath; - } - } catch (Exception ignored) { - // No need for generic find attempt. We found it, but it does not work. - // Probably due to missing xcode. - return Optional.empty(); - } - } - - // generic find attempt - try { - final var executor = Executor.of("/usr/bin/xcrun", "-find", "SetFile"); - final var code = executor.setQuiet(true).saveOutput(true).execute(); - if (code == 0 && executor.getOutput().isEmpty()) { - final var firstLine = executor.getOutput().getFirst(); - Path f = Path.of(firstLine); - if (Files.exists(f) && Files.isExecutable(f)) { - return Optional.of(f.toAbsolutePath()); - } - } - } catch (IOException ignored) {} - - return Optional.empty(); - } - - private void applyToPipeline(PackagingPipeline.Builder pipelineBuilder) { + @Override + public void accept(PackagingPipeline.Builder pipelineBuilder) { pipelineBuilder .excludeDirFromCopying(outputDir) .task(DmgPackageTaskID.COPY_DMG_CONTENT) @@ -318,7 +237,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // create temp image ProcessBuilder pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "create", hdiUtilVerbosityFlag, "-srcfolder", normalizedAbsolutePathString(srcFolder), @@ -341,7 +260,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // We need extra room for icons and background image. When we providing // actual files to hdiutil, it will create DMG with ~50 megabytes extra room. pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "create", hdiUtilVerbosityFlag, "-size", String.valueOf(size), @@ -357,7 +276,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // mount temp image pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "attach", normalizedAbsolutePathString(protoDMG), hdiUtilVerbosityFlag, @@ -382,7 +301,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // to install-dir in DMG as critical error, since it can fail in // headless environment. try { - pb = new ProcessBuilder("/usr/bin/osascript", + pb = new ProcessBuilder(sysEnv.osascript().toString(), normalizedAbsolutePathString(volumeScript())); IOUtils.exec(pb, 180); // Wait 3 minutes. See JDK-8248248. } catch (IOException ex) { @@ -397,7 +316,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // NB: attributes of the root directory are ignored // when creating the volume // Therefore we have to do this after we mount image - if (setFileUtility.isPresent()) { + if (sysEnv.setFileUtility().isPresent()) { //can not find utility => keep going without icon try { volumeIconFile.toFile().setWritable(true); @@ -406,14 +325,14 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // "icnC" for the volume icon // (might not work on Mac 10.13 with old XCode) pb = new ProcessBuilder( - setFileUtility.orElseThrow().toString(), + sysEnv.setFileUtility().orElseThrow().toString(), "-c", "icnC", normalizedAbsolutePathString(volumeIconFile)); IOUtils.exec(pb); volumeIconFile.toFile().setReadOnly(); pb = new ProcessBuilder( - setFileUtility.orElseThrow().toString(), + sysEnv.setFileUtility().orElseThrow().toString(), "-a", "C", normalizedAbsolutePathString(mountedVolume)); IOUtils.exec(pb); @@ -428,7 +347,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output } finally { // Detach the temporary image pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "detach", hdiUtilVerbosityFlag, normalizedAbsolutePathString(mountedVolume)); @@ -451,7 +370,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // Now force to detach if it still attached if (Files.exists(mountedVolume)) { pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "detach", "-force", hdiUtilVerbosityFlag, @@ -464,7 +383,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output // Compress it to a new image pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "convert", normalizedAbsolutePathString(protoDMG), hdiUtilVerbosityFlag, @@ -481,7 +400,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output Files.copy(protoDMG, protoCopyDMG); try { pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "convert", normalizedAbsolutePathString(protoCopyDMG), hdiUtilVerbosityFlag, @@ -496,7 +415,7 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output //add license if needed if (pkg.licenseFile().isPresent()) { pb = new ProcessBuilder( - hdiutil.toString(), + sysEnv.hdiutil().toString(), "udifrez", normalizedAbsolutePathString(finalDMG), "-xml", @@ -527,6 +446,4 @@ record MacDmgPackager(MacDmgPackage pkg, BuildEnv env, Path hdiutil, Path output private static final String TEMPLATE_BUNDLE_ICON = "JavaApp.icns"; private static final String DEFAULT_LICENSE_PLIST="lic_template.plist"; - - private static final Path HDIUTIL = Path.of("/usr/bin/hdiutil"); } diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java new file mode 100644 index 00000000000..54eb0c6f4fe --- /dev/null +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacDmgSystemEnvironment.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; +import jdk.jpackage.internal.util.Result; + +record MacDmgSystemEnvironment(Path hdiutil, Path osascript, Optional setFileUtility) implements SystemEnvironment { + + MacDmgSystemEnvironment { + } + + static Result create() { + final var errors = Stream.of(HDIUTIL, OSASCRIPT) + .map(ToolValidator::new) + .map(ToolValidator::checkExistsOnly) + .map(ToolValidator::validate) + .filter(Objects::nonNull) + .toList(); + if (errors.isEmpty()) { + return Result.ofValue(new MacDmgSystemEnvironment(HDIUTIL, OSASCRIPT, findSetFileUtility())); + } else { + return Result.ofErrors(errors); + } + } + + // Location of SetFile utility may be different depending on MacOS version + // We look for several known places and if none of them work will + // try to find it + private static Optional findSetFileUtility() { + String typicalPaths[] = {"/Developer/Tools/SetFile", + "/usr/bin/SetFile", "/Developer/usr/bin/SetFile"}; + + final var setFilePath = Stream.of(typicalPaths).map(Path::of).filter(Files::isExecutable).findFirst(); + if (setFilePath.isPresent()) { + // Validate SetFile, if Xcode is not installed it will run, but exit with error + // code + try { + if (Executor.of(setFilePath.orElseThrow().toString(), "-h").setQuiet(true).execute() == 0) { + return setFilePath; + } + } catch (Exception ignored) { + // No need for generic find attempt. We found it, but it does not work. + // Probably due to missing xcode. + return Optional.empty(); + } + } + + // generic find attempt + try { + final var executor = Executor.of("/usr/bin/xcrun", "-find", "SetFile"); + final var code = executor.setQuiet(true).saveOutput(true).execute(); + if (code == 0 && !executor.getOutput().isEmpty()) { + final var firstLine = executor.getOutput().getFirst(); + Path f = Path.of(firstLine); + if (new ToolValidator(f).checkExistsOnly().validate() == null) { + return Optional.of(f.toAbsolutePath()); + } + } + } catch (IOException ignored) {} + + return Optional.empty(); + } + + private static final Path HDIUTIL = Path.of("/usr/bin/hdiutil"); + private static final Path OSASCRIPT = Path.of("/usr/bin/osascript"); +} diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java index bc19e7c4a1a..e827f238db3 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgBundler.java @@ -28,7 +28,9 @@ package jdk.jpackage.internal; import java.nio.file.Path; import java.util.Map; import java.util.Objects; +import java.util.Optional; import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.MacPkgPackage; import jdk.jpackage.internal.model.PackagerException; public class MacPkgBundler extends MacBaseInstallerBundler { @@ -49,7 +51,7 @@ public class MacPkgBundler extends MacBaseInstallerBundler { try { Objects.requireNonNull(params); - final var pkgPkg = MacFromParams.PKG_PACKAGE.fetchFrom(params); + final var pkg = MacFromParams.PKG_PACKAGE.fetchFrom(params); // run basic validation to ensure requirements are met // we are not interested in return code, only possible exception @@ -72,12 +74,16 @@ public class MacPkgBundler extends MacBaseInstallerBundler { public Path execute(Map params, Path outputParentDir) throws PackagerException { - final var pkg = MacFromParams.PKG_PACKAGE.fetchFrom(params); - var env = MacBuildEnvFromParams.BUILD_ENV.fetchFrom(params); + var pkg = MacFromParams.PKG_PACKAGE.fetchFrom(params); - final var packager = MacPkgPackager.build().outputDir(outputParentDir).pkg(pkg).env(env); + Log.verbose(I18N.format("message.building-pkg", pkg.app().name())); - return packager.execute(); + return Packager.build().outputDir(outputParentDir) + .pkg(pkg) + .env(MacBuildEnvFromParams.BUILD_ENV.fetchFrom(params)) + .pipelineBuilderMutatorFactory((env, _, outputDir) -> { + return new MacPkgPackager(env, pkg, outputDir); + }).execute(MacPackagingPipeline.build(Optional.of(pkg))); } @Override diff --git a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java index 1fb0b9bc160..891c5b120f5 100644 --- a/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java +++ b/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/MacPkgPackager.java @@ -41,6 +41,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Consumer; import java.util.stream.Stream; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; @@ -52,15 +53,25 @@ import javax.xml.transform.stream.StreamSource; import jdk.internal.util.Architecture; import jdk.internal.util.OSVersion; import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; -import jdk.jpackage.internal.PackagingPipeline.StartupParameters; import jdk.jpackage.internal.PackagingPipeline.TaskID; import jdk.jpackage.internal.model.MacPkgPackage; -import jdk.jpackage.internal.model.PackagerException; import jdk.jpackage.internal.resources.ResourceLocator; import jdk.jpackage.internal.util.XmlUtils; import org.xml.sax.SAXException; -record MacPkgPackager(MacPkgPackage pkg, BuildEnv env, Optional services, Path outputDir) { +record MacPkgPackager(BuildEnv env, MacPkgPackage pkg, Optional services, + Path outputDir) implements Consumer { + + MacPkgPackager { + Objects.requireNonNull(env); + Objects.requireNonNull(pkg); + Objects.requireNonNull(services); + Objects.requireNonNull(outputDir); + } + + MacPkgPackager(BuildEnv env, MacPkgPackage pkg, Path outputDir) { + this(env, pkg, createServices(env, pkg), outputDir); + } enum PkgPackageTaskID implements TaskID { PREPARE_MAIN_SCRIPTS, @@ -69,37 +80,6 @@ record MacPkgPackager(MacPkgPackage pkg, BuildEnv env, Optional servic PREPARE_SERVICES } - static Builder build() { - return new Builder(); - } - - static final class Builder extends PackagerBuilder { - - Path execute() throws PackagerException { - Log.verbose(MessageFormat.format(I18N.getString("message.building-pkg"), - pkg.app().name())); - - IOUtils.writableOutputDir(outputDir); - - return execute(MacPackagingPipeline.build(Optional.of(pkg))); - } - - @Override - protected void configurePackagingPipeline(PackagingPipeline.Builder pipelineBuilder, - StartupParameters startupParameters) { - final var packager = new MacPkgPackager(pkg, startupParameters.packagingEnv(), createServices(), outputDir); - packager.applyToPipeline(pipelineBuilder); - } - - private Optional createServices() { - if (pkg.app().isService()) { - return Optional.of(Services.create(pkg, env)); - } else { - return Optional.empty(); - } - } - } - record InternalPackage(Path srcRoot, String identifier, Path path, List otherPkgbuildArgs) { InternalPackage { @@ -230,7 +210,8 @@ record MacPkgPackager(MacPkgPackage pkg, BuildEnv env, Optional servic private final Optional nameSuffix; } - private void applyToPipeline(PackagingPipeline.Builder pipelineBuilder) { + @Override + public void accept(PackagingPipeline.Builder pipelineBuilder) { pipelineBuilder .excludeDirFromCopying(outputDir) .task(PkgPackageTaskID.PREPARE_MAIN_SCRIPTS) @@ -559,6 +540,14 @@ record MacPkgPackager(MacPkgPackage pkg, BuildEnv env, Optional servic IOUtils.exec(pb, false, null, true, Executor.INFINITE_TIMEOUT); } + private static Optional createServices(BuildEnv env, MacPkgPackage pkg) { + if (pkg.app().isService()) { + return Optional.of(Services.create(pkg, env)); + } else { + return Optional.empty(); + } + } + private static final String DEFAULT_BACKGROUND_IMAGE = "background_pkg.png"; private static final String DEFAULT_PDF = "product-def.plist"; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/PackagerBuilder.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Packager.java similarity index 57% rename from src/jdk.jpackage/share/classes/jdk/jpackage/internal/PackagerBuilder.java rename to src/jdk.jpackage/share/classes/jdk/jpackage/internal/Packager.java index 4ba2f0fbda6..501fd64bdca 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/PackagerBuilder.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/Packager.java @@ -26,50 +26,80 @@ package jdk.jpackage.internal; import java.nio.file.Path; import java.util.Objects; -import jdk.jpackage.internal.PackagingPipeline.StartupParameters; +import java.util.Optional; +import java.util.function.Consumer; import jdk.jpackage.internal.model.Package; import jdk.jpackage.internal.model.PackagerException; -abstract class PackagerBuilder> { +final class Packager { - U pkg(T v) { + static Packager build() { + return new Packager<>(); + } + + Packager pkg(T v) { pkg = v; - return thiz(); + return this; } - U env(BuildEnv v) { + Packager env(BuildEnv v) { env = v; - return thiz(); + return this; } - U outputDir(Path v) { + Packager outputDir(Path v) { outputDir = v; - return thiz(); + return this; } - @SuppressWarnings("unchecked") - private U thiz() { - return (U)this; + Packager pipelineBuilderMutatorFactory(PipelineBuilderMutatorFactory v) { + pipelineBuilderMutatorFactory = v; + return this; } - protected abstract void configurePackagingPipeline(PackagingPipeline.Builder pipelineBuilder, - StartupParameters startupParameters); + T pkg() { + return Objects.requireNonNull(pkg); + } + + Path outputDir() { + return Objects.requireNonNull(outputDir); + } + + BuildEnv env() { + return Objects.requireNonNull(env); + } Path execute(PackagingPipeline.Builder pipelineBuilder) throws PackagerException { Objects.requireNonNull(pkg); Objects.requireNonNull(env); Objects.requireNonNull(outputDir); + IOUtils.writableOutputDir(outputDir); + final var startupParameters = pipelineBuilder.createStartupParameters(env, pkg, outputDir); - configurePackagingPipeline(pipelineBuilder, startupParameters); + pipelineBuilderMutatorFactory().ifPresent(factory -> { + factory.create(startupParameters.packagingEnv(), pkg, outputDir).accept(pipelineBuilder); + }); pipelineBuilder.create().execute(startupParameters); return outputDir.resolve(pkg.packageFileNameWithSuffix()); } - protected T pkg; - protected BuildEnv env; - protected Path outputDir; + + @FunctionalInterface + interface PipelineBuilderMutatorFactory { + Consumer create(BuildEnv env, T pkg, Path outputDir); + } + + + private Optional> pipelineBuilderMutatorFactory() { + return Optional.ofNullable(pipelineBuilderMutatorFactory); + } + + private T pkg; + private BuildEnv env; + private Path outputDir; + private PipelineBuilderMutatorFactory pipelineBuilderMutatorFactory; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java new file mode 100644 index 00000000000..de98e97c922 --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/SystemEnvironment.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 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; + +public interface SystemEnvironment { +} diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java index f673ac7e33e..13e87d5cfa6 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/ToolValidator.java @@ -24,36 +24,31 @@ */ package jdk.jpackage.internal; -import jdk.internal.util.OperatingSystem; -import jdk.jpackage.internal.model.ConfigException; -import jdk.jpackage.internal.model.DottedVersion; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; -import java.text.MessageFormat; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Objects; import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Stream; +import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.DottedVersion; -public final class ToolValidator { +final class ToolValidator { ToolValidator(String tool) { this(Path.of(tool)); } ToolValidator(Path toolPath) { - this.toolPath = toolPath; - args = new ArrayList<>(); - + this.toolPath = Objects.requireNonNull(toolPath); if (OperatingSystem.isLinux()) { setCommandLine("--version"); } - - setToolNotFoundErrorHandler(null); - setToolOldVersionErrorHandler(null); } ToolValidator setCommandLine(String... args) { @@ -67,7 +62,17 @@ public final class ToolValidator { } ToolValidator setMinimalVersion(DottedVersion v) { - return setMinimalVersion(t -> DottedVersion.compareComponents(v, DottedVersion.lazy(t))); + return setMinimalVersion(new Comparable() { + @Override + public int compareTo(String o) { + return DottedVersion.compareComponents(v, DottedVersion.lazy(o)); + } + + @Override + public String toString() { + return v.toString(); + } + }); } ToolValidator setVersionParser(Function, String> v) { @@ -75,69 +80,85 @@ public final class ToolValidator { return this; } - ToolValidator setToolNotFoundErrorHandler( - BiFunction v) { + ToolValidator setToolNotFoundErrorHandler(Function v) { toolNotFoundErrorHandler = v; return this; } - ToolValidator setToolOldVersionErrorHandler(BiFunction v) { + ToolValidator setToolOldVersionErrorHandler(BiFunction v) { toolOldVersionErrorHandler = v; return this; } + ToolValidator checkExistsOnly(boolean v) { + checkExistsOnly = v; + return this; + } + + ToolValidator checkExistsOnly() { + return checkExistsOnly(true); + } + ConfigException validate() { + if (checkExistsOnly) { + if (Files.isExecutable(toolPath) && !Files.isDirectory(toolPath)) { + return null; + } else if (Files.exists(toolPath)) { + return new ConfigException( + I18N.format("error.tool-not-executable", toolPath), (String)null); + } else if (toolNotFoundErrorHandler != null) { + return toolNotFoundErrorHandler.apply(toolPath); + } else { + return new ConfigException( + I18N.format("error.tool-not-found", toolPath), + I18N.format("error.tool-not-found.advice", toolPath)); + } + } + List cmdline = new ArrayList<>(); cmdline.add(toolPath.toString()); - cmdline.addAll(args); + if (args != null) { + cmdline.addAll(args); + } + + boolean canUseTool[] = new boolean[1]; + if (minimalVersion == null) { + // No version check. + canUseTool[0] = true; + } + + String[] version = new String[1]; - String name = IOUtils.getFileName(toolPath).toString(); try { - ProcessBuilder pb = new ProcessBuilder(cmdline); - AtomicBoolean canUseTool = new AtomicBoolean(); - if (minimalVersion == null) { - // No version check. - canUseTool.setPlain(true); - } - - String[] version = new String[1]; - Executor.of(pb).setQuiet(true).setOutputConsumer(lines -> { + Executor.of(cmdline.toArray(String[]::new)).setQuiet(true).setOutputConsumer(lines -> { if (versionParser != null && minimalVersion != null) { version[0] = versionParser.apply(lines); - if (minimalVersion.compareTo(version[0]) < 0) { - canUseTool.setPlain(true); + if (version[0] != null && minimalVersion.compareTo(version[0]) <= 0) { + canUseTool[0] = true; } } }).execute(); - - if (!canUseTool.getPlain()) { - if (toolOldVersionErrorHandler != null) { - return toolOldVersionErrorHandler.apply(name, version[0]); - } - return new ConfigException(MessageFormat.format(I18N.getString( - "error.tool-old-version"), name, minimalVersion), - MessageFormat.format(I18N.getString( - "error.tool-old-version.advice"), name, - minimalVersion)); - } } catch (IOException e) { - if (toolNotFoundErrorHandler != null) { - return toolNotFoundErrorHandler.apply(name, e); - } - return new ConfigException(MessageFormat.format(I18N.getString( - "error.tool-not-found"), name, e.getMessage()), - MessageFormat.format(I18N.getString( - "error.tool-not-found.advice"), name), e); + return new ConfigException(I18N.format("error.tool-error", toolPath, e.getMessage()), null, e); } - // All good. Tool can be used. - return null; + if (canUseTool[0]) { + // All good. Tool can be used. + return null; + } else if (toolOldVersionErrorHandler != null) { + return toolOldVersionErrorHandler.apply(toolPath, version[0]); + } else { + return new ConfigException( + I18N.format("error.tool-old-version", toolPath, minimalVersion), + I18N.format("error.tool-old-version.advice", toolPath, minimalVersion)); + } } private final Path toolPath; private List args; private Comparable minimalVersion; private Function, String> versionParser; - private BiFunction toolNotFoundErrorHandler; - private BiFunction toolOldVersionErrorHandler; + private Function toolNotFoundErrorHandler; + private BiFunction toolOldVersionErrorHandler; + private boolean checkExistsOnly; } diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties index 684a97bc1bd..967549f6855 100644 --- a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/resources/MainResources.properties @@ -66,10 +66,13 @@ error.no-content-types-for-file-association.advice=Specify MIME type for File As error.too-many-content-types-for-file-association=More than one MIME types was specified for File Association number {0} error.too-many-content-types-for-file-association.advice=Specify only one MIME type for File Association number {0} -error.tool-not-found=Can not find {0}. Reason: {1} -error.tool-not-found.advice=Please install {0} -error.tool-old-version=Can not find {0} {1} or newer -error.tool-old-version.advice=Please install {0} {1} or newer +error.tool-error=Can not validate "{0}". Reason: {1} +error.tool-not-executable="{0}" is not executable +error.tool-not-found=Can not find "{0}" +error.tool-not-found.advice=Please install "{0}" +error.tool-old-version=Can not find "{0}" {1} or newer +error.tool-old-version.advice=Please install "{0}" {1} or newer + error.jlink.failed=jlink failed with: {0} error.blocked.option=jlink option [{0}] is not permitted in --jlink-options error.no.name=Name not specified with --name and cannot infer one from app-image diff --git a/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/Result.java b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/Result.java new file mode 100644 index 00000000000..7bd6408183a --- /dev/null +++ b/src/jdk.jpackage/share/classes/jdk/jpackage/internal/util/Result.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 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. + * + * 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.util; + +import static jdk.jpackage.internal.util.function.ExceptionBox.rethrowUnchecked; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.stream.StreamSupport; + + +public record Result(Optional value, Collection errors) { + public Result { + if (value.isEmpty() == errors.isEmpty()) { + throw new IllegalArgumentException(); + } + + if (value.isEmpty() && errors.isEmpty()) { + throw new IllegalArgumentException("Error collection must be non-empty"); + } + + } + + public T orElseThrow() { + firstError().ifPresent(ex -> { + rethrowUnchecked(ex); + }); + return value.orElseThrow(); + } + + public boolean hasValue() { + return value.isPresent(); + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public Result map(Function conv) { + return new Result<>(value.map(conv), errors); + } + + public Result flatMap(Function> conv) { + return value.map(conv).orElseGet(() -> { + return new Result<>(Optional.empty(), errors); + }); + } + + public Result mapErrors(UnaryOperator> errorsMapper) { + return new Result<>(value, errorsMapper.apply(errors)); + } + + public Result mapErrors() { + return new Result<>(Optional.empty(), errors); + } + + public Result peekErrors(Consumer> consumer) { + if (hasErrors()) { + consumer.accept(errors); + } + return this; + } + + public Result peekValue(Consumer consumer) { + value.ifPresent(consumer); + return this; + } + + public Optional firstError() { + return errors.stream().findFirst(); + } + + public static Result create(Supplier supplier) { + try { + return ofValue(supplier.get()); + } catch (Exception ex) { + return ofError(ex); + } + } + + public static Result ofValue(T value) { + return new Result<>(Optional.of(value), List.of()); + } + + public static Result ofErrors(Collection errors) { + return new Result<>(Optional.empty(), List.copyOf(errors)); + } + + public static Result ofError(Exception error) { + return ofErrors(List.of(error)); + } + + public static boolean allHaveValues(Iterable> results) { + return StreamSupport.stream(results.spliterator(), false).allMatch(Result::hasValue); + } + + public static boolean allHaveValues(Result... results) { + return allHaveValues(List.of(results)); + } +} diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java index 54f43f05715..9ce758eb3c7 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExeBundler.java @@ -24,17 +24,11 @@ */ package jdk.jpackage.internal; -import static jdk.jpackage.internal.StandardBundlerParam.ICON; -import static jdk.jpackage.internal.util.function.ThrowingRunnable.toRunnable; - -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.util.Map; import jdk.jpackage.internal.model.ConfigException; import jdk.jpackage.internal.model.PackagerException; -import jdk.jpackage.internal.model.WinExePackage; +import jdk.jpackage.internal.model.WinMsiPackage; @SuppressWarnings("restricted") public class WinExeBundler extends AbstractBundler { @@ -79,63 +73,23 @@ public class WinExeBundler extends AbstractBundler { throws PackagerException { // Order is important! - var pkg = WinFromParams.MSI_PACKAGE.fetchFrom(params); + var pkg = WinFromParams.EXE_PACKAGE.fetchFrom(params); var env = BuildEnvFromParams.BUILD_ENV.fetchFrom(params); - IOUtils.writableOutputDir(outdir); + var msiOutputDir = env.buildRoot().resolve("msi"); - Path msiDir = env.buildRoot().resolve("msi"); - toRunnable(() -> Files.createDirectories(msiDir)).run(); - - // Write msi to temporary directory. - Path msi = msiBundler.execute(params, msiDir); - - try { - new ScriptRunner() - .setDirectory(msi.getParent()) - .setResourceCategoryId("resource.post-msi-script") - .setScriptNameSuffix("post-msi") - .setEnvironmentVariable("JpMsiFile", msi.toAbsolutePath().toString()) - .run(env, pkg.packageName()); - - var exePkg = new WinExePackageBuilder(pkg).icon(ICON.fetchFrom(params)).create(); - return buildEXE(env, exePkg, msi, outdir); - } catch (IOException|ConfigException ex) { - Log.verbose(ex); - throw new PackagerException(ex); - } + return Packager.build().outputDir(msiOutputDir) + .pkg(pkg.msiPackage()) + .env(env) + .pipelineBuilderMutatorFactory((packagingEnv, msiPackage, _) -> { + var msiPackager = new WinMsiPackager(packagingEnv, msiPackage, + msiOutputDir, msiBundler.sysEnv.orElseThrow()); + var exePackager = new WinExePackager(packagingEnv, pkg, outdir, msiOutputDir); + return msiPackager.andThen(exePackager); + }).execute(WinPackagingPipeline.build()); } - private Path buildEXE(BuildEnv env, WinExePackage pkg, Path msi, - Path outdir) throws IOException { - - Log.verbose(I18N.format("message.outputting-to-location", outdir.toAbsolutePath())); - - // Copy template msi wrapper next to msi file - final Path exePath = msi.getParent().resolve(pkg.packageFileNameWithSuffix()); - - env.createResource("msiwrapper.exe") - .setCategory(I18N.getString("resource.installer-exe")) - .setPublicName("installer.exe") - .saveToFile(exePath); - - new ExecutableRebrander(pkg, env::createResource, resourceLock -> { - // Embed msi in msi wrapper exe. - embedMSI(resourceLock, msi.toAbsolutePath().toString()); - }).execute(env, exePath, pkg.icon()); - - Path dstExePath = outdir.resolve(exePath.getFileName()); - - Files.copy(exePath, dstExePath, StandardCopyOption.REPLACE_EXISTING); - - dstExePath.toFile().setExecutable(true); - - Log.verbose(I18N.format("message.output-location", outdir.toAbsolutePath())); - - return dstExePath; - } + static native int embedMSI(long resourceLock, String msiPath); private final WinMsiBundler msiBundler = new WinMsiBundler(); - - private static native int embedMSI(long resourceLock, String msiPath); } diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExePackager.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExePackager.java new file mode 100644 index 00000000000..9a13a0f954d --- /dev/null +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinExePackager.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Objects; +import java.util.function.Consumer; +import jdk.jpackage.internal.PackagingPipeline.PackageTaskID; +import jdk.jpackage.internal.PackagingPipeline.PrimaryTaskID; +import jdk.jpackage.internal.PackagingPipeline.TaskID; +import jdk.jpackage.internal.model.WinExePackage; + +final record WinExePackager(BuildEnv env, WinExePackage pkg, Path outputDir, Path msiOutputDir) implements Consumer { + + WinExePackager { + Objects.requireNonNull(env); + Objects.requireNonNull(pkg); + Objects.requireNonNull(outputDir); + Objects.requireNonNull(msiOutputDir); + } + + enum ExePackageTaskID implements TaskID { + RUN_POST_MSI_USER_SCRIPT, + WRAP_MSI_IN_EXE + } + + @Override + public void accept(PackagingPipeline.Builder pipelineBuilder) { + pipelineBuilder.excludeDirFromCopying(outputDir) + .task(ExePackageTaskID.RUN_POST_MSI_USER_SCRIPT) + .action(this::runPostMsiScript) + .addDependency(PackageTaskID.CREATE_PACKAGE_FILE) + .add() + .task(ExePackageTaskID.WRAP_MSI_IN_EXE) + .action(this::wrapMsiInExe) + .addDependency(ExePackageTaskID.RUN_POST_MSI_USER_SCRIPT) + .addDependent(PrimaryTaskID.PACKAGE) + .add(); + } + + private Path msi() { + return msiOutputDir.resolve(pkg.msiPackage().packageFileNameWithSuffix()); + } + + private void runPostMsiScript() throws IOException { + new ScriptRunner() + .setDirectory(msiOutputDir) + .setResourceCategoryId("resource.post-msi-script") + .setScriptNameSuffix("post-msi") + .setEnvironmentVariable("JpMsiFile", msi().toAbsolutePath().toString()) + .run(env, pkg.msiPackage().packageName()); + } + + private void wrapMsiInExe() throws IOException { + + Log.verbose(I18N.format("message.outputting-to-location", outputDir.toAbsolutePath())); + + final var msi = msi(); + + // Copy template msi wrapper next to msi file + final Path exePath = msi.getParent().resolve(pkg.packageFileNameWithSuffix()); + + env.createResource("msiwrapper.exe") + .setCategory(I18N.getString("resource.installer-exe")) + .setPublicName("installer.exe") + .saveToFile(exePath); + + new ExecutableRebrander(pkg, env::createResource, resourceLock -> { + // Embed msi in msi wrapper exe. + WinExeBundler.embedMSI(resourceLock, msi.toAbsolutePath().toString()); + }).execute(env, exePath, pkg.icon()); + + Path dstExePath = outputDir.resolve(exePath.getFileName()); + + Files.createDirectories(dstExePath.getParent()); + Files.copy(exePath, dstExePath, StandardCopyOption.REPLACE_EXISTING); + + dstExePath.toFile().setExecutable(true); + + Log.verbose(I18N.format("message.output-location", outputDir.toAbsolutePath())); + } +} diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromParams.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromParams.java index e2259535058..29c1b665f86 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromParams.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinFromParams.java @@ -31,6 +31,7 @@ import static jdk.jpackage.internal.FromParams.createApplicationBundlerParam; import static jdk.jpackage.internal.FromParams.createPackageBuilder; import static jdk.jpackage.internal.FromParams.createPackageBundlerParam; import static jdk.jpackage.internal.FromParams.findLauncherShortcut; +import static jdk.jpackage.internal.StandardBundlerParam.ICON; import static jdk.jpackage.internal.StandardBundlerParam.RESOURCE_DIR; import static jdk.jpackage.internal.WinPackagingPipeline.APPLICATION_LAYOUT; import static jdk.jpackage.internal.model.StandardPackageType.WIN_MSI; @@ -41,6 +42,7 @@ import java.util.Map; import java.util.UUID; import jdk.jpackage.internal.model.ConfigException; import jdk.jpackage.internal.model.WinApplication; +import jdk.jpackage.internal.model.WinExePackage; import jdk.jpackage.internal.model.WinLauncher; import jdk.jpackage.internal.model.WinLauncherMixin; import jdk.jpackage.internal.model.WinMsiPackage; @@ -99,12 +101,26 @@ final class WinFromParams { return pkgBuilder.create(); } + private static WinExePackage createWinExePackage(Map params) throws ConfigException, IOException { + + final var msiPkg = MSI_PACKAGE.fetchFrom(params); + + final var pkgBuilder = new WinExePackageBuilder(msiPkg); + + ICON.copyInto(params, pkgBuilder::icon); + + return pkgBuilder.create(); + } + static final BundlerParamInfo APPLICATION = createApplicationBundlerParam( WinFromParams::createWinApplication); static final BundlerParamInfo MSI_PACKAGE = createPackageBundlerParam( WinFromParams::createWinMsiPackage); + static final BundlerParamInfo EXE_PACKAGE = createPackageBundlerParam( + WinFromParams::createWinExePackage); + private static final BundlerParamInfo WIN_MENU_HINT = createStringBundlerParam( Arguments.CLIOptions.WIN_MENU_HINT.getId()); diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java index 9bd0c679146..3ca26f38f82 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiBundler.java @@ -27,115 +27,16 @@ 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; +import jdk.jpackage.internal.util.Result; -/** - * WinMsiBundler - * - * Produces .msi installer from application image. Uses WiX Toolkit to build - * .msi installer. - *

- * {@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: - *

    - *
  • main.wxs. Main source file with the installer description - *
  • bundle.wxf. Source file with application and Java run-time directory tree - * description. - *
  • ui.wxf. Source file with UI description of the installer. - *
- * - *

- * 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 {@link #execute} method is called - *
  • 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 - *
  • JpAllowUpgrades. Set to "yes", but all that matters is it is defined. - *
  • JpAllowDowngrades. Defined for application installers, and undefined for - * Java runtime installers. - *
  • 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 - *
  • JpAppSizeKb. Set to estimated size of the application in kilobytes - *
  • JpHelpURL. Set to value of --win-help-url command line option if it - * was specified. Undefined otherwise - *
  • JpAboutURL. Set to value of --about-url command line option if it - * was specified. Undefined otherwise - *
  • JpUpdateURL. Set to value of --win-update-url command line option if it - * was specified. Undefined otherwise - *
- * - *

- * 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: - *

    - *
  • JpLicenseRtf. Set to the value of --license-file command line option. - * Undefined if --license-file command line option was not specified - *
- */ 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()) - ).map(e -> { - e.getValue().setOutputFileName(e.getKey()); - return e.getValue(); - }).toList(); } @Override @@ -156,10 +57,12 @@ public class WinMsiBundler extends AbstractBundler { @Override public boolean supported(boolean platformInstaller) { try { - if (wixToolset == null) { - wixToolset = WixTool.createToolset(); + try { + sysEnv.orElseThrow(); + return true; + } catch (RuntimeException ex) { + ConfigException.rethrowConfigException(ex); } - return true; } catch (ConfigException ce) { Log.error(ce.getMessage()); if (ce.getAdvice() != null) { @@ -184,9 +87,7 @@ public class WinMsiBundler extends AbstractBundler { WinFromParams.APPLICATION.fetchFrom(params); BuildEnvFromParams.BUILD_ENV.fetchFrom(params); - if (wixToolset == null) { - wixToolset = WixTool.createToolset(); - } + final var wixToolset = sysEnv.orElseThrow().wixToolset(); for (var tool : wixToolset.getType().getTools()) { Log.verbose(I18N.format("message.tool-version", @@ -194,367 +95,23 @@ public class WinMsiBundler extends AbstractBundler { 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 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(); + return Packager.build().outputDir(outputParentDir) + .pkg(WinFromParams.MSI_PACKAGE.fetchFrom(params)) + .env(BuildEnvFromParams.BUILD_ENV.fetchFrom(params)) + .pipelineBuilderMutatorFactory((env, pkg, outputDir) -> { + return new WinMsiPackager(env, pkg, outputDir, sysEnv.orElseThrow()); + }).execute(WinPackagingPipeline.build()); } - private void prepareConfigFiles(PackageBuildEnv 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 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 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 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 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 createWixVars(PackageBuildEnv env) throws IOException { - Map 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 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 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 wixFragments; + final Result sysEnv = WinSystemEnvironment.create(); } diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiPackager.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiPackager.java new file mode 100644 index 00000000000..99b6c367fec --- /dev/null +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinMsiPackager.java @@ -0,0 +1,486 @@ +/* + * Copyright (c) 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 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.Objects; +import java.util.Set; +import java.util.function.Consumer; +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.model.AppImageLayout; +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; + +/** + * WinMsiPackager + * + * Produces .msi installer from application image. Uses WiX Toolkit to build + * .msi installer. + *

+ * 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. + *
  • ui.wxf. Source file with UI description of the installer. + *
+ * + *

+ * 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 {@link #execute} method is called + *
  • 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 + *
  • JpAllowUpgrades. Set to "yes", but all that matters is it is defined. + *
  • JpAllowDowngrades. Defined for application installers, and undefined for + * Java runtime installers. + *
  • 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 + *
  • JpAppSizeKb. Set to estimated size of the application in kilobytes + *
  • JpHelpURL. Set to value of --win-help-url command line option if it + * was specified. Undefined otherwise + *
  • JpAboutURL. Set to value of --about-url command line option if it + * was specified. Undefined otherwise + *
  • JpUpdateURL. Set to value of --win-update-url command line option if it + * was specified. Undefined otherwise + *
+ * + *

+ * 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: + *

    + *
  • JpLicenseRtf. Set to the value of --license-file command line option. + * Undefined if --license-file command line option was not specified + *
+ */ +final class WinMsiPackager implements Consumer { + + WinMsiPackager(BuildEnv env, WinMsiPackage pkg, Path outputDir, WixToolset wixToolset) { + this.pkg = Objects.requireNonNull(pkg); + this.env = Objects.requireNonNull(env); + this.outputDir = Objects.requireNonNull(outputDir); + this.wixToolset = Objects.requireNonNull(wixToolset); + + wixFragments = Stream.of( + Map.entry("bundle.wxf", new WixAppImageFragmentBuilder()), + Map.entry("ui.wxf", new WixUiFragmentBuilder()), + Map.entry("os-condition.wxf", OSVersionCondition.createWixFragmentBuilder()) + ).map(e -> { + e.getValue().setOutputFileName(e.getKey()); + return e.getValue(); + }).toList(); + + // Configure installer icon + if (env.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")).toAbsolutePath(); + } else { + installerIcon = env.asApplicationLayout().orElseThrow().launchersDirectory().resolve( + pkg.app().mainLauncher().orElseThrow().executableNameWithSuffix()).toAbsolutePath(); + } + + wixFragments.forEach(wixFragment -> wixFragment.setWixVersion(wixToolset.getVersion(), + wixToolset.getType())); + + wixFragments.stream().map(WixFragmentBuilder::getLoggableWixFeatures).flatMap( + List::stream).distinct().toList().forEach(Log::verbose); + } + + WinMsiPackager(BuildEnv env, WinMsiPackage pkg, Path outputDir, WinSystemEnvironment sysEnv) { + this(env, pkg, outputDir, sysEnv.wixToolset()); + } + + @Override + public void accept(PackagingPipeline.Builder pipelineBuilder) { + pipelineBuilder.excludeDirFromCopying(outputDir) + .task(PackagingPipeline.PackageTaskID.CREATE_CONFIG_FILES) + .action(this::prepareConfigFiles) + .add() + .task(PackagingPipeline.PackageTaskID.CREATE_PACKAGE_FILE) + .action(this::buildPackage) + .add(); + } + + private void prepareConfigFiles() throws PackagerException, IOException { + + 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); + }); + + for (var wixFragment : wixFragments) { + wixFragment.initFromParams(env, pkg); + wixFragment.addFilesToConfigRoot(); + } + + final var msiOut = outputDir.resolve(pkg.packageFileNameWithSuffix()); + + Log.verbose(I18N.format("message.preparing-msi-config", msiOut.toAbsolutePath())); + + final var wixVars = createWixVars(); + + final var wixObjDir = env.buildRoot().resolve("wixobj"); + + final var configDir = env.configDir(); + + final var wixPipelineBuilder = WixPipeline.build() + .setWixObjDir(wixObjDir) + .setWorkDir(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 (!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.createResource(name).setPublicName(name).setCategory( + I18N.getString("resource.wxl-file")), path); + } + + wixResources.addResource(env.createResource("main.wxs").setPublicName("main.wxs"). + setCategory(I18N.getString("resource.main-wix-file")), configDir.resolve("main.wxs")); + + wixResources.addResource(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 customWxlFiles = env.resourceDir() + .map(WinMsiPackager::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.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 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 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() throws PackagerException, IOException { + final var msiOut = outputDir.resolve(pkg.packageFileNameWithSuffix()); + Log.verbose(I18N.format("message.generating-msi", msiOut.toAbsolutePath())); + wixPipeline.buildMsi(msiOut.toAbsolutePath()); + } + + private Map createWixVars() throws IOException { + Map data = new HashMap<>(); + + 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.appImageLayout()).sizeInBytes() >> 10)); + + data.put("JpConfigDir", env.configDir().toAbsolutePath().toString()); + + if (pkg.isSystemWideInstall()) { + data.put("JpIsSystemWide", "yes"); + } + + return data; + } + + private static List 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 RuntimeException(I18N.format( + "error.extract-culture-from-wix-l10n-file", + wxlPath.toAbsolutePath().normalize())); + } + + return nodes.item(0).getNodeValue(); + } catch (XPathExpressionException | ParserConfigurationException | SAXException ex) { + throw new RuntimeException(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 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 final WinMsiPackage pkg; + private final BuildEnv env; + private final Path outputDir; + private final WixToolset wixToolset; + private final List wixFragments; + private final Path installerIcon; + private WixPipeline wixPipeline; +} diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinSystemEnvironment.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinSystemEnvironment.java new file mode 100644 index 00000000000..33b5a09a31b --- /dev/null +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WinSystemEnvironment.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 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.util.function.ThrowingSupplier.toSupplier; + +import java.util.Objects; +import jdk.jpackage.internal.util.Result; + +record WinSystemEnvironment(WixToolset wixToolset) implements SystemEnvironment { + + WinSystemEnvironment { + Objects.requireNonNull(wixToolset); + } + + static Result create() { + return Result.create(toSupplier(WixTool::createToolset)).map(WinSystemEnvironment::new); + } +} diff --git a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java index 7e4353876b4..6e72511e080 100644 --- a/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java +++ b/src/jdk.jpackage/windows/classes/jdk/jpackage/internal/WixTool.java @@ -183,13 +183,12 @@ public enum WixTool { final boolean[] tooOld = new boolean[1]; final String[] parsedVersion = new String[1]; - final var validator = new ToolValidator(toolPath).setMinimalVersion(tool.minimalVersion). - setToolNotFoundErrorHandler((name, ex) -> { - return new ConfigException("", ""); - }).setToolOldVersionErrorHandler((name, version) -> { - tooOld[0] = true; - return null; - }); + final var validator = new ToolValidator(toolPath) + .setMinimalVersion(tool.minimalVersion) + .setToolOldVersionErrorHandler((name, version) -> { + tooOld[0] = true; + return null; + }); final Function, String> versionParser; diff --git a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ToolValidatorTest.java b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ToolValidatorTest.java index c0acea5e3e7..b3660383e47 100644 --- a/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ToolValidatorTest.java +++ b/test/jdk/tools/jpackage/junit/share/jdk.jpackage/jdk/jpackage/internal/ToolValidatorTest.java @@ -23,33 +23,141 @@ package jdk.jpackage.internal; -import jdk.jpackage.internal.model.DottedVersion; -import jdk.jpackage.internal.model.ConfigException; -import java.nio.file.Path; -import jdk.internal.util.OperatingSystem; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import jdk.internal.util.OperatingSystem; +import jdk.jpackage.internal.model.ConfigException; +import jdk.jpackage.internal.model.DottedVersion; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; public class ToolValidatorTest { - @Test - public void testAvailable() { - assertNull(new ToolValidator(TOOL_JAVA).validate()); + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testAvailable(boolean checkExistsOnly) { + assertNull(new ToolValidator(TOOL_JAVA).checkExistsOnly(checkExistsOnly).validate()); } @Test - public void testNotAvailable() { - assertValidationFailure(new ToolValidator(TOOL_UNKNOWN).validate(), true); + public void testAvailable_setCommandLine() { + // java doesn't recognize "--foo" command line option, but the validation will + // still pass as there is no minimal version specified and the validator ignores + // the exit code + assertNull(new ToolValidator(TOOL_JAVA).setCommandLine("--foo").validate()); + } + + enum TestAvailableMode { + NO_VERSION(null), + TOO_OLD("0.9"), + EQUALS("1.0"), + NEWER("1.1"); + + TestAvailableMode(String parsedVersion) { + this.parsedVersion = parsedVersion; + } + + final String parsedVersion; + } + + @ParameterizedTest + @EnumSource(TestAvailableMode.class) + public void testAvailable(TestAvailableMode mode) { + var minVer = TestAvailableMode.EQUALS.parsedVersion; + var err = new ToolValidator(TOOL_JAVA).setVersionParser(lines -> { + return mode.parsedVersion; + }).setMinimalVersion(DottedVersion.greedy(minVer)).validate(); + + if (Set.of(TestAvailableMode.NO_VERSION, TestAvailableMode.TOO_OLD).contains(mode)) { + var expectedMessage = I18N.format("error.tool-old-version", TOOL_JAVA, minVer); + var expectedAdvice = I18N.format("error.tool-old-version.advice", TOOL_JAVA, minVer); + + assertEquals(expectedMessage, err.getMessage()); + assertEquals(expectedAdvice, err.getAdvice()); + } else { + assertNull(err); + } + } + + @ParameterizedTest + @EnumSource(TestAvailableMode.class) + public void testAvailable_setToolOldVersionErrorHandler(TestAvailableMode mode) { + var handler = new ToolOldVersionErrorHandler(); + var minVer = TestAvailableMode.EQUALS.parsedVersion; + var err = new ToolValidator(TOOL_JAVA).setVersionParser(lines -> { + return mode.parsedVersion; + }).setMinimalVersion(DottedVersion.greedy(minVer)).setToolOldVersionErrorHandler(handler).validate(); + + if (Set.of(TestAvailableMode.NO_VERSION, TestAvailableMode.TOO_OLD).contains(mode)) { + assertSame(ToolOldVersionErrorHandler.ERR, err); + handler.verifyCalled(Path.of(TOOL_JAVA), mode.parsedVersion); + } else { + assertNull(err); + handler.verifyNotCalled(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testNotAvailable(boolean checkExistsOnly, @TempDir Path dir) { + var err = new ToolValidator(dir.resolve("foo")).checkExistsOnly(checkExistsOnly).validate(); + if (checkExistsOnly) { + assertValidationFailure(err, false); + } else { + assertValidationFailureNoAdvice(err, !checkExistsOnly); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testToolIsDirectory(boolean checkExistsOnly, @TempDir Path dir) { + var err = new ToolValidator(dir).checkExistsOnly(checkExistsOnly).validate(); + assertValidationFailureNoAdvice(err, !checkExistsOnly); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testNotAvailable_setToolNotFoundErrorHandler(boolean checkExistsOnly, @TempDir Path dir) { + var handler = new ToolNotFoundErrorHandler(); + var err = new ToolValidator(dir.resolve("foo")).checkExistsOnly(checkExistsOnly) + .setToolNotFoundErrorHandler(handler) + .validate(); + if (checkExistsOnly) { + handler.verifyCalled(dir.resolve("foo")); + assertSame(ToolNotFoundErrorHandler.ERR, err); + } else { + handler.verifyNotCalled(); + assertValidationFailureNoAdvice(err, !checkExistsOnly); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testToolIsDirectory_setToolNotFoundErrorHandler(boolean checkExistsOnly, @TempDir Path dir) { + var handler = new ToolNotFoundErrorHandler(); + var err = new ToolValidator(dir).checkExistsOnly(checkExistsOnly).validate(); + handler.verifyNotCalled(); + assertValidationFailureNoAdvice(err, !checkExistsOnly); } @Test public void testVersionParserUsage() { // Without minimal version configured, version parser should not be used new ToolValidator(TOOL_JAVA).setVersionParser(unused -> { - throw new RuntimeException(); + throw new AssertionError(); }).validate(); // Minimal version is 1, actual is 10. Should be OK. @@ -81,9 +189,68 @@ public class ToolValidatorTest { } } + private static void assertValidationFailureNoAdvice(ConfigException v, boolean withCause) { + assertNotNull(v); + assertNotEquals("", v.getMessage().strip()); + assertNull(v.getAdvice()); + if (withCause) { + assertNotNull(v.getCause()); + } else { + assertNull(v.getCause()); + } + } + + + private static final class ToolNotFoundErrorHandler implements Function { + + @Override + public ConfigException apply(Path tool) { + assertNotNull(tool); + this.tool = tool; + return ERR; + } + + void verifyCalled(Path expectedTool) { + assertEquals(Objects.requireNonNull(expectedTool), tool); + } + + void verifyNotCalled() { + assertNull(tool); + } + + private Path tool; + + static final ConfigException ERR = new ConfigException("no tool", "install the tool"); + } + + + private static final class ToolOldVersionErrorHandler implements BiFunction { + + @Override + public ConfigException apply(Path tool, String parsedVersion) { + assertNotNull(tool); + this.tool = tool; + this.parsedVersion = parsedVersion; + return ERR; + } + + void verifyCalled(Path expectedTool, String expectedParsedVersion) { + assertEquals(Objects.requireNonNull(expectedTool), tool); + assertEquals(expectedParsedVersion, parsedVersion); + } + + void verifyNotCalled() { + assertNull(tool); + } + + private Path tool; + private String parsedVersion; + + static final ConfigException ERR = new ConfigException("tool too old", "install the newer version"); + } + + private static final String TOOL_JAVA; - private static final String TOOL_UNKNOWN = Path.of(System.getProperty( - "java.home"), "bin").toString(); static { String fname = "java";