|
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 } |