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