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