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