1#! /usr/bin/env python3
2"""Interfaces for launching and remotely controlling Web browsers."""
3# Maintained by Georg Brandl.
4
5import os
6import shlex
7import shutil
8import sys
9import subprocess
10import threading
11
12__all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"]
13
14class Error(Exception):
15    pass
16
17_lock = threading.RLock()
18_browsers = {}                  # Dictionary of available browser controllers
19_tryorder = None                # Preference order of available browsers
20_os_preferred_browser = None    # The preferred browser
21
22def register(name, klass, instance=None, *, preferred=False):
23    """Register a browser connector."""
24    with _lock:
25        if _tryorder is None:
26            register_standard_browsers()
27        _browsers[name.lower()] = [klass, instance]
28
29        # Preferred browsers go to the front of the list.
30        # Need to match to the default browser returned by xdg-settings, which
31        # may be of the form e.g. "firefox.desktop".
32        if preferred or (_os_preferred_browser and name in _os_preferred_browser):
33            _tryorder.insert(0, name)
34        else:
35            _tryorder.append(name)
36
37def get(using=None):
38    """Return a browser launcher instance appropriate for the environment."""
39    if _tryorder is None:
40        with _lock:
41            if _tryorder is None:
42                register_standard_browsers()
43    if using is not None:
44        alternatives = [using]
45    else:
46        alternatives = _tryorder
47    for browser in alternatives:
48        if '%s' in browser:
49            # User gave us a command line, split it into name and args
50            browser = shlex.split(browser)
51            if browser[-1] == '&':
52                return BackgroundBrowser(browser[:-1])
53            else:
54                return GenericBrowser(browser)
55        else:
56            # User gave us a browser name or path.
57            try:
58                command = _browsers[browser.lower()]
59            except KeyError:
60                command = _synthesize(browser)
61            if command[1] is not None:
62                return command[1]
63            elif command[0] is not None:
64                return command[0]()
65    raise Error("could not locate runnable browser")
66
67# Please note: the following definition hides a builtin function.
68# It is recommended one does "import webbrowser" and uses webbrowser.open(url)
69# instead of "from webbrowser import *".
70
71def open(url, new=0, autoraise=True):
72    """Display url using the default browser.
73
74    If possible, open url in a location determined by new.
75    - 0: the same browser window (the default).
76    - 1: a new browser window.
77    - 2: a new browser page ("tab").
78    If possible, autoraise raises the window (the default) or not.
79    """
80    if _tryorder is None:
81        with _lock:
82            if _tryorder is None:
83                register_standard_browsers()
84    for name in _tryorder:
85        browser = get(name)
86        if browser.open(url, new, autoraise):
87            return True
88    return False
89
90def open_new(url):
91    """Open url in a new window of the default browser.
92
93    If not possible, then open url in the only browser window.
94    """
95    return open(url, 1)
96
97def open_new_tab(url):
98    """Open url in a new page ("tab") of the default browser.
99
100    If not possible, then the behavior becomes equivalent to open_new().
101    """
102    return open(url, 2)
103
104
105def _synthesize(browser, *, preferred=False):
106    """Attempt to synthesize a controller based on existing controllers.
107
108    This is useful to create a controller when a user specifies a path to
109    an entry in the BROWSER environment variable -- we can copy a general
110    controller to operate using a specific installation of the desired
111    browser in this way.
112
113    If we can't create a controller in this way, or if there is no
114    executable for the requested browser, return [None, None].
115
116    """
117    cmd = browser.split()[0]
118    if not shutil.which(cmd):
119        return [None, None]
120    name = os.path.basename(cmd)
121    try:
122        command = _browsers[name.lower()]
123    except KeyError:
124        return [None, None]
125    # now attempt to clone to fit the new name:
126    controller = command[1]
127    if controller and name.lower() == controller.basename:
128        import copy
129        controller = copy.copy(controller)
130        controller.name = browser
131        controller.basename = os.path.basename(browser)
132        register(browser, None, instance=controller, preferred=preferred)
133        return [None, controller]
134    return [None, None]
135
136
137# General parent classes
138
139class BaseBrowser(object):
140    """Parent class for all browsers. Do not use directly."""
141
142    args = ['%s']
143
144    def __init__(self, name=""):
145        self.name = name
146        self.basename = name
147
148    def open(self, url, new=0, autoraise=True):
149        raise NotImplementedError
150
151    def open_new(self, url):
152        return self.open(url, 1)
153
154    def open_new_tab(self, url):
155        return self.open(url, 2)
156
157
158class GenericBrowser(BaseBrowser):
159    """Class for all browsers started with a command
160       and without remote functionality."""
161
162    def __init__(self, name):
163        if isinstance(name, str):
164            self.name = name
165            self.args = ["%s"]
166        else:
167            # name should be a list with arguments
168            self.name = name[0]
169            self.args = name[1:]
170        self.basename = os.path.basename(self.name)
171
172    def open(self, url, new=0, autoraise=True):
173        sys.audit("webbrowser.open", url)
174        cmdline = [self.name] + [arg.replace("%s", url)
175                                 for arg in self.args]
176        try:
177            if sys.platform[:3] == 'win':
178                p = subprocess.Popen(cmdline)
179            else:
180                p = subprocess.Popen(cmdline, close_fds=True)
181            return not p.wait()
182        except OSError:
183            return False
184
185
186class BackgroundBrowser(GenericBrowser):
187    """Class for all browsers which are to be started in the
188       background."""
189
190    def open(self, url, new=0, autoraise=True):
191        cmdline = [self.name] + [arg.replace("%s", url)
192                                 for arg in self.args]
193        sys.audit("webbrowser.open", url)
194        try:
195            if sys.platform[:3] == 'win':
196                p = subprocess.Popen(cmdline)
197            else:
198                p = subprocess.Popen(cmdline, close_fds=True,
199                                     start_new_session=True)
200            return (p.poll() is None)
201        except OSError:
202            return False
203
204
205class UnixBrowser(BaseBrowser):
206    """Parent class for all Unix browsers with remote functionality."""
207
208    raise_opts = None
209    background = False
210    redirect_stdout = True
211    # In remote_args, %s will be replaced with the requested URL.  %action will
212    # be replaced depending on the value of 'new' passed to open.
213    # remote_action is used for new=0 (open).  If newwin is not None, it is
214    # used for new=1 (open_new).  If newtab is not None, it is used for
215    # new=3 (open_new_tab).  After both substitutions are made, any empty
216    # strings in the transformed remote_args list will be removed.
217    remote_args = ['%action', '%s']
218    remote_action = None
219    remote_action_newwin = None
220    remote_action_newtab = None
221
222    def _invoke(self, args, remote, autoraise, url=None):
223        raise_opt = []
224        if remote and self.raise_opts:
225            # use autoraise argument only for remote invocation
226            autoraise = int(autoraise)
227            opt = self.raise_opts[autoraise]
228            if opt: raise_opt = [opt]
229
230        cmdline = [self.name] + raise_opt + args
231
232        if remote or self.background:
233            inout = subprocess.DEVNULL
234        else:
235            # for TTY browsers, we need stdin/out
236            inout = None
237        p = subprocess.Popen(cmdline, close_fds=True, stdin=inout,
238                             stdout=(self.redirect_stdout and inout or None),
239                             stderr=inout, start_new_session=True)
240        if remote:
241            # wait at most five seconds. If the subprocess is not finished, the
242            # remote invocation has (hopefully) started a new instance.
243            try:
244                rc = p.wait(5)
245                # if remote call failed, open() will try direct invocation
246                return not rc
247            except subprocess.TimeoutExpired:
248                return True
249        elif self.background:
250            if p.poll() is None:
251                return True
252            else:
253                return False
254        else:
255            return not p.wait()
256
257    def open(self, url, new=0, autoraise=True):
258        sys.audit("webbrowser.open", url)
259        if new == 0:
260            action = self.remote_action
261        elif new == 1:
262            action = self.remote_action_newwin
263        elif new == 2:
264            if self.remote_action_newtab is None:
265                action = self.remote_action_newwin
266            else:
267                action = self.remote_action_newtab
268        else:
269            raise Error("Bad 'new' parameter to open(); " +
270                        "expected 0, 1, or 2, got %s" % new)
271
272        args = [arg.replace("%s", url).replace("%action", action)
273                for arg in self.remote_args]
274        args = [arg for arg in args if arg]
275        success = self._invoke(args, True, autoraise, url)
276        if not success:
277            # remote invocation failed, try straight way
278            args = [arg.replace("%s", url) for arg in self.args]
279            return self._invoke(args, False, False)
280        else:
281            return True
282
283
284class Mozilla(UnixBrowser):
285    """Launcher class for Mozilla browsers."""
286
287    remote_args = ['%action', '%s']
288    remote_action = ""
289    remote_action_newwin = "-new-window"
290    remote_action_newtab = "-new-tab"
291    background = True
292
293
294class Netscape(UnixBrowser):
295    """Launcher class for Netscape browser."""
296
297    raise_opts = ["-noraise", "-raise"]
298    remote_args = ['-remote', 'openURL(%s%action)']
299    remote_action = ""
300    remote_action_newwin = ",new-window"
301    remote_action_newtab = ",new-tab"
302    background = True
303
304
305class Galeon(UnixBrowser):
306    """Launcher class for Galeon/Epiphany browsers."""
307
308    raise_opts = ["-noraise", ""]
309    remote_args = ['%action', '%s']
310    remote_action = "-n"
311    remote_action_newwin = "-w"
312    background = True
313
314
315class Chrome(UnixBrowser):
316    "Launcher class for Google Chrome browser."
317
318    remote_args = ['%action', '%s']
319    remote_action = ""
320    remote_action_newwin = "--new-window"
321    remote_action_newtab = ""
322    background = True
323
324Chromium = Chrome
325
326
327class Opera(UnixBrowser):
328    "Launcher class for Opera browser."
329
330    remote_args = ['%action', '%s']
331    remote_action = ""
332    remote_action_newwin = "--new-window"
333    remote_action_newtab = ""
334    background = True
335
336
337class Elinks(UnixBrowser):
338    "Launcher class for Elinks browsers."
339
340    remote_args = ['-remote', 'openURL(%s%action)']
341    remote_action = ""
342    remote_action_newwin = ",new-window"
343    remote_action_newtab = ",new-tab"
344    background = False
345
346    # elinks doesn't like its stdout to be redirected -
347    # it uses redirected stdout as a signal to do -dump
348    redirect_stdout = False
349
350
351class Konqueror(BaseBrowser):
352    """Controller for the KDE File Manager (kfm, or Konqueror).
353
354    See the output of ``kfmclient --commands``
355    for more information on the Konqueror remote-control interface.
356    """
357
358    def open(self, url, new=0, autoraise=True):
359        sys.audit("webbrowser.open", url)
360        # XXX Currently I know no way to prevent KFM from opening a new win.
361        if new == 2:
362            action = "newTab"
363        else:
364            action = "openURL"
365
366        devnull = subprocess.DEVNULL
367
368        try:
369            p = subprocess.Popen(["kfmclient", action, url],
370                                 close_fds=True, stdin=devnull,
371                                 stdout=devnull, stderr=devnull)
372        except OSError:
373            # fall through to next variant
374            pass
375        else:
376            p.wait()
377            # kfmclient's return code unfortunately has no meaning as it seems
378            return True
379
380        try:
381            p = subprocess.Popen(["konqueror", "--silent", url],
382                                 close_fds=True, stdin=devnull,
383                                 stdout=devnull, stderr=devnull,
384                                 start_new_session=True)
385        except OSError:
386            # fall through to next variant
387            pass
388        else:
389            if p.poll() is None:
390                # Should be running now.
391                return True
392
393        try:
394            p = subprocess.Popen(["kfm", "-d", url],
395                                 close_fds=True, stdin=devnull,
396                                 stdout=devnull, stderr=devnull,
397                                 start_new_session=True)
398        except OSError:
399            return False
400        else:
401            return (p.poll() is None)
402
403
404class Grail(BaseBrowser):
405    # There should be a way to maintain a connection to Grail, but the
406    # Grail remote control protocol doesn't really allow that at this
407    # point.  It probably never will!
408    def _find_grail_rc(self):
409        import glob
410        import pwd
411        import socket
412        import tempfile
413        tempdir = os.path.join(tempfile.gettempdir(),
414                               ".grail-unix")
415        user = pwd.getpwuid(os.getuid())[0]
416        filename = os.path.join(glob.escape(tempdir), glob.escape(user) + "-*")
417        maybes = glob.glob(filename)
418        if not maybes:
419            return None
420        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
421        for fn in maybes:
422            # need to PING each one until we find one that's live
423            try:
424                s.connect(fn)
425            except OSError:
426                # no good; attempt to clean it out, but don't fail:
427                try:
428                    os.unlink(fn)
429                except OSError:
430                    pass
431            else:
432                return s
433
434    def _remote(self, action):
435        s = self._find_grail_rc()
436        if not s:
437            return 0
438        s.send(action)
439        s.close()
440        return 1
441
442    def open(self, url, new=0, autoraise=True):
443        sys.audit("webbrowser.open", url)
444        if new:
445            ok = self._remote("LOADNEW " + url)
446        else:
447            ok = self._remote("LOAD " + url)
448        return ok
449
450
451#
452# Platform support for Unix
453#
454
455# These are the right tests because all these Unix browsers require either
456# a console terminal or an X display to run.
457
458def register_X_browsers():
459
460    # use xdg-open if around
461    if shutil.which("xdg-open"):
462        register("xdg-open", None, BackgroundBrowser("xdg-open"))
463
464    # The default GNOME3 browser
465    if "GNOME_DESKTOP_SESSION_ID" in os.environ and shutil.which("gvfs-open"):
466        register("gvfs-open", None, BackgroundBrowser("gvfs-open"))
467
468    # The default GNOME browser
469    if "GNOME_DESKTOP_SESSION_ID" in os.environ and shutil.which("gnome-open"):
470        register("gnome-open", None, BackgroundBrowser("gnome-open"))
471
472    # The default KDE browser
473    if "KDE_FULL_SESSION" in os.environ and shutil.which("kfmclient"):
474        register("kfmclient", Konqueror, Konqueror("kfmclient"))
475
476    if shutil.which("x-www-browser"):
477        register("x-www-browser", None, BackgroundBrowser("x-www-browser"))
478
479    # The Mozilla browsers
480    for browser in ("firefox", "iceweasel", "iceape", "seamonkey"):
481        if shutil.which(browser):
482            register(browser, None, Mozilla(browser))
483
484    # The Netscape and old Mozilla browsers
485    for browser in ("mozilla-firefox",
486                    "mozilla-firebird", "firebird",
487                    "mozilla", "netscape"):
488        if shutil.which(browser):
489            register(browser, None, Netscape(browser))
490
491    # Konqueror/kfm, the KDE browser.
492    if shutil.which("kfm"):
493        register("kfm", Konqueror, Konqueror("kfm"))
494    elif shutil.which("konqueror"):
495        register("konqueror", Konqueror, Konqueror("konqueror"))
496
497    # Gnome's Galeon and Epiphany
498    for browser in ("galeon", "epiphany"):
499        if shutil.which(browser):
500            register(browser, None, Galeon(browser))
501
502    # Skipstone, another Gtk/Mozilla based browser
503    if shutil.which("skipstone"):
504        register("skipstone", None, BackgroundBrowser("skipstone"))
505
506    # Google Chrome/Chromium browsers
507    for browser in ("google-chrome", "chrome", "chromium", "chromium-browser"):
508        if shutil.which(browser):
509            register(browser, None, Chrome(browser))
510
511    # Opera, quite popular
512    if shutil.which("opera"):
513        register("opera", None, Opera("opera"))
514
515    # Next, Mosaic -- old but still in use.
516    if shutil.which("mosaic"):
517        register("mosaic", None, BackgroundBrowser("mosaic"))
518
519    # Grail, the Python browser. Does anybody still use it?
520    if shutil.which("grail"):
521        register("grail", Grail, None)
522
523def register_standard_browsers():
524    global _tryorder
525    _tryorder = []
526
527    if sys.platform == 'darwin':
528        register("MacOSX", None, MacOSXOSAScript('default'))
529        register("chrome", None, MacOSXOSAScript('chrome'))
530        register("firefox", None, MacOSXOSAScript('firefox'))
531        register("safari", None, MacOSXOSAScript('safari'))
532        # OS X can use below Unix support (but we prefer using the OS X
533        # specific stuff)
534
535    if sys.platform[:3] == "win":
536        # First try to use the default Windows browser
537        register("windows-default", WindowsDefault)
538
539        # Detect some common Windows browsers, fallback to IE
540        iexplore = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"),
541                                "Internet Explorer\\IEXPLORE.EXE")
542        for browser in ("firefox", "firebird", "seamonkey", "mozilla",
543                        "netscape", "opera", iexplore):
544            if shutil.which(browser):
545                register(browser, None, BackgroundBrowser(browser))
546    else:
547        # Prefer X browsers if present
548        if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
549            try:
550                cmd = "xdg-settings get default-web-browser".split()
551                raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
552                result = raw_result.decode().strip()
553            except (FileNotFoundError, subprocess.CalledProcessError, PermissionError, NotADirectoryError) :
554                pass
555            else:
556                global _os_preferred_browser
557                _os_preferred_browser = result
558
559            register_X_browsers()
560
561        # Also try console browsers
562        if os.environ.get("TERM"):
563            if shutil.which("www-browser"):
564                register("www-browser", None, GenericBrowser("www-browser"))
565            # The Links/elinks browsers <http://artax.karlin.mff.cuni.cz/~mikulas/links/>
566            if shutil.which("links"):
567                register("links", None, GenericBrowser("links"))
568            if shutil.which("elinks"):
569                register("elinks", None, Elinks("elinks"))
570            # The Lynx browser <http://lynx.isc.org/>, <http://lynx.browser.org/>
571            if shutil.which("lynx"):
572                register("lynx", None, GenericBrowser("lynx"))
573            # The w3m browser <http://w3m.sourceforge.net/>
574            if shutil.which("w3m"):
575                register("w3m", None, GenericBrowser("w3m"))
576
577    # OK, now that we know what the default preference orders for each
578    # platform are, allow user to override them with the BROWSER variable.
579    if "BROWSER" in os.environ:
580        userchoices = os.environ["BROWSER"].split(os.pathsep)
581        userchoices.reverse()
582
583        # Treat choices in same way as if passed into get() but do register
584        # and prepend to _tryorder
585        for cmdline in userchoices:
586            if cmdline != '':
587                cmd = _synthesize(cmdline, preferred=True)
588                if cmd[1] is None:
589                    register(cmdline, None, GenericBrowser(cmdline), preferred=True)
590
591    # what to do if _tryorder is now empty?
592
593
594#
595# Platform support for Windows
596#
597
598if sys.platform[:3] == "win":
599    class WindowsDefault(BaseBrowser):
600        def open(self, url, new=0, autoraise=True):
601            sys.audit("webbrowser.open", url)
602            try:
603                os.startfile(url)
604            except OSError:
605                # [Error 22] No application is associated with the specified
606                # file for this operation: '<URL>'
607                return False
608            else:
609                return True
610
611#
612# Platform support for MacOS
613#
614
615if sys.platform == 'darwin':
616    # Adapted from patch submitted to SourceForge by Steven J. Burr
617    class MacOSX(BaseBrowser):
618        """Launcher class for Aqua browsers on Mac OS X
619
620        Optionally specify a browser name on instantiation.  Note that this
621        will not work for Aqua browsers if the user has moved the application
622        package after installation.
623
624        If no browser is specified, the default browser, as specified in the
625        Internet System Preferences panel, will be used.
626        """
627        def __init__(self, name):
628            self.name = name
629
630        def open(self, url, new=0, autoraise=True):
631            sys.audit("webbrowser.open", url)
632            assert "'" not in url
633            # hack for local urls
634            if not ':' in url:
635                url = 'file:'+url
636
637            # new must be 0 or 1
638            new = int(bool(new))
639            if self.name == "default":
640                # User called open, open_new or get without a browser parameter
641                script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
642            else:
643                # User called get and chose a browser
644                if self.name == "OmniWeb":
645                    toWindow = ""
646                else:
647                    # Include toWindow parameter of OpenURL command for browsers
648                    # that support it.  0 == new window; -1 == existing
649                    toWindow = "toWindow %d" % (new - 1)
650                cmd = 'OpenURL "%s"' % url.replace('"', '%22')
651                script = '''tell application "%s"
652                                activate
653                                %s %s
654                            end tell''' % (self.name, cmd, toWindow)
655            # Open pipe to AppleScript through osascript command
656            osapipe = os.popen("osascript", "w")
657            if osapipe is None:
658                return False
659            # Write script to osascript's stdin
660            osapipe.write(script)
661            rc = osapipe.close()
662            return not rc
663
664    class MacOSXOSAScript(BaseBrowser):
665        def __init__(self, name):
666            self._name = name
667
668        def open(self, url, new=0, autoraise=True):
669            if self._name == 'default':
670                script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
671            else:
672                script = '''
673                   tell application "%s"
674                       activate
675                       open location "%s"
676                   end
677                   '''%(self._name, url.replace('"', '%22'))
678
679            osapipe = os.popen("osascript", "w")
680            if osapipe is None:
681                return False
682
683            osapipe.write(script)
684            rc = osapipe.close()
685            return not rc
686
687
688def main():
689    import getopt
690    usage = """Usage: %s [-n | -t] url
691    -n: open new window
692    -t: open new tab""" % sys.argv[0]
693    try:
694        opts, args = getopt.getopt(sys.argv[1:], 'ntd')
695    except getopt.error as msg:
696        print(msg, file=sys.stderr)
697        print(usage, file=sys.stderr)
698        sys.exit(1)
699    new_win = 0
700    for o, a in opts:
701        if o == '-n': new_win = 1
702        elif o == '-t': new_win = 2
703    if len(args) != 1:
704        print(usage, file=sys.stderr)
705        sys.exit(1)
706
707    url = args[0]
708    open(url, new_win)
709
710    print("\a")
711
712if __name__ == "__main__":
713    main()
714