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
10
11__all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"]
12
13class Error(Exception):
14    pass
15
16_browsers = {}          # Dictionary of available browser controllers
17_tryorder = []          # Preference order of available browsers
18
19def 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
27def 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
57def 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
64def open_new(url):
65    return open(url, 1)
66
67def open_new_tab(url):
68    return open(url, 2)
69
70
71def _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
105class 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
124class 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
151class 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
169class 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
247class 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
257class 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
268class 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
278class 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
287Chromium = Chrome
288
289
290class 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
301class 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
315class 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
367class 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
420def 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
486if os.environ.get("DISPLAY"):
487    register_X_browsers()
488
489# Also try console browsers
490if 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
509if 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
539if 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.
621if "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
639def 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
663if __name__ == "__main__":
664    main()
665