• 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    """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 == "serenityos":
536        # SerenityOS webbrowser, simply called "Browser".
537        register("Browser", None, BackgroundBrowser("Browser"))
538
539    if sys.platform[:3] == "win":
540        # First try to use the default Windows browser
541        register("windows-default", WindowsDefault)
542
543        # Detect some common Windows browsers, fallback to IE
544        iexplore = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"),
545                                "Internet Explorer\\IEXPLORE.EXE")
546        for browser in ("firefox", "firebird", "seamonkey", "mozilla",
547                        "netscape", "opera", iexplore):
548            if shutil.which(browser):
549                register(browser, None, BackgroundBrowser(browser))
550    else:
551        # Prefer X browsers if present
552        if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
553            try:
554                cmd = "xdg-settings get default-web-browser".split()
555                raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
556                result = raw_result.decode().strip()
557            except (FileNotFoundError, subprocess.CalledProcessError, PermissionError, NotADirectoryError) :
558                pass
559            else:
560                global _os_preferred_browser
561                _os_preferred_browser = result
562
563            register_X_browsers()
564
565        # Also try console browsers
566        if os.environ.get("TERM"):
567            if shutil.which("www-browser"):
568                register("www-browser", None, GenericBrowser("www-browser"))
569            # The Links/elinks browsers <http://artax.karlin.mff.cuni.cz/~mikulas/links/>
570            if shutil.which("links"):
571                register("links", None, GenericBrowser("links"))
572            if shutil.which("elinks"):
573                register("elinks", None, Elinks("elinks"))
574            # The Lynx browser <http://lynx.isc.org/>, <http://lynx.browser.org/>
575            if shutil.which("lynx"):
576                register("lynx", None, GenericBrowser("lynx"))
577            # The w3m browser <http://w3m.sourceforge.net/>
578            if shutil.which("w3m"):
579                register("w3m", None, GenericBrowser("w3m"))
580
581    # OK, now that we know what the default preference orders for each
582    # platform are, allow user to override them with the BROWSER variable.
583    if "BROWSER" in os.environ:
584        userchoices = os.environ["BROWSER"].split(os.pathsep)
585        userchoices.reverse()
586
587        # Treat choices in same way as if passed into get() but do register
588        # and prepend to _tryorder
589        for cmdline in userchoices:
590            if cmdline != '':
591                cmd = _synthesize(cmdline, preferred=True)
592                if cmd[1] is None:
593                    register(cmdline, None, GenericBrowser(cmdline), preferred=True)
594
595    # what to do if _tryorder is now empty?
596
597
598#
599# Platform support for Windows
600#
601
602if sys.platform[:3] == "win":
603    class WindowsDefault(BaseBrowser):
604        def open(self, url, new=0, autoraise=True):
605            sys.audit("webbrowser.open", url)
606            try:
607                os.startfile(url)
608            except OSError:
609                # [Error 22] No application is associated with the specified
610                # file for this operation: '<URL>'
611                return False
612            else:
613                return True
614
615#
616# Platform support for MacOS
617#
618
619if sys.platform == 'darwin':
620    # Adapted from patch submitted to SourceForge by Steven J. Burr
621    class MacOSX(BaseBrowser):
622        """Launcher class for Aqua browsers on Mac OS X
623
624        Optionally specify a browser name on instantiation.  Note that this
625        will not work for Aqua browsers if the user has moved the application
626        package after installation.
627
628        If no browser is specified, the default browser, as specified in the
629        Internet System Preferences panel, will be used.
630        """
631        def __init__(self, name):
632            self.name = name
633
634        def open(self, url, new=0, autoraise=True):
635            sys.audit("webbrowser.open", url)
636            assert "'" not in url
637            # hack for local urls
638            if not ':' in url:
639                url = 'file:'+url
640
641            # new must be 0 or 1
642            new = int(bool(new))
643            if self.name == "default":
644                # User called open, open_new or get without a browser parameter
645                script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
646            else:
647                # User called get and chose a browser
648                if self.name == "OmniWeb":
649                    toWindow = ""
650                else:
651                    # Include toWindow parameter of OpenURL command for browsers
652                    # that support it.  0 == new window; -1 == existing
653                    toWindow = "toWindow %d" % (new - 1)
654                cmd = 'OpenURL "%s"' % url.replace('"', '%22')
655                script = '''tell application "%s"
656                                activate
657                                %s %s
658                            end tell''' % (self.name, cmd, toWindow)
659            # Open pipe to AppleScript through osascript command
660            osapipe = os.popen("osascript", "w")
661            if osapipe is None:
662                return False
663            # Write script to osascript's stdin
664            osapipe.write(script)
665            rc = osapipe.close()
666            return not rc
667
668    class MacOSXOSAScript(BaseBrowser):
669        def __init__(self, name):
670            self._name = name
671
672        def open(self, url, new=0, autoraise=True):
673            if self._name == 'default':
674                script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
675            else:
676                script = '''
677                   tell application "%s"
678                       activate
679                       open location "%s"
680                   end
681                   '''%(self._name, url.replace('"', '%22'))
682
683            osapipe = os.popen("osascript", "w")
684            if osapipe is None:
685                return False
686
687            osapipe.write(script)
688            rc = osapipe.close()
689            return not rc
690
691
692def main():
693    import getopt
694    usage = """Usage: %s [-n | -t] url
695    -n: open new window
696    -t: open new tab""" % sys.argv[0]
697    try:
698        opts, args = getopt.getopt(sys.argv[1:], 'ntd')
699    except getopt.error as msg:
700        print(msg, file=sys.stderr)
701        print(usage, file=sys.stderr)
702        sys.exit(1)
703    new_win = 0
704    for o, a in opts:
705        if o == '-n': new_win = 1
706        elif o == '-t': new_win = 2
707    if len(args) != 1:
708        print(usage, file=sys.stderr)
709        sys.exit(1)
710
711    url = args[0]
712    open(url, new_win)
713
714    print("\a")
715
716if __name__ == "__main__":
717    main()
718