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