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