/* * Copyright (c) 2019, 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.incubator.jpackage.internal; import java.awt.image.BufferedImage; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.imageio.ImageIO; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; import static jdk.incubator.jpackage.internal.LinuxAppBundler.ICON_PNG; import static jdk.incubator.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON; import static jdk.incubator.jpackage.internal.OverridableResource.createResource; import static jdk.incubator.jpackage.internal.StandardBundlerParam.*; /** * Helper to create files for desktop integration. */ final class DesktopIntegration { static final String DESKTOP_COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL"; static final String DESKTOP_COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL"; static final String UTILITY_SCRIPTS = "UTILITY_SCRIPTS"; DesktopIntegration(PlatformPackage thePackage, Map params) { associations = FileAssociation.fetchFrom(params).stream() .filter(fa -> !fa.mimeTypes.isEmpty()) .map(LinuxFileAssociation::new) .collect(Collectors.toUnmodifiableList()); launchers = ADD_LAUNCHERS.fetchFrom(params); this.thePackage = thePackage; final File customIconFile = ICON_PNG.fetchFrom(params); iconResource = createResource(DEFAULT_ICON, params) .setCategory(I18N.getString("resource.menu-icon")) .setExternal(customIconFile); desktopFileResource = createResource("template.desktop", params) .setCategory(I18N.getString("resource.menu-shortcut-descriptor")) .setPublicName(APP_NAME.fetchFrom(params) + ".desktop"); // 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", thePackage.name(), APP_NAME.fetchFrom(params)); final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml", thePackage.name(), APP_NAME.fetchFrom(params)); mimeInfoFile = new DesktopFile(mimeInfoFileName); if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) { // // Create primary .desktop file if one of conditions is met: // - there are file associations configured // - user explicitely requested to create a shortcut // - custom icon specified // desktopFile = new DesktopFile(desktopFileName); iconFile = new DesktopFile(APP_NAME.fetchFrom(params) + IOUtils.getSuffix(Path.of(DEFAULT_ICON))); } else { desktopFile = null; iconFile = null; } desktopFileData = Collections.unmodifiableMap( createDataForDesktopFile(params)); nestedIntegrations = launchers.stream().map( launcherParams -> new DesktopIntegration(thePackage, launcherParams)).collect(Collectors.toList()); } List requiredPackages() { return Stream.of(List.of(this), nestedIntegrations).flatMap( List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap( List::stream).distinct().collect(Collectors.toList()); } Map create() throws IOException { associations.forEach(assoc -> assoc.data.verify()); if (iconFile != null) { // Create application icon file. iconResource.saveToFile(iconFile.srcPath()); } Map data = new HashMap<>(desktopFileData); final ShellCommands shellCommands; if (desktopFile != null) { // Create application desktop description file. createDesktopFile(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); } boolean needCleanupScripts = !associations.isEmpty(); // 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 installShellCmds = new ArrayList<>(Arrays.asList( data.get(DESKTOP_COMMANDS_INSTALL))); List uninstallShellCmds = new ArrayList<>(Arrays.asList( data.get(DESKTOP_COMMANDS_UNINSTALL))); for (var integration: nestedIntegrations) { if (!integration.associations.isEmpty()) { needCleanupScripts = true; } Map launcherData = integration.create(); installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL)); uninstallShellCmds.add(launcherData.get( DESKTOP_COMMANDS_UNINSTALL)); } data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands( installShellCmds)); data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands( uninstallShellCmds)); if (needCleanupScripts) { // Pull in utils.sh scrips library. try (InputStream is = OverridableResource.readDefault("utils.sh"); InputStreamReader isr = new InputStreamReader(is); BufferedReader reader = new BufferedReader(isr)) { data.put(UTILITY_SCRIPTS, reader.lines().collect( Collectors.joining(System.lineSeparator()))); } } else { data.put(UTILITY_SCRIPTS, ""); } return data; } private List requiredPackagesSelf() { if (desktopFile != null) { return List.of("xdg-utils"); } return Collections.emptyList(); } private Map createDataForDesktopFile( Map params) { Map data = new HashMap<>(); data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params)); data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params)); data.put("APPLICATION_ICON", iconFile != null ? iconFile.installPath().toString() : null); data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params)); data.put("APPLICATION_LAUNCHER", thePackage.installedApplicationLayout().launchersDirectory().resolve( LinuxAppImageBuilder.getLauncherName(params)).toString()); return data; } /** * Shell commands to integrate something with desktop. */ private class ShellCommands { ShellCommands() { registerIconCmds = new ArrayList<>(); unregisterIconCmds = new ArrayList<>(); registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu", "install", desktopFile.installPath().toString()); unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu", "uninstall", desktopFile.installPath().toString()); } void setFileAssociations() { registerFileAssociationsCmd = String.join(" ", "xdg-mime", "install", mimeInfoFile.installPath().toString()); unregisterFileAssociationsCmd = String.join(" ", "xdg-mime", "uninstall", mimeInfoFile.installPath().toString()); // // Add manual cleanup of system files to get rid of // the default mime type handlers. // // Even after mime type is unregisterd 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(" ", "uninstall_default_mime_handler", desktopFile.installPath().getFileName().toString(), String.join(" ", getMimeTypeNamesFromFileAssociations())); unregisterFileAssociationsCmd = stringifyShellCommands( unregisterFileAssociationsCmd, cleanUpCommand); } void addIcon(String mimeType, Path iconFile) { addIcon(mimeType, iconFile, getSquareSizeOfImage(iconFile.toFile())); } 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(" ", "xdg-icon-resource", "uninstall", dashMime, "--size", Integer.toString(imgSize))); } void applyTo(Map data) { List cmds = new ArrayList<>(); cmds.add(registerDesktopFileCmd); cmds.add(registerFileAssociationsCmd); cmds.addAll(registerIconCmds); data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds)); cmds.clear(); cmds.add(unregisterDesktopFileCmd); cmds.add(unregisterFileAssociationsCmd); cmds.addAll(unregisterIconCmds); data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds)); } private String registerDesktopFileCmd; private String unregisterDesktopFileCmd; private String registerFileAssociationsCmd; private String unregisterFileAssociationsCmd; private List registerIconCmds; private List unregisterIconCmds; } /** * Desktop integration file. xml, icon, etc. * Resides somewhere in application installation tree. * Has two paths: * - path where it should be placed at package build time; * - path where it should be installed by package manager; */ private class DesktopFile { DesktopFile(String fileName) { installPath = thePackage .installedApplicationLayout() .destktopIntegrationDirectory().resolve(fileName); srcPath = thePackage .sourceApplicationLayout() .destktopIntegrationDirectory().resolve(fileName); } private final Path installPath; private final Path srcPath; Path installPath() { return installPath; } Path srcPath() { return srcPath; } } private void appendFileAssociation(XMLStreamWriter xml, FileAssociation assoc) throws XMLStreamException { for (var mimeType : assoc.mimeTypes) { xml.writeStartElement("mime-type"); xml.writeAttribute("type", mimeType); final String description = assoc.description; if (description != null && !description.isEmpty()) { xml.writeStartElement("comment"); xml.writeCharacters(description); xml.writeEndElement(); } for (String ext : assoc.extensions) { xml.writeStartElement("glob"); xml.writeAttribute("pattern", "*." + ext); xml.writeEndElement(); } xml.writeEndElement(); } } private void createFileAssociationsMimeInfoFile() throws IOException { IOUtils.createXml(mimeInfoFile.srcPath(), xml -> { xml.writeStartElement("mime-info"); xml.writeDefaultNamespace( "http://www.freedesktop.org/standards/shared-mime-info"); for (var assoc : associations) { appendFileAssociation(xml, assoc.data); } xml.writeEndElement(); }); } private void addFileAssociationIconFiles(ShellCommands shellCommands) throws IOException { Set processedMimeTypes = new HashSet<>(); for (var assoc : associations) { if (assoc.iconSize <= 0) { // No icon. continue; } for (var mimeType : assoc.data.mimeTypes) { if (processedMimeTypes.contains(mimeType)) { continue; } processedMimeTypes.add(mimeType); // Create icon name for mime type from mime type. DesktopFile faIconFile = new DesktopFile(mimeType.replace( File.separatorChar, '-') + IOUtils.getSuffix( assoc.data.iconPath)); IOUtils.copyFile(assoc.data.iconPath.toFile(), faIconFile.srcPath().toFile()); shellCommands.addIcon(mimeType, faIconFile.installPath(), assoc.iconSize); } } } private void createDesktopFile(Map data) throws IOException { List mimeTypes = getMimeTypeNamesFromFileAssociations(); data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes)); // prepare desktop shortcut desktopFileResource .setSubstitutionData(data) .saveToFile(desktopFile.srcPath()); } private List getMimeTypeNamesFromFileAssociations() { return associations.stream() .map(fa -> fa.data.mimeTypes) .flatMap(List::stream) .collect(Collectors.toUnmodifiableList()); } private static int getSquareSizeOfImage(File f) { try { BufferedImage bi = ImageIO.read(f); 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 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 static String stringifyShellCommands(String... commands) { return stringifyShellCommands(Arrays.asList(commands)); } private static String stringifyShellCommands(List commands) { return String.join(System.lineSeparator(), commands.stream().filter( s -> s != null && !s.isEmpty()).collect(Collectors.toList())); } private static class LinuxFileAssociation { LinuxFileAssociation(FileAssociation fa) { this.data = fa; if (fa.iconPath != null && Files.isReadable(fa.iconPath)) { iconSize = getSquareSizeOfImage(fa.iconPath.toFile()); } else { iconSize = -1; } } final FileAssociation data; final int iconSize; } private final PlatformPackage thePackage; private final List associations; private final List> launchers; private final OverridableResource iconResource; private final OverridableResource desktopFileResource; private final DesktopFile mimeInfoFile; private final DesktopFile desktopFile; private final DesktopFile iconFile; private final List nestedIntegrations; private final Map desktopFileData; private static final BundlerParamInfo MENU_GROUP = new StandardBundlerParam<>( Arguments.CLIOptions.LINUX_MENU_GROUP.getId(), String.class, params -> I18N.getString("param.menu-group.default"), (s, p) -> s ); private static final StandardBundlerParam SHORTCUT_HINT = new StandardBundlerParam<>( Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(), Boolean.class, params -> false, (s, p) -> (s == null || "null".equalsIgnoreCase(s)) ? false : Boolean.valueOf(s) ); }