1 /* 2 * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. Oracle designates this 8 * particular file as subject to the "Classpath" exception as provided 9 * by Oracle in the LICENSE file that accompanied this code. 10 * 11 * This code is distributed in the hope that it will be useful, but WITHOUT 12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 14 * version 2 for more details (a copy is included in the LICENSE file that 15 * accompanied this code). 16 * 17 * You should have received a copy of the GNU General Public License version 18 * 2 along with this work; if not, write to the Free Software Foundation, 19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 20 * 21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 22 * or visit www.oracle.com if you need additional information or have any 23 * questions. 24 */ 25 package jdk.incubator.jpackage.internal; 26 27 import java.awt.image.BufferedImage; 28 import java.io.*; 29 import java.nio.file.Files; 30 import java.nio.file.Path; 31 import java.util.*; 32 import java.util.stream.Collectors; 33 import java.util.stream.Stream; 34 import javax.imageio.ImageIO; 35 import javax.xml.stream.XMLStreamException; 36 import javax.xml.stream.XMLStreamWriter; 37 import static jdk.incubator.jpackage.internal.LinuxAppBundler.ICON_PNG; 38 import static jdk.incubator.jpackage.internal.LinuxAppImageBuilder.DEFAULT_ICON; 39 import static jdk.incubator.jpackage.internal.OverridableResource.createResource; 40 import static jdk.incubator.jpackage.internal.StandardBundlerParam.*; 41 42 /** 43 * Helper to create files for desktop integration. 44 */ 45 final class DesktopIntegration { 46 47 static final String DESKTOP_COMMANDS_INSTALL = "DESKTOP_COMMANDS_INSTALL"; 48 static final String DESKTOP_COMMANDS_UNINSTALL = "DESKTOP_COMMANDS_UNINSTALL"; 49 static final String UTILITY_SCRIPTS = "UTILITY_SCRIPTS"; 50 51 DesktopIntegration(PlatformPackage thePackage, 52 Map<String, ? super Object> params) { 53 54 associations = FileAssociation.fetchFrom(params).stream() 55 .filter(fa -> !fa.mimeTypes.isEmpty()) 56 .map(LinuxFileAssociation::new) 57 .collect(Collectors.toUnmodifiableList()); 58 59 launchers = ADD_LAUNCHERS.fetchFrom(params); 60 61 this.thePackage = thePackage; 62 63 final File customIconFile = ICON_PNG.fetchFrom(params); 64 65 iconResource = createResource(DEFAULT_ICON, params) 66 .setCategory(I18N.getString("resource.menu-icon")) 67 .setExternal(customIconFile); 68 desktopFileResource = createResource("template.desktop", params) 69 .setCategory(I18N.getString("resource.menu-shortcut-descriptor")) 70 .setPublicName(APP_NAME.fetchFrom(params) + ".desktop"); 71 72 // XDG recommends to use vendor prefix in desktop file names as xdg 73 // commands copy files to system directories. 74 // Package name should be a good prefix. 75 final String desktopFileName = String.format("%s-%s.desktop", 76 thePackage.name(), APP_NAME.fetchFrom(params)); 77 final String mimeInfoFileName = String.format("%s-%s-MimeInfo.xml", 78 thePackage.name(), APP_NAME.fetchFrom(params)); 79 80 mimeInfoFile = new DesktopFile(mimeInfoFileName); 81 82 if (!associations.isEmpty() || SHORTCUT_HINT.fetchFrom(params) || customIconFile != null) { 83 // 84 // Create primary .desktop file if one of conditions is met: 85 // - there are file associations configured 86 // - user explicitely requested to create a shortcut 87 // - custom icon specified 88 // 89 desktopFile = new DesktopFile(desktopFileName); 90 iconFile = new DesktopFile(APP_NAME.fetchFrom(params) 91 + IOUtils.getSuffix(Path.of(DEFAULT_ICON))); 92 } else { 93 desktopFile = null; 94 iconFile = null; 95 } 96 97 desktopFileData = Collections.unmodifiableMap( 98 createDataForDesktopFile(params)); 99 100 nestedIntegrations = launchers.stream().map( 101 launcherParams -> new DesktopIntegration(thePackage, 102 launcherParams)).collect(Collectors.toList()); 103 } 104 105 List<String> requiredPackages() { 106 return Stream.of(List.of(this), nestedIntegrations).flatMap( 107 List::stream).map(DesktopIntegration::requiredPackagesSelf).flatMap( 108 List::stream).distinct().collect(Collectors.toList()); 109 } 110 111 Map<String, String> create() throws IOException { 112 associations.forEach(assoc -> assoc.data.verify()); 113 114 if (iconFile != null) { 115 // Create application icon file. 116 iconResource.saveToFile(iconFile.srcPath()); 117 } 118 119 Map<String, String> data = new HashMap<>(desktopFileData); 120 121 final ShellCommands shellCommands; 122 if (desktopFile != null) { 123 // Create application desktop description file. 124 createDesktopFile(data); 125 126 // Shell commands will be created only if desktop file 127 // should be installed. 128 shellCommands = new ShellCommands(); 129 } else { 130 shellCommands = null; 131 } 132 133 if (!associations.isEmpty()) { 134 // Create XML file with mime types corresponding to file associations. 135 createFileAssociationsMimeInfoFile(); 136 137 shellCommands.setFileAssociations(); 138 139 // Create icon files corresponding to file associations 140 addFileAssociationIconFiles(shellCommands); 141 } 142 143 // Create shell commands to install/uninstall integration with desktop of the app. 144 if (shellCommands != null) { 145 shellCommands.applyTo(data); 146 } 147 148 boolean needCleanupScripts = !associations.isEmpty(); 149 150 // Take care of additional launchers if there are any. 151 // Process every additional launcher as the main application launcher. 152 // Collect shell commands to install/uninstall integration with desktop 153 // of the additional launchers and append them to the corresponding 154 // commands of the main launcher. 155 List<String> installShellCmds = new ArrayList<>(Arrays.asList( 156 data.get(DESKTOP_COMMANDS_INSTALL))); 157 List<String> uninstallShellCmds = new ArrayList<>(Arrays.asList( 158 data.get(DESKTOP_COMMANDS_UNINSTALL))); 159 for (var integration: nestedIntegrations) { 160 if (!integration.associations.isEmpty()) { 161 needCleanupScripts = true; 162 } 163 164 Map<String, String> launcherData = integration.create(); 165 166 installShellCmds.add(launcherData.get(DESKTOP_COMMANDS_INSTALL)); 167 uninstallShellCmds.add(launcherData.get( 168 DESKTOP_COMMANDS_UNINSTALL)); 169 } 170 171 data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands( 172 installShellCmds)); 173 data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands( 174 uninstallShellCmds)); 175 176 if (needCleanupScripts) { 177 // Pull in utils.sh scrips library. 178 try (InputStream is = OverridableResource.readDefault("utils.sh"); 179 InputStreamReader isr = new InputStreamReader(is); 180 BufferedReader reader = new BufferedReader(isr)) { 181 data.put(UTILITY_SCRIPTS, reader.lines().collect( 182 Collectors.joining(System.lineSeparator()))); 183 } 184 } else { 185 data.put(UTILITY_SCRIPTS, ""); 186 } 187 188 return data; 189 } 190 191 private List<String> requiredPackagesSelf() { 192 if (desktopFile != null) { 193 return List.of("xdg-utils"); 194 } 195 return Collections.emptyList(); 196 } 197 198 private Map<String, String> createDataForDesktopFile( 199 Map<String, ? super Object> params) { 200 Map<String, String> data = new HashMap<>(); 201 data.put("APPLICATION_NAME", APP_NAME.fetchFrom(params)); 202 data.put("APPLICATION_DESCRIPTION", DESCRIPTION.fetchFrom(params)); 203 data.put("APPLICATION_ICON", 204 iconFile != null ? iconFile.installPath().toString() : null); 205 data.put("DEPLOY_BUNDLE_CATEGORY", MENU_GROUP.fetchFrom(params)); 206 data.put("APPLICATION_LAUNCHER", 207 thePackage.installedApplicationLayout().launchersDirectory().resolve( 208 LinuxAppImageBuilder.getLauncherName(params)).toString()); 209 210 return data; 211 } 212 213 /** 214 * Shell commands to integrate something with desktop. 215 */ 216 private class ShellCommands { 217 218 ShellCommands() { 219 registerIconCmds = new ArrayList<>(); 220 unregisterIconCmds = new ArrayList<>(); 221 222 registerDesktopFileCmd = String.join(" ", "xdg-desktop-menu", 223 "install", desktopFile.installPath().toString()); 224 unregisterDesktopFileCmd = String.join(" ", "xdg-desktop-menu", 225 "uninstall", desktopFile.installPath().toString()); 226 } 227 228 void setFileAssociations() { 229 registerFileAssociationsCmd = String.join(" ", "xdg-mime", 230 "install", 231 mimeInfoFile.installPath().toString()); 232 unregisterFileAssociationsCmd = String.join(" ", "xdg-mime", 233 "uninstall", mimeInfoFile.installPath().toString()); 234 235 // 236 // Add manual cleanup of system files to get rid of 237 // the default mime type handlers. 238 // 239 // Even after mime type is unregisterd with `xdg-mime uninstall` 240 // command and desktop file deleted with `xdg-desktop-menu uninstall` 241 // command, records in 242 // `/usr/share/applications/defaults.list` (Ubuntu 16) or 243 // `/usr/local/share/applications/defaults.list` (OracleLinux 7) 244 // files remain referencing deleted mime time and deleted 245 // desktop file which makes `xdg-mime query default` output name 246 // of non-existing desktop file. 247 // 248 String cleanUpCommand = String.join(" ", 249 "uninstall_default_mime_handler", 250 desktopFile.installPath().getFileName().toString(), 251 String.join(" ", getMimeTypeNamesFromFileAssociations())); 252 253 unregisterFileAssociationsCmd = stringifyShellCommands( 254 unregisterFileAssociationsCmd, cleanUpCommand); 255 } 256 257 void addIcon(String mimeType, Path iconFile) { 258 addIcon(mimeType, iconFile, getSquareSizeOfImage(iconFile.toFile())); 259 } 260 261 void addIcon(String mimeType, Path iconFile, int imgSize) { 262 imgSize = normalizeIconSize(imgSize); 263 final String dashMime = mimeType.replace('/', '-'); 264 registerIconCmds.add(String.join(" ", "xdg-icon-resource", 265 "install", "--context", "mimetypes", "--size", 266 Integer.toString(imgSize), iconFile.toString(), dashMime)); 267 unregisterIconCmds.add(String.join(" ", "xdg-icon-resource", 268 "uninstall", dashMime, "--size", Integer.toString(imgSize))); 269 } 270 271 void applyTo(Map<String, String> data) { 272 List<String> cmds = new ArrayList<>(); 273 274 cmds.add(registerDesktopFileCmd); 275 cmds.add(registerFileAssociationsCmd); 276 cmds.addAll(registerIconCmds); 277 data.put(DESKTOP_COMMANDS_INSTALL, stringifyShellCommands(cmds)); 278 279 cmds.clear(); 280 cmds.add(unregisterDesktopFileCmd); 281 cmds.add(unregisterFileAssociationsCmd); 282 cmds.addAll(unregisterIconCmds); 283 data.put(DESKTOP_COMMANDS_UNINSTALL, stringifyShellCommands(cmds)); 284 } 285 286 private String registerDesktopFileCmd; 287 private String unregisterDesktopFileCmd; 288 289 private String registerFileAssociationsCmd; 290 private String unregisterFileAssociationsCmd; 291 292 private List<String> registerIconCmds; 293 private List<String> unregisterIconCmds; 294 } 295 296 /** 297 * Desktop integration file. xml, icon, etc. 298 * Resides somewhere in application installation tree. 299 * Has two paths: 300 * - path where it should be placed at package build time; 301 * - path where it should be installed by package manager; 302 */ 303 private class DesktopFile { 304 305 DesktopFile(String fileName) { 306 installPath = thePackage 307 .installedApplicationLayout() 308 .destktopIntegrationDirectory().resolve(fileName); 309 srcPath = thePackage 310 .sourceApplicationLayout() 311 .destktopIntegrationDirectory().resolve(fileName); 312 } 313 314 private final Path installPath; 315 private final Path srcPath; 316 317 Path installPath() { 318 return installPath; 319 } 320 321 Path srcPath() { 322 return srcPath; 323 } 324 } 325 326 private void appendFileAssociation(XMLStreamWriter xml, 327 FileAssociation assoc) throws XMLStreamException { 328 329 for (var mimeType : assoc.mimeTypes) { 330 xml.writeStartElement("mime-type"); 331 xml.writeAttribute("type", mimeType); 332 333 final String description = assoc.description; 334 if (description != null && !description.isEmpty()) { 335 xml.writeStartElement("comment"); 336 xml.writeCharacters(description); 337 xml.writeEndElement(); 338 } 339 340 for (String ext : assoc.extensions) { 341 xml.writeStartElement("glob"); 342 xml.writeAttribute("pattern", "*." + ext); 343 xml.writeEndElement(); 344 } 345 346 xml.writeEndElement(); 347 } 348 } 349 350 private void createFileAssociationsMimeInfoFile() throws IOException { 351 IOUtils.createXml(mimeInfoFile.srcPath(), xml -> { 352 xml.writeStartElement("mime-info"); 353 xml.writeDefaultNamespace( 354 "http://www.freedesktop.org/standards/shared-mime-info"); 355 356 for (var assoc : associations) { 357 appendFileAssociation(xml, assoc.data); 358 } 359 360 xml.writeEndElement(); 361 }); 362 } 363 364 private void addFileAssociationIconFiles(ShellCommands shellCommands) 365 throws IOException { 366 Set<String> processedMimeTypes = new HashSet<>(); 367 for (var assoc : associations) { 368 if (assoc.iconSize <= 0) { 369 // No icon. 370 continue; 371 } 372 373 for (var mimeType : assoc.data.mimeTypes) { 374 if (processedMimeTypes.contains(mimeType)) { 375 continue; 376 } 377 378 processedMimeTypes.add(mimeType); 379 380 // Create icon name for mime type from mime type. 381 DesktopFile faIconFile = new DesktopFile(mimeType.replace( 382 File.separatorChar, '-') + IOUtils.getSuffix( 383 assoc.data.iconPath)); 384 385 IOUtils.copyFile(assoc.data.iconPath.toFile(), 386 faIconFile.srcPath().toFile()); 387 388 shellCommands.addIcon(mimeType, faIconFile.installPath(), 389 assoc.iconSize); 390 } 391 } 392 } 393 394 private void createDesktopFile(Map<String, String> data) throws IOException { 395 List<String> mimeTypes = getMimeTypeNamesFromFileAssociations(); 396 data.put("DESKTOP_MIMES", "MimeType=" + String.join(";", mimeTypes)); 397 398 // prepare desktop shortcut 399 desktopFileResource 400 .setSubstitutionData(data) 401 .saveToFile(desktopFile.srcPath()); 402 } 403 404 private List<String> getMimeTypeNamesFromFileAssociations() { 405 return associations.stream() 406 .map(fa -> fa.data.mimeTypes) 407 .flatMap(List::stream) 408 .collect(Collectors.toUnmodifiableList()); 409 } 410 411 private static int getSquareSizeOfImage(File f) { 412 try { 413 BufferedImage bi = ImageIO.read(f); 414 return Math.max(bi.getWidth(), bi.getHeight()); 415 } catch (IOException e) { 416 Log.verbose(e); 417 } 418 return 0; 419 } 420 421 private static int normalizeIconSize(int iconSize) { 422 // If register icon with "uncommon" size, it will be ignored. 423 // So find the best matching "common" size. 424 List<Integer> commonIconSizes = List.of(16, 22, 32, 48, 64, 128); 425 426 int idx = Collections.binarySearch(commonIconSizes, iconSize); 427 if (idx < 0) { 428 // Given icon size is greater than the largest common icon size. 429 return commonIconSizes.get(commonIconSizes.size() - 1); 430 } 431 432 if (idx == 0) { 433 // Given icon size is less or equal than the smallest common icon size. 434 return commonIconSizes.get(idx); 435 } 436 437 int commonIconSize = commonIconSizes.get(idx); 438 if (iconSize < commonIconSize) { 439 // It is better to scale down original icon than to scale it up for 440 // better visual quality. 441 commonIconSize = commonIconSizes.get(idx - 1); 442 } 443 444 return commonIconSize; 445 } 446 447 private static String stringifyShellCommands(String... commands) { 448 return stringifyShellCommands(Arrays.asList(commands)); 449 } 450 451 private static String stringifyShellCommands(List<String> commands) { 452 return String.join(System.lineSeparator(), commands.stream().filter( 453 s -> s != null && !s.isEmpty()).collect(Collectors.toList())); 454 } 455 456 private static class LinuxFileAssociation { 457 LinuxFileAssociation(FileAssociation fa) { 458 this.data = fa; 459 if (fa.iconPath != null && Files.isReadable(fa.iconPath)) { 460 iconSize = getSquareSizeOfImage(fa.iconPath.toFile()); 461 } else { 462 iconSize = -1; 463 } 464 } 465 466 final FileAssociation data; 467 final int iconSize; 468 } 469 470 private final PlatformPackage thePackage; 471 472 private final List<LinuxFileAssociation> associations; 473 474 private final List<Map<String, ? super Object>> launchers; 475 476 private final OverridableResource iconResource; 477 private final OverridableResource desktopFileResource; 478 479 private final DesktopFile mimeInfoFile; 480 private final DesktopFile desktopFile; 481 private final DesktopFile iconFile; 482 483 private final List<DesktopIntegration> nestedIntegrations; 484 485 private final Map<String, String> desktopFileData; 486 487 private static final BundlerParamInfo<String> MENU_GROUP = 488 new StandardBundlerParam<>( 489 Arguments.CLIOptions.LINUX_MENU_GROUP.getId(), 490 String.class, 491 params -> I18N.getString("param.menu-group.default"), 492 (s, p) -> s 493 ); 494 495 private static final StandardBundlerParam<Boolean> SHORTCUT_HINT = 496 new StandardBundlerParam<>( 497 Arguments.CLIOptions.LINUX_SHORTCUT_HINT.getId(), 498 Boolean.class, 499 params -> false, 500 (s, p) -> (s == null || "null".equalsIgnoreCase(s)) 501 ? false : Boolean.valueOf(s) 502 ); 503 }