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