blob: 8de462abac793790209024478dcd0c9623d15cc1 [file]
/*
* Copyright (c) 2019, 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 jdk.jpackage.internal.model.LinuxPackage;
import jdk.jpackage.internal.model.LinuxLauncher;
import jdk.jpackage.internal.model.Package;
import jdk.jpackage.internal.model.Launcher;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import javax.imageio.ImageIO;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import static jdk.jpackage.internal.ApplicationImageUtils.createLauncherIconResource;
import jdk.jpackage.internal.model.FileAssociation;
import jdk.jpackage.internal.util.CompositeProxy;
import jdk.jpackage.internal.util.PathUtils;
import jdk.jpackage.internal.util.XmlUtils;
import static jdk.jpackage.internal.util.function.ThrowingFunction.toFunction;
/**
* Helper to create files for desktop integration.
*/
final class DesktopIntegration extends ShellCustomAction {
private static final String COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL";
private static final String COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL";
private static final String SCRIPTS = "DESKTOP_SCRIPTS";
private static final String COMMON_SCRIPTS = "COMMON_SCRIPTS";
private static final List<String> REPLACEMENT_STRING_IDS = List.of(
COMMANDS_INSTALL, COMMANDS_UNINSTALL, SCRIPTS, COMMON_SCRIPTS);
private DesktopIntegration(BuildEnv env, LinuxPackage pkg, LinuxLauncher launcher) throws IOException {
associations = launcher.fileAssociations().stream().map(
LinuxFileAssociation::create).toList();
this.env = env;
this.pkg = pkg;
this.launcher = launcher;
// Need desktop and icon files if one of conditions is met:
// - there are file associations configured
// - user explicitly requested to create a shortcut
boolean withDesktopFile = !associations.isEmpty() || launcher.shortcut().orElse(false);
var curIconResource = createLauncherIconResource(pkg.app(), launcher,
env::createResource);
if (curIconResource.isEmpty()) {
// 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;
}
}
desktopFileResource = env.createResource("template.desktop")
.setCategory(I18N.getString("resource.menu-shortcut-descriptor"))
.setPublicName(launcher.name() + ".desktop");
final String escapedAppFileName = launcher.name().replaceAll("\\s+", "_");
// XDG recommends to use vendor prefix in desktop file names as xdg
// commands copy files to system directories.
// Package name should be a good prefix.
final String desktopFileName = String.format("%s-%s.desktop",
pkg.packageName(), escapedAppFileName);
final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml",
pkg.packageName(), escapedAppFileName);
mimeInfoFile = createDesktopFile(mimeInfoFileName);
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;
desktopFileData = createDataForDesktopFile();
if (launcher != pkg.app().mainLauncher().orElseThrow()) {
nestedIntegrations = List.of();
} else {
nestedIntegrations = pkg.app().additionalLaunchers().stream().map(v -> {
return (LinuxLauncher)v;
}).filter(l -> {
return l.shortcut().orElse(true);
}).map(toFunction(l -> {
return new DesktopIntegration(env, pkg, l);
})).toList();
}
}
static ShellCustomAction create(BuildEnv env, Package pkg) throws IOException {
if (pkg.isRuntimeInstaller()) {
return ShellCustomAction.nop(REPLACEMENT_STRING_IDS);
}
return new DesktopIntegration(env, (LinuxPackage) pkg,
(LinuxLauncher) pkg.app().mainLauncher().orElseThrow());
}
@Override
List<String> requiredPackages() {
return Stream.of(List.of(this), nestedIntegrations).flatMap(
List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap(
List::stream).distinct().toList();
}
@Override
protected List<String> replacementStringIds() {
return REPLACEMENT_STRING_IDS;
}
@Override
protected Map<String, String> createImpl() throws IOException {
if (iconFile.isPresent()) {
// Create application icon file.
iconResource.orElseThrow().saveToFile(iconFile.orElseThrow().srcPath());
}
Map<String, String> data = new HashMap<>(desktopFileData);
final ShellCommands shellCommands;
if (desktopFile.isPresent()) {
// Create application desktop description file.
saveDesktopFile(data);
// Shell commands will be created only if desktop file
// should be installed.
shellCommands = new ShellCommands();
} else {
shellCommands = null;
}
if (!associations.isEmpty()) {
// Create XML file with mime types corresponding to file associations.
createFileAssociationsMimeInfoFile();
shellCommands.setFileAssociations();
// Create icon files corresponding to file associations
addFileAssociationIconFiles(shellCommands);
}
// Create shell commands to install/uninstall integration with desktop of the app.
if (shellCommands != null) {
shellCommands.applyTo(data);
}
// Take care of additional launchers if there are any.
// Process every additional launcher as the main application launcher.
// Collect shell commands to install/uninstall integration with desktop
// of the additional launchers and append them to the corresponding
// commands of the main launcher.
List<String> installShellCmds = new ArrayList<>(Arrays.asList(
data.get(COMMANDS_INSTALL)));
List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList(
data.get(COMMANDS_UNINSTALL)));
for (var integration: nestedIntegrations) {
Map<String, String> launcherData = integration.create();
installShellCmds.add(launcherData.get(COMMANDS_INSTALL));
uninstallShellCmds.add(launcherData.get(COMMANDS_UNINSTALL));
}
data.put(COMMANDS_INSTALL, stringifyShellCommands(installShellCmds));
data.put(COMMANDS_UNINSTALL, stringifyShellCommands(uninstallShellCmds));
data.put(COMMON_SCRIPTS, stringifyTextFile("common_utils.sh"));
data.put(SCRIPTS, stringifyTextFile("desktop_utils.sh"));
return data;
}
private List<String> requiredPackagesSelf() {
return desktopFile.map(file -> List.of("xdg-utils")).orElseGet(Collections::emptyList);
}
private Map<String, String> createDataForDesktopFile() {
Map<String, String> data = new HashMap<>();
data.put("APPLICATION_NAME", launcher.name());
data.put("APPLICATION_DESCRIPTION", launcher.description());
data.put("APPLICATION_ICON", iconFile.map(
f -> f.installPath().toString()).orElse(null));
data.put("DEPLOY_BUNDLE_CATEGORY", pkg.menuGroupName());
data.put("APPLICATION_LAUNCHER", Enquoter.forPropertyValues().applyTo(
pkg.asInstalledPackageApplicationLayout().orElseThrow().launchersDirectory().resolve(
launcher.executableNameWithSuffix()).toString()));
return data;
}
/**
* Shell commands to integrate something with desktop.
*/
private class ShellCommands {
ShellCommands() {
registerIconCmds = new ArrayList<>();
unregisterIconCmds = new ArrayList<>();
final var desktopFileInstallPath = desktopFile.orElseThrow().installPath().toString();
registerDesktopFileCmd = String.join(" ",
"xdg-desktop-menu", "install", desktopFileInstallPath);
unregisterDesktopFileCmd = String.join(" ",
"do_if_file_belongs_to_single_package", desktopFileInstallPath,
"xdg-desktop-menu", "uninstall", desktopFileInstallPath);
}
void setFileAssociations() {
registerFileAssociationsCmd = String.join(" ", "xdg-mime",
"install",
mimeInfoFile.installPath().toString());
unregisterFileAssociationsCmd = String.join(" ",
"do_if_file_belongs_to_single_package", mimeInfoFile.
installPath().toString(), "xdg-mime", "uninstall",
mimeInfoFile.installPath().toString());
final var desktopFileInstallPath = desktopFile.orElseThrow().installPath();
//
// Add manual cleanup of system files to get rid of
// the default mime type handlers.
//
// Even after mime type is unregistered with `xdg-mime uninstall`
// command and desktop file deleted with `xdg-desktop-menu uninstall`
// command, records in
// `/usr/share/applications/defaults.list` (Ubuntu 16) or
// `/usr/local/share/applications/defaults.list` (OracleLinux 7)
// files remain referencing deleted mime time and deleted
// desktop file which makes `xdg-mime query default` output name
// of non-existing desktop file.
//
String cleanUpCommand = String.join(" ",
"do_if_file_belongs_to_single_package", desktopFileInstallPath.toString(),
"desktop_uninstall_default_mime_handler", desktopFileInstallPath.getFileName().toString(),
String.join(" ", getMimeTypeNamesFromFileAssociations()));
unregisterFileAssociationsCmd = stringifyShellCommands(
unregisterFileAssociationsCmd, cleanUpCommand);
}
void addIcon(String mimeType, Path iconFile, int imgSize) {
imgSize = normalizeIconSize(imgSize);
final String dashMime = mimeType.replace('/', '-');
registerIconCmds.add(String.join(" ", "xdg-icon-resource",
"install", "--context", "mimetypes", "--size",
Integer.toString(imgSize), iconFile.toString(), dashMime));
unregisterIconCmds.add(String.join(" ",
"do_if_file_belongs_to_single_package", iconFile.toString(),
"xdg-icon-resource", "uninstall", dashMime, "--size",
Integer.toString(imgSize)));
}
void applyTo(Map<String, String> data) {
List<String> cmds = new ArrayList<>();
cmds.add(registerDesktopFileCmd);
cmds.add(registerFileAssociationsCmd);
cmds.addAll(registerIconCmds);
data.put(COMMANDS_INSTALL, stringifyShellCommands(cmds));
cmds.clear();
cmds.add(unregisterDesktopFileCmd);
cmds.add(unregisterFileAssociationsCmd);
cmds.addAll(unregisterIconCmds);
data.put(COMMANDS_UNINSTALL, stringifyShellCommands(cmds));
}
private String registerDesktopFileCmd;
private String unregisterDesktopFileCmd;
private String registerFileAssociationsCmd;
private String unregisterFileAssociationsCmd;
private List<String> registerIconCmds;
private List<String> unregisterIconCmds;
}
/**
* Creates desktop integration file. xml, icon, etc.
*
* Returned instance:
* - srcPath(): path where it should be placed at package build time;
* - installPath(): path where it should be installed by package manager;
*/
private InstallableFile createDesktopFile(String fileName) {
var srcPath = pkg.asPackageApplicationLayout().orElseThrow().resolveAt(env.appImageDir()).desktopIntegrationDirectory().resolve(fileName);
var installPath = pkg.asInstalledPackageApplicationLayout().orElseThrow().desktopIntegrationDirectory().resolve(fileName);
return new InstallableFile(srcPath, installPath);
}
private void appendFileAssociation(XMLStreamWriter xml,
LinuxFileAssociation fa) throws XMLStreamException {
xml.writeStartElement("mime-type");
xml.writeAttribute("type", fa.mimeType());
final var description = fa.description().orElse(null);
if (description != null) {
xml.writeStartElement("comment");
xml.writeCharacters(description);
xml.writeEndElement();
}
xml.writeStartElement("glob");
xml.writeAttribute("pattern", "*." + fa.extension());
xml.writeEndElement();
xml.writeEndElement();
}
private void createFileAssociationsMimeInfoFile() throws IOException {
XmlUtils.createXml(mimeInfoFile.srcPath(), xml -> {
xml.writeStartElement("mime-info");
xml.writeDefaultNamespace(
"http://www.freedesktop.org/standards/shared-mime-info");
for (var fa : associations) {
appendFileAssociation(xml, fa);
}
xml.writeEndElement();
});
}
private void addFileAssociationIconFiles(ShellCommands shellCommands)
throws IOException {
Set<String> processedMimeTypes = new HashSet<>();
for (var fa : associations) {
if (!fa.hasIcon()) {
// No icon.
continue;
}
var mimeType = fa.mimeType();
if (processedMimeTypes.contains(mimeType)) {
continue;
}
processedMimeTypes.add(mimeType);
final var faIcon = fa.icon().orElseThrow();
// Create icon name for mime type from mime type.
var faIconFile = createDesktopFile(mimeType.replace(File.separatorChar,
'-') + PathUtils.getSuffix(faIcon));
IOUtils.copyFile(faIcon, faIconFile.srcPath());
shellCommands.addIcon(mimeType, faIconFile.installPath(), fa.iconSize());
}
}
private void saveDesktopFile(Map<String, String> data) throws IOException {
List<String> mimeTypes = getMimeTypeNamesFromFileAssociations();
data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes));
// prepare desktop shortcut
desktopFileResource
.setSubstitutionData(data)
.saveToFile(desktopFile.orElseThrow().srcPath());
}
private List<String> getMimeTypeNamesFromFileAssociations() {
return associations.stream().map(FileAssociation::mimeType).toList();
}
private static int getSquareSizeOfImage(Path path) {
try {
BufferedImage bi = ImageIO.read(path.toFile());
return Math.max(bi.getWidth(), bi.getHeight());
} catch (IOException e) {
Log.verbose(e);
}
return 0;
}
private static int normalizeIconSize(int iconSize) {
// If register icon with "uncommon" size, it will be ignored.
// So find the best matching "common" size.
List<Integer> commonIconSizes = List.of(16, 22, 32, 48, 64, 128);
int idx = Collections.binarySearch(commonIconSizes, iconSize);
if (idx < 0) {
// Given icon size is greater than the largest common icon size.
return commonIconSizes.get(commonIconSizes.size() - 1);
}
if (idx == 0) {
// Given icon size is less or equal than the smallest common icon size.
return commonIconSizes.get(idx);
}
int commonIconSize = commonIconSizes.get(idx);
if (iconSize < commonIconSize) {
// It is better to scale down original icon than to scale it up for
// better visual quality.
commonIconSize = commonIconSizes.get(idx - 1);
}
return commonIconSize;
}
private interface LinuxFileAssociationMixin {
int iconSize();
record Stub(int iconSize) implements LinuxFileAssociationMixin {}
}
private static interface LinuxFileAssociation extends FileAssociation, LinuxFileAssociationMixin {
static LinuxFileAssociation create(FileAssociation fa) {
var iconSize = getIconSize(fa);
if (iconSize <= 0) {
// nullify the icon
fa = new FileAssociation.Stub(fa.description(), Optional.empty(),
fa.mimeType(), fa.extension());
}
return CompositeProxy.build()
.invokeTunnel(CompositeProxyTunnel.INSTANCE)
.create(LinuxFileAssociation.class, fa,
new LinuxFileAssociationMixin.Stub(iconSize));
}
private static int getIconSize(FileAssociation fa) {
return Optional.of(fa)
.flatMap(FileAssociation::icon)
.map(DesktopIntegration::getSquareSizeOfImage)
.orElse(-1);
}
}
private final BuildEnv env;
private final LinuxPackage pkg;
private final Launcher launcher;
private final List<LinuxFileAssociation> associations;
private final Optional<OverridableResource> iconResource;
private final OverridableResource desktopFileResource;
private final InstallableFile mimeInfoFile;
private final Optional<InstallableFile> desktopFile;
private final Optional<InstallableFile> iconFile;
private final List<DesktopIntegration> nestedIntegrations;
private final Map<String, String> desktopFileData;
}