8370100: Redundant .png files in Linux app-image cause unnecessary bloat

Reviewed-by: almatvee
This commit is contained in:
Alexey Semenyuk 2025-10-28 03:10:19 +00:00
parent 460a69bd50
commit 327b7c3cd8
25 changed files with 515 additions and 133 deletions

View File

@ -26,12 +26,10 @@ package jdk.jpackage.internal;
import static jdk.jpackage.internal.ApplicationImageUtils.createLauncherIconResource;
import static jdk.jpackage.internal.model.LauncherShortcut.toRequest;
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;
@ -82,22 +80,12 @@ final class DesktopIntegration extends ShellCustomAction {
// - user explicitly requested to create a shortcut
boolean withDesktopFile = !associations.isEmpty() || toRequest(launcher.shortcut()).orElse(false);
var curIconResource = createLauncherIconResource(pkg.app(), launcher,
env::createResource);
if (curIconResource.isEmpty()) {
if (!launcher.hasIcon()) {
// This is additional launcher with explicit `no icon` configuration.
withDesktopFile = false;
} else {
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);
}
} else if (launcher.hasCustomIcon()) {
// This launcher has custom icon configured.
withDesktopFile = true;
}
desktopFileResource = env.createResource("template.desktop")
@ -119,17 +107,12 @@ final class DesktopIntegration extends ShellCustomAction {
if (withDesktopFile) {
desktopFile = Optional.of(createDesktopFile(desktopFileName));
iconFile = Optional.of(createDesktopFile(escapedAppFileName + ".png"));
if (curIconResource.isEmpty()) {
// Create default icon.
curIconResource = createLauncherIconResource(pkg.app(), pkg.app().mainLauncher().orElseThrow(), env::createResource);
}
} else {
desktopFile = Optional.empty();
iconFile = Optional.empty();
}
iconResource = curIconResource;
iconResource = createLauncherIconResource(launcher, env::createResource);
desktopFileData = createDataForDesktopFile();

View File

@ -25,13 +25,15 @@
package jdk.jpackage.internal;
import java.util.Optional;
public class LinuxAppBundler extends AppImageBundler {
public LinuxAppBundler() {
setAppImageSupplier((params, output) -> {
// Order is important!
var app = LinuxFromParams.APPLICATION.fetchFrom(params);
var env = BuildEnvFromParams.BUILD_ENV.fetchFrom(params);
LinuxPackagingPipeline.build()
LinuxPackagingPipeline.build(Optional.empty())
.excludeDirFromCopying(output.getParent())
.create().execute(BuildEnv.withAppImageDir(env, output), app);
});

View File

@ -27,6 +27,7 @@ package jdk.jpackage.internal;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import jdk.jpackage.internal.model.LinuxDebPackage;
import jdk.jpackage.internal.model.PackagerException;
import jdk.jpackage.internal.model.StandardPackageType;
@ -51,12 +52,14 @@ public class LinuxDebBundler extends LinuxPackageBundler {
@Override
public Path execute(Map<String, ? super Object> params, Path outputParentDir) throws PackagerException {
var pkg = LinuxFromParams.DEB_PACKAGE.fetchFrom(params);
return Packager.<LinuxDebPackage>build().outputDir(outputParentDir)
.pkg(LinuxFromParams.DEB_PACKAGE.fetchFrom(params))
.pkg(pkg)
.env(BuildEnvFromParams.BUILD_ENV.fetchFrom(params))
.pipelineBuilderMutatorFactory((env, pkg, outputDir) -> {
.pipelineBuilderMutatorFactory((env, _, outputDir) -> {
return new LinuxDebPackager(env, pkg, outputDir, sysEnv.orElseThrow());
}).execute(LinuxPackagingPipeline.build());
}).execute(LinuxPackagingPipeline.build(Optional.of(pkg)));
}
@Override

View File

@ -43,6 +43,7 @@ import jdk.jpackage.internal.model.LinuxDebPackage;
import jdk.jpackage.internal.model.LinuxLauncher;
import jdk.jpackage.internal.model.LinuxLauncherMixin;
import jdk.jpackage.internal.model.LinuxRpmPackage;
import jdk.jpackage.internal.model.Launcher;
import jdk.jpackage.internal.model.StandardPackageType;
final class LinuxFromParams {
@ -55,7 +56,9 @@ final class LinuxFromParams {
final var launcher = launcherFromParams.create(launcherParams);
final var shortcut = findLauncherShortcut(LINUX_SHORTCUT_HINT, params, launcherParams);
return LinuxLauncher.create(launcher, new LinuxLauncherMixin.Stub(shortcut));
}), APPLICATION_LAYOUT).create();
}), (LinuxLauncher linuxLauncher, Launcher launcher) -> {
return LinuxLauncher.create(launcher, linuxLauncher);
}, APPLICATION_LAYOUT).create();
return LinuxApplication.create(app);
}

View File

@ -25,17 +25,20 @@
package jdk.jpackage.internal;
import static jdk.jpackage.internal.ApplicationImageUtils.createLauncherIconResource;
import jdk.jpackage.internal.PackagingPipeline.AppImageBuildEnv;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import jdk.jpackage.internal.PackagingPipeline.AppImageBuildEnv;
import jdk.jpackage.internal.PackagingPipeline.BuildApplicationTaskID;
import jdk.jpackage.internal.PackagingPipeline.PrimaryTaskID;
import jdk.jpackage.internal.PackagingPipeline.TaskID;
import jdk.jpackage.internal.model.Application;
import jdk.jpackage.internal.model.ApplicationLayout;
import jdk.jpackage.internal.model.Launcher;
import jdk.jpackage.internal.model.LinuxPackage;
import jdk.jpackage.internal.resources.ResourceLocator;
final class LinuxPackagingPipeline {
@ -45,14 +48,20 @@ final class LinuxPackagingPipeline {
LAUNCHER_ICONS
}
static PackagingPipeline.Builder build() {
return PackagingPipeline.buildStandard()
static PackagingPipeline.Builder build(Optional<LinuxPackage> pkg) {
var builder = PackagingPipeline.buildStandard()
.task(LinuxAppImageTaskID.LAUNCHER_LIB)
.addDependent(PrimaryTaskID.BUILD_APPLICATION_IMAGE)
.applicationAction(LinuxPackagingPipeline::writeLauncherLib).add()
.task(LinuxAppImageTaskID.LAUNCHER_ICONS)
.addDependent(BuildApplicationTaskID.CONTENT)
.applicationAction(LinuxPackagingPipeline::writeLauncherIcons).add();
pkg.ifPresent(_ -> {
builder.task(LinuxAppImageTaskID.LAUNCHER_ICONS).noaction().add();
});
return builder;
}
private static void writeLauncherLib(
@ -68,8 +77,8 @@ final class LinuxPackagingPipeline {
private static void writeLauncherIcons(
AppImageBuildEnv<Application, ApplicationLayout> env) throws IOException {
for (var launcher : env.app().launchers()) {
createLauncherIconResource(env.app(), launcher, env.env()::createResource).ifPresent(iconResource -> {
env.app().launchers().stream().filter(Launcher::hasCustomIcon).forEach(launcher -> {
createLauncherIconResource(launcher, env.env()::createResource).ifPresent(iconResource -> {
String iconFileName = launcher.executableName() + ".png";
Path iconTarget = env.resolvedLayout().desktopIntegrationDirectory().resolve(iconFileName);
try {
@ -78,7 +87,7 @@ final class LinuxPackagingPipeline {
throw new UncheckedIOException(ex);
}
});
}
});
}
static final LinuxApplicationLayout APPLICATION_LAYOUT = LinuxApplicationLayout.create(

View File

@ -27,6 +27,7 @@ package jdk.jpackage.internal;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import jdk.jpackage.internal.model.LinuxRpmPackage;
import jdk.jpackage.internal.model.PackagerException;
import jdk.jpackage.internal.model.StandardPackageType;
@ -52,12 +53,14 @@ public class LinuxRpmBundler extends LinuxPackageBundler {
@Override
public Path execute(Map<String, ? super Object> params, Path outputParentDir) throws PackagerException {
var pkg = LinuxFromParams.RPM_PACKAGE.fetchFrom(params);
return Packager.<LinuxRpmPackage>build().outputDir(outputParentDir)
.pkg(LinuxFromParams.RPM_PACKAGE.fetchFrom(params))
.pkg(pkg)
.env(BuildEnvFromParams.BUILD_ENV.fetchFrom(params))
.pipelineBuilderMutatorFactory((env, pkg, outputDir) -> {
.pipelineBuilderMutatorFactory((env, _, outputDir) -> {
return new LinuxRpmPackager(env, pkg, outputDir, sysEnv.orElseThrow());
}).execute(LinuxPackagingPipeline.build());
}).execute(LinuxPackagingPipeline.build(Optional.of(pkg)));
}
@Override

View File

@ -92,7 +92,9 @@ final class MacFromParams {
final var superAppBuilder = createApplicationBuilder(params, toFunction(launcherParams -> {
var launcher = launcherFromParams.create(launcherParams);
return MacLauncher.create(launcher);
}), APPLICATION_LAYOUT, RUNTIME_BUNDLE_LAYOUT, predefinedRuntimeLayout.map(RuntimeLayout::unresolve));
}), (MacLauncher _, Launcher launcher) -> {
return MacLauncher.create(launcher);
}, APPLICATION_LAYOUT, RUNTIME_BUNDLE_LAYOUT, predefinedRuntimeLayout.map(RuntimeLayout::unresolve));
if (hasPredefinedAppImage(params)) {
// Set the main launcher start up info.

View File

@ -32,7 +32,9 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;
import jdk.jpackage.internal.model.AppImageLayout;
import jdk.jpackage.internal.model.Application;
import jdk.jpackage.internal.model.ApplicationLaunchers;
@ -40,7 +42,9 @@ import jdk.jpackage.internal.model.ConfigException;
import jdk.jpackage.internal.model.ExternalApplication;
import jdk.jpackage.internal.model.ExternalApplication.LauncherInfo;
import jdk.jpackage.internal.model.Launcher;
import jdk.jpackage.internal.model.LauncherIcon;
import jdk.jpackage.internal.model.LauncherStartupInfo;
import jdk.jpackage.internal.model.ResourceDirLauncherIcon;
import jdk.jpackage.internal.model.RuntimeBuilder;
final class ApplicationBuilder {
@ -168,6 +172,97 @@ final class ApplicationBuilder {
return this;
}
static <T extends Launcher> ApplicationLaunchers normalizeIcons(
ApplicationLaunchers appLaunchers, Optional<Path> resourceDir, BiFunction<T, Launcher, T> launcherOverrideCtor) {
Objects.requireNonNull(resourceDir);
return normalizeLauncherProperty(appLaunchers, Launcher::hasDefaultIcon, (T launcher) -> {
return resourceDir.<LauncherIcon>flatMap(dir -> {
var resource = LauncherBuilder.createLauncherIconResource(launcher, _ -> {
return new OverridableResource()
.setResourceDir(dir)
.setSourceOrder(OverridableResource.Source.ResourceDir);
});
if (resource.probe() == OverridableResource.Source.ResourceDir) {
return Optional.of(ResourceDirLauncherIcon.create(resource.getPublicName().toString()));
} else {
return Optional.empty();
}
});
}, launcher -> {
return launcher.icon().orElseThrow();
}, (launcher, icon) -> {
return launcherOverrideCtor.apply(launcher, overrideIcon(launcher, icon));
});
}
static <T, U extends Launcher> ApplicationLaunchers normalizeLauncherProperty(
ApplicationLaunchers appLaunchers,
Predicate<U> needsNormalization,
Function<U, Optional<T>> normalizedPropertyValueFinder,
BiFunction<U, T, U> propertyOverrider) {
return normalizeLauncherProperty(
appLaunchers,
needsNormalization,
normalizedPropertyValueFinder,
launcher -> {
return normalizedPropertyValueFinder.apply(launcher).orElseThrow();
},
propertyOverrider);
}
static <T, U extends Launcher> ApplicationLaunchers normalizeLauncherProperty(
ApplicationLaunchers appLaunchers,
Predicate<U> needsNormalization,
Function<U, Optional<T>> normalizedPropertyValueFinder,
Function<U, T> normalizedPropertyValueGetter,
BiFunction<U, T, U> propertyOverrider) {
Objects.requireNonNull(appLaunchers);
Objects.requireNonNull(needsNormalization);
Objects.requireNonNull(normalizedPropertyValueFinder);
Objects.requireNonNull(normalizedPropertyValueGetter);
Objects.requireNonNull(propertyOverrider);
boolean[] modified = new boolean[1];
@SuppressWarnings("unchecked")
var newLaunchers = appLaunchers.asList().stream().map(launcher -> {
return (U)launcher;
}).map(launcher -> {
if (needsNormalization.test(launcher)) {
return normalizedPropertyValueFinder.apply(launcher).map(normalizedPropertyValue -> {
modified[0] = true;
return propertyOverrider.apply(launcher, normalizedPropertyValue);
}).orElse(launcher);
} else {
return launcher;
}
}).toList();
var newMainLauncher = newLaunchers.getFirst();
if (!needsNormalization.test(newMainLauncher)) {
// The main launcher doesn't require normalization.
newLaunchers = newLaunchers.stream().map(launcher -> {
if (needsNormalization.test(launcher)) {
var normalizedPropertyValue = normalizedPropertyValueGetter.apply(newMainLauncher);
modified[0] = true;
return propertyOverrider.apply(launcher, normalizedPropertyValue);
} else {
return launcher;
}
}).toList();
}
if (modified[0]) {
return ApplicationLaunchers.fromList(newLaunchers).orElseThrow();
} else {
return appLaunchers;
}
}
static Launcher overrideLauncherStartupInfo(Launcher launcher, LauncherStartupInfo startupInfo) {
return new Launcher.Stub(
launcher.name(),
@ -195,6 +290,18 @@ final class ApplicationBuilder {
app.extraAppImageFileData());
}
private static Launcher overrideIcon(Launcher launcher, LauncherIcon icon) {
return new Launcher.Stub(
launcher.name(),
launcher.startupInfo(),
launcher.fileAssociations(),
launcher.isService(),
launcher.description(),
Optional.of(icon),
launcher.defaultIconResourceName(),
launcher.extraAppImageFileData());
}
record MainLauncherStartupInfo(String qualifiedClassName) implements LauncherStartupInfo {
@Override
public List<String> javaOptions() {

View File

@ -26,7 +26,6 @@
package jdk.jpackage.internal;
import static jdk.jpackage.internal.util.function.ThrowingConsumer.toConsumer;
import static jdk.jpackage.internal.util.function.ThrowingSupplier.toSupplier;
import java.io.IOException;
import java.nio.file.Files;
@ -43,44 +42,36 @@ import jdk.jpackage.internal.PackagingPipeline.ApplicationImageTaskAction;
import jdk.jpackage.internal.model.Application;
import jdk.jpackage.internal.model.ApplicationLayout;
import jdk.jpackage.internal.model.CustomLauncherIcon;
import jdk.jpackage.internal.model.DefaultLauncherIcon;
import jdk.jpackage.internal.model.Launcher;
import jdk.jpackage.internal.model.ResourceDirLauncherIcon;
import jdk.jpackage.internal.util.FileUtils;
import jdk.jpackage.internal.util.PathUtils;
final class ApplicationImageUtils {
static Optional<OverridableResource> createLauncherIconResource(Application app,
Launcher launcher,
static Optional<OverridableResource> createLauncherIconResource(Launcher launcher,
Function<String, OverridableResource> resourceSupplier) {
final String defaultIconName = launcher.defaultIconResourceName();
final String resourcePublicName = launcher.executableName() + PathUtils.getSuffix(Path.of(defaultIconName));
if (!launcher.hasIcon()) {
return Optional.empty();
}
return launcher.icon().map(icon -> {
var resource = LauncherBuilder.createLauncherIconResource(launcher, resourceSupplier);
OverridableResource resource = resourceSupplier.apply(defaultIconName)
.setCategory("icon")
.setPublicName(resourcePublicName);
launcher.icon().flatMap(CustomLauncherIcon::fromLauncherIcon).map(CustomLauncherIcon::path).ifPresent(resource::setExternal);
if (launcher.hasDefaultIcon() && app.mainLauncher().orElseThrow() != launcher) {
// No icon explicitly configured for this launcher.
// Dry-run resource creation to figure out its source.
final Path nullPath = null;
if (toSupplier(() -> resource.saveToFile(nullPath)).get() != OverridableResource.Source.ResourceDir) {
// No icon in resource dir for this launcher, inherit icon
// configured for the main launcher.
return createLauncherIconResource(
app, app.mainLauncher().orElseThrow(),
resourceSupplier
).map(r -> r.setLogPublicName(resourcePublicName));
switch (icon) {
case DefaultLauncherIcon _ -> {
resource.setSourceOrder(OverridableResource.Source.DefaultResource);
}
case ResourceDirLauncherIcon v -> {
resource.setSourceOrder(OverridableResource.Source.ResourceDir);
resource.setPublicName(v.name());
}
case CustomLauncherIcon v -> {
resource.setSourceOrder(OverridableResource.Source.External);
resource.setExternal(v.path());
}
}
}
return Optional.of(resource);
return resource;
});
}
static ApplicationImageTaskAction<Application, ApplicationLayout> createWriteRuntimeAction() {

View File

@ -47,6 +47,7 @@ import static jdk.jpackage.internal.StandardBundlerParam.NAME;
import static jdk.jpackage.internal.StandardBundlerParam.PREDEFINED_APP_IMAGE;
import static jdk.jpackage.internal.StandardBundlerParam.PREDEFINED_APP_IMAGE_FILE;
import static jdk.jpackage.internal.StandardBundlerParam.PREDEFINED_RUNTIME_IMAGE;
import static jdk.jpackage.internal.StandardBundlerParam.RESOURCE_DIR;
import static jdk.jpackage.internal.StandardBundlerParam.SOURCE_DIR;
import static jdk.jpackage.internal.StandardBundlerParam.VENDOR;
import static jdk.jpackage.internal.StandardBundlerParam.VERSION;
@ -60,6 +61,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Function;
import jdk.jpackage.internal.model.Application;
import jdk.jpackage.internal.model.ApplicationLaunchers;
@ -76,14 +78,16 @@ import jdk.jpackage.internal.util.function.ThrowingFunction;
final class FromParams {
static ApplicationBuilder createApplicationBuilder(Map<String, ? super Object> params,
static <T extends Launcher> ApplicationBuilder createApplicationBuilder(Map<String, ? super Object> params,
Function<Map<String, ? super Object>, Launcher> launcherMapper,
BiFunction<T, Launcher, T> launcherOverrideCtor,
ApplicationLayout appLayout) throws ConfigException, IOException {
return createApplicationBuilder(params, launcherMapper, appLayout, RuntimeLayout.DEFAULT, Optional.of(RuntimeLayout.DEFAULT));
return createApplicationBuilder(params, launcherMapper, launcherOverrideCtor, appLayout, RuntimeLayout.DEFAULT, Optional.of(RuntimeLayout.DEFAULT));
}
static ApplicationBuilder createApplicationBuilder(Map<String, ? super Object> params,
static <T extends Launcher> ApplicationBuilder createApplicationBuilder(Map<String, ? super Object> params,
Function<Map<String, ? super Object>, Launcher> launcherMapper,
BiFunction<T, Launcher, T> launcherOverrideCtor,
ApplicationLayout appLayout, RuntimeLayout runtimeLayout,
Optional<RuntimeLayout> predefinedRuntimeLayout) throws ConfigException, IOException {
@ -133,7 +137,9 @@ final class FromParams {
jlinkOptionsBuilder.apply();
});
appBuilder.launchers(launchers).runtimeBuilder(runtimeBuilderBuilder.create());
final var normalizedLaunchers = ApplicationBuilder.normalizeIcons(launchers, RESOURCE_DIR.findIn(params), launcherOverrideCtor);
appBuilder.launchers(normalizedLaunchers).runtimeBuilder(runtimeBuilderBuilder.create());
}
}

View File

@ -32,14 +32,18 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import jdk.internal.util.OperatingSystem;
import jdk.jpackage.internal.model.ConfigException;
import jdk.jpackage.internal.model.CustomLauncherIcon;
import jdk.jpackage.internal.model.DefaultLauncherIcon;
import jdk.jpackage.internal.model.FileAssociation;
import jdk.jpackage.internal.model.Launcher;
import jdk.jpackage.internal.model.Launcher.Stub;
import jdk.jpackage.internal.util.PathUtils;
import jdk.jpackage.internal.model.LauncherIcon;
import jdk.jpackage.internal.model.LauncherStartupInfo;
import jdk.jpackage.internal.model.ResourceDirLauncherIcon;
final class LauncherBuilder {
@ -110,6 +114,15 @@ final class LauncherBuilder {
return Optional.ofNullable(name).orElseGet(() -> startupInfo.simpleClassName());
}
static OverridableResource createLauncherIconResource(Launcher launcher,
Function<String, OverridableResource> resourceSupplier) {
var defaultIconResourceName = launcher.defaultIconResourceName();
return resourceSupplier.apply(defaultIconResourceName)
.setPublicName(launcher.executableName() + PathUtils.getSuffix(Path.of(defaultIconResourceName)))
.setCategory("icon");
}
static void validateIcon(Path icon) throws ConfigException {
switch (OperatingSystem.current()) {
case WINDOWS -> {

View File

@ -29,11 +29,11 @@ import java.util.Objects;
import java.util.Optional;
/**
* Custom application launcher icon.
* Custom application launcher icon sourced from an external file.
* <p>
* Use {@link #create(Path)} method to create an instance of this type.
*/
public interface CustomLauncherIcon extends LauncherIcon {
public sealed interface CustomLauncherIcon extends LauncherIcon {
/**
* Returns path to icon file.

View File

@ -29,11 +29,11 @@ import java.util.Optional;
/**
* Default application launcher icon.
* <p>
* Default icon is either loaded from the resources of {@link jdk.jpackage} module or picked from the resource directory.
* Default icon is loaded from the resources of {@link jdk.jpackage} module.
* <p>
* Use {@link #INSTANCE} field to get an instance of this type.
*/
public interface DefaultLauncherIcon extends LauncherIcon {
public sealed interface DefaultLauncherIcon extends LauncherIcon {
/**
* Returns the given icon as {@link DefaultLauncherIcon} type or an empty {@link Optional} instance
@ -53,5 +53,9 @@ public interface DefaultLauncherIcon extends LauncherIcon {
/**
* Singleton.
*/
public static DefaultLauncherIcon INSTANCE = new DefaultLauncherIcon() {};
public static DefaultLauncherIcon INSTANCE = new Details.Impl();
static final class Details {
private static final class Impl implements DefaultLauncherIcon {}
}
}

View File

@ -151,16 +151,18 @@ public interface Launcher {
}
/**
* Returns <code>true</code> if this launcher has a custom icon.
* Returns <code>true</code> if this launcher has non-default icon.
* <p>
* A custom icon can be sourced from an external file or from the resource directory.
*
* @return <code>true</code> if this launcher has a custom icon
* @return <code>true</code> if this launcher has non-default icon
* @see CustomLauncherIcon
* @see #icon()
* @see #hasDefaultIcon()
* @see #hasIcon()
*/
default boolean hasCustomIcon() {
return icon().flatMap(CustomLauncherIcon::fromLauncherIcon).isPresent();
return !hasDefaultIcon() && icon().isPresent();
}
/**

View File

@ -27,5 +27,5 @@ package jdk.jpackage.internal.model;
/**
* Application launcher icon.
*/
public interface LauncherIcon {
public sealed interface LauncherIcon permits DefaultLauncherIcon, ResourceDirLauncherIcon, CustomLauncherIcon {
}

View File

@ -0,0 +1,73 @@
/*
* 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.model;
import java.util.Objects;
import java.util.Optional;
/**
* Custom application launcher icon sourced from the resource directory.
* <p>
* Use {@link #create(String)} method to create an instance of this type.
*/
public sealed interface ResourceDirLauncherIcon extends LauncherIcon {
/**
* Returns name of the resource referencing an icon file in the resource directory.
* @return name of the resource referencing an icon file in the resource directory
*/
String name();
/**
* Returns the given icon as {@link ResourceDirLauncherIcon} type or an empty {@link Optional} instance
* if the given icon object is not an instance of {@link ResourceDirLauncherIcon} type.
*
* @param icon application launcher icon object or <code>null</null>
* @return the given icon as {@link ResourceDirLauncherIcon} type or an empty {@link Optional} instance
*/
public static Optional<ResourceDirLauncherIcon> fromLauncherIcon(LauncherIcon icon) {
if (icon instanceof ResourceDirLauncherIcon customIcon) {
return Optional.of(customIcon);
} else {
return Optional.empty();
}
}
/**
* Creates object of type {@link ResourceDirLauncherIcon} from the name of the resource referencing an icon file in the resource directory.
* @param name name of the resource referencing an icon file in the resource directory
* @return {@link ResourceDirLauncherIcon} instance
*/
public static ResourceDirLauncherIcon create(String name) {
Objects.requireNonNull(name);
return new Stub(name);
}
/**
* Default implementation of {@link ResourceDirLauncherIcon} type.
*/
record Stub(String name) implements ResourceDirLauncherIcon {
}
}

View File

@ -25,14 +25,17 @@
package jdk.jpackage.internal.util;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -291,26 +294,119 @@ public final class CompositeProxy {
return proxy;
}
private static Map<Class<?>, Object> createInterfaceDispatch(Class<?>[] interfaces, Object[] slices) {
private record InterfaceDispatchBuilder(Set<? extends Class<?>> interfaces, Collection<Object> slices) {
final Map<Class<?>, Object> interfaceDispatch = Stream.of(interfaces).collect(toMap(x -> x, iface -> {
return Stream.of(slices).filter(obj -> {
return Set.of(obj.getClass().getInterfaces()).contains(iface);
}).reduce((a, b) -> {
throw new IllegalArgumentException(
String.format("both [%s] and [%s] slices implement %s", a, b, iface));
}).orElseThrow(() -> createInterfaceNotImplementedException(List.of(iface)));
}));
InterfaceDispatchBuilder {
Objects.requireNonNull(interfaces);
Objects.requireNonNull(slices);
if (interfaceDispatch.size() != interfaces.length) {
final List<Class<?>> missingInterfaces = new ArrayList<>(Set.of(interfaces));
missingInterfaces.removeAll(interfaceDispatch.keySet());
throw createInterfaceNotImplementedException(missingInterfaces);
if (interfaces.isEmpty()) {
throw new IllegalArgumentException("No interfaces to dispatch");
}
if (slices.isEmpty()) {
throw createInterfaceNotImplementedException(interfaces);
}
}
return Stream.of(interfaces).flatMap(iface -> {
InterfaceDispatchBuilder(Result result) {
this(result.unservedInterfaces(), result.unusedSlices());
}
Map<? extends Class<?>, List<Object>> createDispatchGroups() {
return interfaces.stream().collect(toMap(x -> x, iface -> {
return slices.stream().filter(obj -> {
return Stream.of(obj.getClass().getInterfaces()).flatMap(sliceIface -> {
return unfoldInterface(sliceIface);
}).anyMatch(Predicate.isEqual(iface));
}).toList();
}));
}
Result createDispatch() {
var groups = createDispatchGroups();
var dispatch = groups.entrySet().stream().filter(e -> {
return e.getValue().size() == 1;
}).collect(toMap(Map.Entry::getKey, e -> {
return e.getValue().getFirst();
}));
var unservedInterfaces = groups.entrySet().stream().filter(e -> {
return e.getValue().size() != 1;
}).map(Map.Entry::getKey).collect(toSet());
var usedSliceIdentities = dispatch.values().stream()
.map(IdentityWrapper::new)
.collect(toSet());
var unusedSliceIdentities = new HashSet<>(toIdentitySet(slices));
unusedSliceIdentities.removeAll(usedSliceIdentities);
return new Result(dispatch, unservedInterfaces, unusedSliceIdentities.stream().map(IdentityWrapper::value).toList());
}
private record Result(Map<? extends Class<?>, Object> dispatch, Set<? extends Class<?>> unservedInterfaces, Collection<Object> unusedSlices) {
Result {
Objects.requireNonNull(dispatch);
Objects.requireNonNull(unservedInterfaces);
Objects.requireNonNull(unusedSlices);
if (!Collections.disjoint(dispatch.keySet(), unservedInterfaces)) {
throw new IllegalArgumentException();
}
if (!Collections.disjoint(toIdentitySet(dispatch.values()), toIdentitySet(unusedSlices))) {
throw new IllegalArgumentException();
}
}
}
private static Collection<IdentityWrapper<Object>> toIdentitySet(Collection<Object> v) {
return v.stream().map(IdentityWrapper::new).collect(toSet());
}
}
private static Map<Class<?>, Object> createInterfaceDispatch(Class<?>[] interfaces, Object[] slices) {
if (interfaces.length == 0) {
return Collections.emptyMap();
}
Map<Class<?>, Object> dispatch = new HashMap<>();
var builder = new InterfaceDispatchBuilder(Set.of(interfaces), List.of(slices));
for (;;) {
var result = builder.createDispatch();
if (result.dispatch().isEmpty()) {
var unserved = builder.createDispatchGroups();
for (var e : unserved.entrySet()) {
var iface = e.getKey();
var ifaceSlices = e.getValue();
if (ifaceSlices.size() > 1) {
throw new IllegalArgumentException(
String.format("multiple slices %s implement %s", ifaceSlices, iface));
}
}
var unservedInterfaces = unserved.entrySet().stream().filter(e -> {
return e.getValue().isEmpty();
}).map(Map.Entry::getKey).toList();
throw createInterfaceNotImplementedException(unservedInterfaces);
} else {
dispatch.putAll(result.dispatch());
if (result.unservedInterfaces().isEmpty()) {
break;
}
}
builder = new InterfaceDispatchBuilder(result);
}
return dispatch.keySet().stream().flatMap(iface -> {
return unfoldInterface(iface).map(unfoldedIface -> {
return Map.entry(unfoldedIface, interfaceDispatch.get(iface));
return Map.entry(unfoldedIface, dispatch.get(iface));
});
}).collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@ -321,7 +417,7 @@ public final class CompositeProxy {
}
private static IllegalArgumentException createInterfaceNotImplementedException(
Collection<Class<?>> missingInterfaces) {
Collection<? extends Class<?>> missingInterfaces) {
return new IllegalArgumentException(String.format("none of the slices implement %s", missingInterfaces));
}

View File

@ -41,6 +41,7 @@ import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import jdk.jpackage.internal.model.ConfigException;
import jdk.jpackage.internal.model.Launcher;
import jdk.jpackage.internal.model.WinApplication;
import jdk.jpackage.internal.model.WinExePackage;
import jdk.jpackage.internal.model.WinLauncher;
@ -66,7 +67,9 @@ final class WinFromParams {
return WinLauncher.create(launcher, new WinLauncherMixin.Stub(isConsole, startMenuShortcut, desktopShortcut));
}), APPLICATION_LAYOUT).create();
}), (WinLauncher winLauncher, Launcher launcher) -> {
return WinLauncher.create(launcher, winLauncher);
}, APPLICATION_LAYOUT).create();
return WinApplication.create(app);
}

View File

@ -56,7 +56,7 @@ final class WinPackagingPipeline {
private static void rebrandLaunchers(AppImageBuildEnv<WinApplication, ApplicationLayout> env)
throws IOException, PackagerException {
for (var launcher : env.app().launchers()) {
final var iconTarget = createLauncherIconResource(env.app(), launcher, env.env()::createResource).map(iconResource -> {
final var iconTarget = createLauncherIconResource(launcher, env.env()::createResource).map(iconResource -> {
var iconDir = env.env().buildRoot().resolve("icons");
var theIconTarget = iconDir.resolve(launcher.executableName() + ".ico");
try {

View File

@ -32,7 +32,6 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import jdk.jpackage.internal.util.PathUtils;
public final class FileAssociations {
@ -75,13 +74,6 @@ public final class FileAssociations {
return this;
}
Path getLinuxIconFileName() {
if (icon == null) {
return null;
}
return Path.of(getMime().replace('/', '-') + PathUtils.getSuffix(icon));
}
Path getPropertiesFile() {
return file;
}
@ -94,6 +86,10 @@ public final class FileAssociations {
return "application/x-jpackage-" + suffixName;
}
boolean hasIcon() {
return icon != null;
}
public void applyTo(PackageTest test) {
test.notForTypes(PackageType.MAC_DMG, () -> {
test.addInitializer(cmd -> {

View File

@ -66,18 +66,11 @@ public final class LauncherIconVerifier {
}
public void applyTo(JPackageCommand cmd) throws IOException {
final String curLauncherName;
final String label;
if (launcherName == null) {
curLauncherName = cmd.name();
label = "main";
} else {
curLauncherName = launcherName;
label = String.format("[%s]", launcherName);
}
final String label = Optional.ofNullable(launcherName).map(v -> {
return String.format("[%s]", v);
}).orElse("main");
Path iconPath = cmd.appLayout().desktopIntegrationDirectory().resolve(
curLauncherName + TKit.ICON_SUFFIX);
Path iconPath = cmd.appLayout().desktopIntegrationDirectory().resolve(iconFileName(cmd));
if (TKit.isWindows()) {
TKit.assertPathExists(iconPath, false);
@ -99,6 +92,14 @@ public final class LauncherIconVerifier {
}
}
private Path iconFileName(JPackageCommand cmd) {
if (TKit.isLinux()) {
return LinuxHelper.getLauncherIconFileName(cmd, launcherName);
} else {
return Path.of(Optional.ofNullable(launcherName).orElseGet(cmd::name) + TKit.ICON_SUFFIX);
}
}
private String launcherName;
private Path expectedIcon;
private boolean expectedDefault;

View File

@ -204,7 +204,7 @@ public final class LauncherVerifier {
verifier.setExpectedIcon(icon);
}
}, () -> {
// No "icon" property in the property file
// No "icon" property in the property file.
iconInResourceDir(cmd, name).ifPresentOrElse(verifier::setExpectedIcon, () -> {
// No icon for this additional launcher in the resource directory.
mainLauncherIcon.ifPresentOrElse(verifier::setExpectedIcon, verifier::setExpectedDefaultIcon);
@ -212,6 +212,29 @@ public final class LauncherVerifier {
});
}
if (TKit.isLinux()) {
// On Linux, a launcher may have an icon only if it has a corresponding .desktop file.
// In case of "app-image" packaging there are no .desktop files, but jpackage will add icon files
// in the app image anyways so that in two-step packaging jpackage can pick the icons for .desktop files.
// jpackage should not add the default icon to the app image in case of "app-image" packaging.
if (cmd.isImagePackageType()) {
// This is "app-image" packaging. Let's see if, in two-step packaging,
// jpackage creates a .desktop file for this launcher.
if (!withLinuxDesktopFile(cmd.createMutableCopy().setPackageType(PackageType.LINUX_RPM))) {
// No .desktop file in the "future" package for this launcher,
// then don't expect an icon in the app image produced by the `cmd`.
verifier.setExpectedNoIcon();
} else if (verifier.expectDefaultIcon()) {
// A .desktop file in the "future" package for this launcher,
// but it will use the default icon.
// Don't expect an icon in the app image produced by the `cmd`.
verifier.setExpectedNoIcon();
}
} else if (!withLinuxDesktopFile(cmd)) {
verifier.setExpectedNoIcon();
}
}
return verifier;
}

View File

@ -22,11 +22,11 @@
*/
package jdk.jpackage.test;
import static jdk.jpackage.test.AdditionalLauncher.getAdditionalLauncherProperties;
import static java.util.Collections.unmodifiableSortedSet;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
import static jdk.jpackage.test.AdditionalLauncher.getAdditionalLauncherProperties;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -45,6 +45,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Matcher;
@ -90,9 +91,7 @@ public final class LinuxHelper {
public static Path getDesktopFile(JPackageCommand cmd, String launcherName) {
cmd.verifyIsOfType(PackageType.LINUX);
String desktopFileName = String.format("%s-%s.desktop", getPackageName(
cmd), Optional.ofNullable(launcherName).orElseGet(
() -> cmd.name()).replaceAll("\\s+", "_"));
var desktopFileName = getLauncherDesktopFileName(cmd, launcherName);
return cmd.appLayout().desktopIntegrationDirectory().resolve(
desktopFileName);
}
@ -204,6 +203,20 @@ public final class LinuxHelper {
}
}
private static Path getFaIconFileName(JPackageCommand cmd, String mimeType) {
return Path.of(mimeType.replace('/', '-') + ".png");
}
static Path getLauncherDesktopFileName(JPackageCommand cmd, String launcherName) {
return Path.of(String.format("%s-%s.desktop", getPackageName(cmd),
Optional.ofNullable(launcherName).orElseGet(cmd::name).replaceAll("\\s+", "_")));
}
static Path getLauncherIconFileName(JPackageCommand cmd, String launcherName) {
return Path.of(String.format("%s.png",
Optional.ofNullable(launcherName).orElseGet(cmd::name).replaceAll("\\s+", "_")));
}
static PackageHandlers createDebPackageHandlers() {
return new PackageHandlers(LinuxHelper::installDeb, LinuxHelper::uninstallDeb, LinuxHelper::unpackDeb);
}
@ -423,7 +436,12 @@ public final class LinuxHelper {
});
}
static void verifyDesktopFiles(JPackageCommand cmd, boolean installed) {
static void verifyDesktopIntegrationFiles(JPackageCommand cmd, boolean installed) {
verifyDesktopFiles(cmd, installed);
verifyAllIconsReferenced(cmd);
}
private static void verifyDesktopFiles(JPackageCommand cmd, boolean installed) {
final var desktopFiles = getDesktopFiles(cmd);
try {
if (installed) {
@ -479,6 +497,39 @@ public final class LinuxHelper {
}).map(packageDir::relativize);
}
private static void verifyAllIconsReferenced(JPackageCommand cmd) {
var installCmd = Optional.ofNullable(cmd.unpackedPackageDirectory()).map(_ -> {
return cmd.createMutableCopy().setUnpackedPackageLocation(null);
}).orElse(cmd);
var installedIconFiles = relativePackageFilesInSubdirectory(
installCmd,
ApplicationLayout::desktopIntegrationDirectory
).filter(path -> {
return ".png".equals(PathUtils.getSuffix(path));
}).map(installCmd.appLayout().desktopIntegrationDirectory()::resolve).collect(toSet());
var referencedIcons = getDesktopFiles(cmd).stream().map(path -> {
return new DesktopFile(path, false);
}).<Path>mapMulti((desktopFile, sink) -> {
desktopFile.findQuotedValue("Icon").map(Path::of).ifPresent(sink);
desktopFile.find("MimeType").ifPresent(str -> {
Stream.of(str.split(";"))
.map(mimeType -> {
return getFaIconFileName(cmd, mimeType);
})
.map(installCmd.appLayout().desktopIntegrationDirectory()::resolve)
.forEach(sink);
});
}).collect(toSet());
var unreferencedIconFiles = Comm.compare(installedIconFiles, referencedIcons).unique1().stream().sorted().toList();
// Verify that all package icon (.png) files are referenced from package .desktop files.
TKit.assertEquals(List.of(), unreferencedIconFiles, "Check there are no unreferenced icon files in the package");
}
private static String launcherNameFromDesktopFile(JPackageCommand cmd, Optional<AppImageFile> predefinedAppImage, Path desktopFile) {
Objects.requireNonNull(cmd);
Objects.requireNonNull(predefinedAppImage);
@ -661,16 +712,19 @@ public final class LinuxHelper {
});
test.addBundleVerifier(cmd -> {
final Path mimeTypeIconFileName = fa.getLinuxIconFileName();
if (mimeTypeIconFileName != null) {
// Verify there are xdg registration commands for mime icon file.
Path mimeTypeIcon = cmd.appLayout().desktopIntegrationDirectory().resolve(
mimeTypeIconFileName);
Optional.of(fa).filter(FileAssociations::hasIcon)
.map(FileAssociations::getMime)
.map(mimeType -> {
return getFaIconFileName(cmd, mimeType);
}).ifPresent(mimeTypeIconFileName -> {
// Verify there are xdg registration commands for mime icon file.
Path mimeTypeIcon = cmd.appLayout().desktopIntegrationDirectory().resolve(
mimeTypeIconFileName);
Map<Scriptlet, List<String>> scriptlets = getScriptlets(cmd);
scriptlets.entrySet().stream().forEach(e -> verifyIconInScriptlet(
e.getKey(), e.getValue(), mimeTypeIcon));
}
Map<Scriptlet, List<String>> scriptlets = getScriptlets(cmd);
scriptlets.entrySet().stream().forEach(e -> verifyIconInScriptlet(
e.getKey(), e.getValue(), mimeTypeIcon));
});
});
}

View File

@ -784,7 +784,7 @@ public final class PackageTest extends RunnablePackageTest {
}
if (isOfType(cmd, LINUX)) {
LinuxHelper.verifyDesktopFiles(cmd, true);
LinuxHelper.verifyDesktopIntegrationFiles(cmd, true);
}
}
@ -865,7 +865,7 @@ public final class PackageTest extends RunnablePackageTest {
}
if (isOfType(cmd, LINUX)) {
LinuxHelper.verifyDesktopFiles(cmd, false);
LinuxHelper.verifyDesktopIntegrationFiles(cmd, false);
}
}

View File

@ -409,6 +409,14 @@ public class IconTest {
iconType = mainLauncherIconType;
}
if (TKit.isLinux()) {
var noDefaultIcon = cmd.isImagePackageType() || !cmd.hasArgument("--linux-shortcut");
if (noDefaultIcon && iconType == IconType.DefaultIcon) {
iconType = IconType.NoIcon;
}
}
return iconType;
}