001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.Utils.getSystemEnv;
006import static org.openstreetmap.josm.tools.Utils.getSystemProperty;
007
008import java.awt.Desktop;
009import java.awt.event.KeyEvent;
010import java.io.BufferedReader;
011import java.io.File;
012import java.io.IOException;
013import java.io.InputStream;
014import java.net.URI;
015import java.net.URISyntaxException;
016import java.nio.charset.StandardCharsets;
017import java.nio.file.Files;
018import java.nio.file.Path;
019import java.nio.file.Paths;
020import java.security.KeyStoreException;
021import java.security.NoSuchAlgorithmException;
022import java.security.cert.CertificateException;
023import java.security.cert.CertificateFactory;
024import java.security.cert.X509Certificate;
025import java.util.Arrays;
026import java.util.Locale;
027import java.util.concurrent.ExecutionException;
028
029import org.openstreetmap.josm.Main;
030import org.openstreetmap.josm.io.CertificateAmendment.NativeCertAmend;
031import org.openstreetmap.josm.spi.preferences.Config;
032
033/**
034 * {@code PlatformHook} implementation for Unix systems.
035 * @since 1023
036 */
037public class PlatformHookUnixoid implements PlatformHook {
038
039    private String osDescription;
040
041    @Override
042    public Platform getPlatform() {
043        return Platform.UNIXOID;
044    }
045
046    @Override
047    public void preStartupHook() {
048        // See #12022 - Disable GNOME ATK Java wrapper as it causes a lot of serious trouble
049        if ("org.GNOME.Accessibility.AtkWrapper".equals(getSystemProperty("assistive_technologies"))) {
050            System.clearProperty("assistive_technologies");
051        }
052    }
053
054    @Override
055    public void openUrl(String url) throws IOException {
056        for (String program : Config.getPref().getList("browser.unix",
057                Arrays.asList("xdg-open", "#DESKTOP#", "$BROWSER", "gnome-open", "kfmclient openURL", "firefox"))) {
058            try {
059                if ("#DESKTOP#".equals(program)) {
060                    Desktop.getDesktop().browse(new URI(url));
061                } else if (program.startsWith("$")) {
062                    program = System.getenv().get(program.substring(1));
063                    Runtime.getRuntime().exec(new String[]{program, url});
064                } else {
065                    Runtime.getRuntime().exec(new String[]{program, url});
066                }
067                return;
068            } catch (IOException | URISyntaxException e) {
069                Logging.warn(e);
070            }
071        }
072    }
073
074    @Override
075    public void initSystemShortcuts() {
076        // CHECKSTYLE.OFF: LineLength
077        // TODO: Insert system shortcuts here. See Windows and especially OSX to see how to.
078        for (int i = KeyEvent.VK_F1; i <= KeyEvent.VK_F12; ++i) {
079            Shortcut.registerSystemShortcut("screen:toogle"+i, tr("reserved"), i, KeyEvent.CTRL_DOWN_MASK | KeyEvent.ALT_DOWN_MASK)
080                .setAutomatic();
081        }
082        Shortcut.registerSystemShortcut("system:reset", tr("reserved"), KeyEvent.VK_DELETE, KeyEvent.CTRL_DOWN_MASK | KeyEvent.ALT_DOWN_MASK)
083            .setAutomatic();
084        Shortcut.registerSystemShortcut("system:resetX", tr("reserved"), KeyEvent.VK_BACK_SPACE, KeyEvent.CTRL_DOWN_MASK | KeyEvent.ALT_DOWN_MASK)
085            .setAutomatic();
086        // CHECKSTYLE.ON: LineLength
087    }
088
089    @Override
090    public String getDefaultStyle() {
091        return "javax.swing.plaf.metal.MetalLookAndFeel";
092    }
093
094    /**
095     * Determines if the distribution is Debian or Ubuntu, or a derivative.
096     * @return {@code true} if the distribution is Debian, Ubuntu or Mint, {@code false} otherwise
097     */
098    public static boolean isDebianOrUbuntu() {
099        try {
100            String dist = Utils.execOutput(Arrays.asList("lsb_release", "-i", "-s"));
101            return "Debian".equalsIgnoreCase(dist) || "Ubuntu".equalsIgnoreCase(dist) || "Mint".equalsIgnoreCase(dist);
102        } catch (IOException | ExecutionException | InterruptedException e) {
103            // lsb_release is not available on all Linux systems, so don't log at warning level
104            Logging.debug(e);
105            return false;
106        }
107    }
108
109    /**
110     * Get the package name including detailed version.
111     * @param packageNames The possible package names (when a package can have different names on different distributions)
112     * @return The package name and package version if it can be identified, null otherwise
113     * @since 7314
114     */
115    public static String getPackageDetails(String... packageNames) {
116        try {
117            // CHECKSTYLE.OFF: SingleSpaceSeparator
118            boolean dpkg = Paths.get("/usr/bin/dpkg-query").toFile().exists();
119            boolean eque = Paths.get("/usr/bin/equery").toFile().exists();
120            boolean rpm  = Paths.get("/bin/rpm").toFile().exists();
121            // CHECKSTYLE.ON: SingleSpaceSeparator
122            if (dpkg || rpm || eque) {
123                for (String packageName : packageNames) {
124                    String[] args;
125                    if (dpkg) {
126                        args = new String[] {"dpkg-query", "--show", "--showformat", "${Architecture}-${Version}", packageName};
127                    } else if (eque) {
128                        args = new String[] {"equery", "-q", "list", "-e", "--format=$fullversion", packageName};
129                    } else {
130                        args = new String[] {"rpm", "-q", "--qf", "%{arch}-%{version}", packageName};
131                    }
132                    try {
133                        String version = Utils.execOutput(Arrays.asList(args));
134                        if (version != null && !version.isEmpty()) {
135                            return packageName + ':' + version;
136                        }
137                    } catch (ExecutionException e) {
138                        // Package does not exist, continue
139                        Logging.trace(e);
140                    }
141                }
142            }
143        } catch (IOException | InterruptedException e) {
144            Logging.warn(e);
145        }
146        return null;
147    }
148
149    /**
150     * Get the Java package name including detailed version.
151     *
152     * Some Java bugs are specific to a certain security update, so in addition
153     * to the Java version, we also need the exact package version.
154     *
155     * @return The package name and package version if it can be identified, null otherwise
156     */
157    public String getJavaPackageDetails() {
158        String home = getSystemProperty("java.home");
159        if (home.contains("java-8-openjdk") || home.contains("java-1.8.0-openjdk")) {
160            return getPackageDetails("openjdk-8-jre", "java-1_8_0-openjdk", "java-1.8.0-openjdk");
161        } else if (home.contains("java-9-openjdk") || home.contains("java-1.9.0-openjdk")) {
162            return getPackageDetails("openjdk-9-jre", "java-1_9_0-openjdk", "java-1.9.0-openjdk", "java-9-openjdk");
163        } else if (home.contains("java-10-openjdk")) {
164            return getPackageDetails("openjdk-10-jre", "java-10-openjdk");
165        } else if (home.contains("java-11-openjdk")) {
166            return getPackageDetails("openjdk-11-jre", "java-11-openjdk");
167        } else if (home.contains("java-openjdk")) {
168            return getPackageDetails("java-openjdk");
169        } else if (home.contains("icedtea")) {
170            return getPackageDetails("icedtea-bin");
171        } else if (home.contains("oracle")) {
172            return getPackageDetails("oracle-jdk-bin", "oracle-jre-bin");
173        }
174        return null;
175    }
176
177    /**
178     * Get the Web Start package name including detailed version.
179     *
180     * OpenJDK packages are shipped with icedtea-web package,
181     * but its version generally does not match main java package version.
182     *
183     * Simply return {@code null} if there's no separate package for Java WebStart.
184     *
185     * @return The package name and package version if it can be identified, null otherwise
186     */
187    public String getWebStartPackageDetails() {
188        if (isOpenJDK()) {
189            return getPackageDetails("icedtea-netx", "icedtea-web");
190        }
191        return null;
192    }
193
194    /**
195     * Get the Gnome ATK wrapper package name including detailed version.
196     *
197     * Debian and Ubuntu derivatives come with a pre-enabled accessibility software
198     * completely buggy that makes Swing crash in a lot of different ways.
199     *
200     * Simply return {@code null} if it's not found.
201     *
202     * @return The package name and package version if it can be identified, null otherwise
203     */
204    public String getAtkWrapperPackageDetails() {
205        if (isOpenJDK() && isDebianOrUbuntu()) {
206            return getPackageDetails("libatk-wrapper-java");
207        }
208        return null;
209    }
210
211    private String buildOSDescription() {
212        String osName = getSystemProperty("os.name");
213        if ("Linux".equalsIgnoreCase(osName)) {
214            try {
215                // Try lsb_release (only available on LSB-compliant Linux systems,
216                // see https://www.linuxbase.org/lsb-cert/productdir.php?by_prod )
217                String line = exec("lsb_release", "-ds");
218                if (line != null && !line.isEmpty()) {
219                    line = line.replaceAll("\"+", "");
220                    line = line.replaceAll("NAME=", ""); // strange code for some Gentoo's
221                    if (line.startsWith("Linux ")) // e.g. Linux Mint
222                        return line;
223                    else if (!line.isEmpty())
224                        return "Linux " + line;
225                }
226            } catch (IOException e) {
227                Logging.debug(e);
228                // Non LSB-compliant Linux system. List of common fallback release files: http://linuxmafia.com/faq/Admin/release-files.html
229                for (LinuxReleaseInfo info : new LinuxReleaseInfo[]{
230                        new LinuxReleaseInfo("/etc/lsb-release", "DISTRIB_DESCRIPTION", "DISTRIB_ID", "DISTRIB_RELEASE"),
231                        new LinuxReleaseInfo("/etc/os-release", "PRETTY_NAME", "NAME", "VERSION"),
232                        new LinuxReleaseInfo("/etc/arch-release"),
233                        new LinuxReleaseInfo("/etc/debian_version", "Debian GNU/Linux "),
234                        new LinuxReleaseInfo("/etc/fedora-release"),
235                        new LinuxReleaseInfo("/etc/gentoo-release"),
236                        new LinuxReleaseInfo("/etc/redhat-release"),
237                        new LinuxReleaseInfo("/etc/SuSE-release")
238                }) {
239                    String description = info.extractDescription();
240                    if (description != null && !description.isEmpty()) {
241                        return "Linux " + description;
242                    }
243                }
244            }
245        }
246        return osName;
247    }
248
249    @Override
250    public String getOSDescription() {
251        if (osDescription == null) {
252            osDescription = buildOSDescription();
253        }
254        return osDescription;
255    }
256
257    private static class LinuxReleaseInfo {
258        private final String path;
259        private final String descriptionField;
260        private final String idField;
261        private final String releaseField;
262        private final boolean plainText;
263        private final String prefix;
264
265        LinuxReleaseInfo(String path, String descriptionField, String idField, String releaseField) {
266            this(path, descriptionField, idField, releaseField, false, null);
267        }
268
269        LinuxReleaseInfo(String path) {
270            this(path, null, null, null, true, null);
271        }
272
273        LinuxReleaseInfo(String path, String prefix) {
274            this(path, null, null, null, true, prefix);
275        }
276
277        private LinuxReleaseInfo(String path, String descriptionField, String idField, String releaseField, boolean plainText, String prefix) {
278            this.path = path;
279            this.descriptionField = descriptionField;
280            this.idField = idField;
281            this.releaseField = releaseField;
282            this.plainText = plainText;
283            this.prefix = prefix;
284        }
285
286        @Override
287        public String toString() {
288            return "ReleaseInfo [path=" + path + ", descriptionField=" + descriptionField +
289                    ", idField=" + idField + ", releaseField=" + releaseField + ']';
290        }
291
292        /**
293         * Extracts OS detailed information from a Linux release file (/etc/xxx-release)
294         * @return The OS detailed information, or {@code null}
295         */
296        public String extractDescription() {
297            String result = null;
298            if (path != null) {
299                Path p = Paths.get(path);
300                if (p.toFile().exists()) {
301                    try (BufferedReader reader = Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
302                        String id = null;
303                        String release = null;
304                        String line;
305                        while (result == null && (line = reader.readLine()) != null) {
306                            if (line.contains("=")) {
307                                String[] tokens = line.split("=");
308                                if (tokens.length >= 2) {
309                                    // Description, if available, contains exactly what we need
310                                    if (descriptionField != null && descriptionField.equalsIgnoreCase(tokens[0])) {
311                                        result = Utils.strip(tokens[1]);
312                                    } else if (idField != null && idField.equalsIgnoreCase(tokens[0])) {
313                                        id = Utils.strip(tokens[1]);
314                                    } else if (releaseField != null && releaseField.equalsIgnoreCase(tokens[0])) {
315                                        release = Utils.strip(tokens[1]);
316                                    }
317                                }
318                            } else if (plainText && !line.isEmpty()) {
319                                // Files composed of a single line
320                                result = Utils.strip(line);
321                            }
322                        }
323                        // If no description has been found, try to rebuild it with "id" + "release" (i.e. "name" + "version")
324                        if (result == null && id != null && release != null) {
325                            result = id + ' ' + release;
326                        }
327                    } catch (IOException e) {
328                        // Ignore
329                        Logging.trace(e);
330                    }
331                }
332            }
333            // Append prefix if any
334            if (result != null && !result.isEmpty() && prefix != null && !prefix.isEmpty()) {
335                result = prefix + result;
336            }
337            if (result != null)
338                result = result.replaceAll("\"+", "");
339            return result;
340        }
341    }
342
343    /**
344     * Get the dot directory <code>~/.josm</code>.
345     * @return the dot directory
346     */
347    private static File getDotDirectory() {
348        String dirName = "." + Main.pref.getJOSMDirectoryBaseName().toLowerCase(Locale.ENGLISH);
349        return new File(getSystemProperty("user.home"), dirName);
350    }
351
352    /**
353     * Returns true if the dot directory should be used for storing preferences,
354     * cache and user data.
355     * Currently this is the case, if the dot directory already exists.
356     * @return true if the dot directory should be used
357     */
358    private static boolean useDotDirectory() {
359        return getDotDirectory().exists();
360    }
361
362    @Override
363    public File getDefaultCacheDirectory() {
364        if (useDotDirectory()) {
365            return new File(getDotDirectory(), "cache");
366        } else {
367            String xdgCacheDir = getSystemEnv("XDG_CACHE_HOME");
368            if (xdgCacheDir != null && !xdgCacheDir.isEmpty()) {
369                return new File(xdgCacheDir, Main.pref.getJOSMDirectoryBaseName());
370            } else {
371                return new File(getSystemProperty("user.home") + File.separator +
372                        ".cache" + File.separator + Main.pref.getJOSMDirectoryBaseName());
373            }
374        }
375    }
376
377    @Override
378    public File getDefaultPrefDirectory() {
379        if (useDotDirectory()) {
380            return getDotDirectory();
381        } else {
382            String xdgConfigDir = getSystemEnv("XDG_CONFIG_HOME");
383            if (xdgConfigDir != null && !xdgConfigDir.isEmpty()) {
384                return new File(xdgConfigDir, Main.pref.getJOSMDirectoryBaseName());
385            } else {
386                return new File(getSystemProperty("user.home") + File.separator +
387                        ".config" + File.separator + Main.pref.getJOSMDirectoryBaseName());
388            }
389        }
390    }
391
392    @Override
393    public File getDefaultUserDataDirectory() {
394        if (useDotDirectory()) {
395            return getDotDirectory();
396        } else {
397            String xdgDataDir = getSystemEnv("XDG_DATA_HOME");
398            if (xdgDataDir != null && !xdgDataDir.isEmpty()) {
399                return new File(xdgDataDir, Main.pref.getJOSMDirectoryBaseName());
400            } else {
401                return new File(getSystemProperty("user.home") + File.separator +
402                        ".local" + File.separator + "share" + File.separator + Main.pref.getJOSMDirectoryBaseName());
403            }
404        }
405    }
406
407    @Override
408    public X509Certificate getX509Certificate(NativeCertAmend certAmend)
409            throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException {
410        for (String dir : new String[] {"/etc/ssl/certs", "/usr/share/ca-certificates/mozilla"}) {
411            File f = new File(dir, certAmend.getFilename());
412            if (f.exists()) {
413                CertificateFactory fact = CertificateFactory.getInstance("X.509");
414                try (InputStream is = Files.newInputStream(f.toPath())) {
415                    return (X509Certificate) fact.generateCertificate(is);
416                }
417            }
418        }
419        return null;
420    }
421}