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 seconds. 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 Chrome(UnixBrowser): 308 "Launcher class for Google Chrome browser." 309 310 remote_args = ['%action', '%s'] 311 remote_action = "" 312 remote_action_newwin = "--new-window" 313 remote_action_newtab = "" 314 background = True 315 316Chromium = Chrome 317 318 319class Opera(UnixBrowser): 320 "Launcher class for Opera browser." 321 322 remote_args = ['%action', '%s'] 323 remote_action = "" 324 remote_action_newwin = "--new-window" 325 remote_action_newtab = "" 326 background = True 327 328 329class Elinks(UnixBrowser): 330 "Launcher class for Elinks browsers." 331 332 remote_args = ['-remote', 'openURL(%s%action)'] 333 remote_action = "" 334 remote_action_newwin = ",new-window" 335 remote_action_newtab = ",new-tab" 336 background = False 337 338 # elinks doesn't like its stdout to be redirected - 339 # it uses redirected stdout as a signal to do -dump 340 redirect_stdout = False 341 342 343class Konqueror(BaseBrowser): 344 """Controller for the KDE File Manager (kfm, or Konqueror). 345 346 See the output of ``kfmclient --commands`` 347 for more information on the Konqueror remote-control interface. 348 """ 349 350 def open(self, url, new=0, autoraise=True): 351 # XXX Currently I know no way to prevent KFM from opening a new win. 352 if new == 2: 353 action = "newTab" 354 else: 355 action = "openURL" 356 357 devnull = file(os.devnull, "r+") 358 # if possible, put browser in separate process group, so 359 # keyboard interrupts don't affect browser as well as Python 360 setsid = getattr(os, 'setsid', None) 361 if not setsid: 362 setsid = getattr(os, 'setpgrp', None) 363 364 try: 365 p = subprocess.Popen(["kfmclient", action, url], 366 close_fds=True, stdin=devnull, 367 stdout=devnull, stderr=devnull) 368 except OSError: 369 # fall through to next variant 370 pass 371 else: 372 p.wait() 373 # kfmclient's return code unfortunately has no meaning as it seems 374 return True 375 376 try: 377 p = subprocess.Popen(["konqueror", "--silent", url], 378 close_fds=True, stdin=devnull, 379 stdout=devnull, stderr=devnull, 380 preexec_fn=setsid) 381 except OSError: 382 # fall through to next variant 383 pass 384 else: 385 if p.poll() is None: 386 # Should be running now. 387 return True 388 389 try: 390 p = subprocess.Popen(["kfm", "-d", url], 391 close_fds=True, stdin=devnull, 392 stdout=devnull, stderr=devnull, 393 preexec_fn=setsid) 394 except OSError: 395 return False 396 else: 397 return (p.poll() is None) 398 399 400class Grail(BaseBrowser): 401 # There should be a way to maintain a connection to Grail, but the 402 # Grail remote control protocol doesn't really allow that at this 403 # point. It probably never will! 404 def _find_grail_rc(self): 405 import glob 406 import pwd 407 import socket 408 import tempfile 409 tempdir = os.path.join(tempfile.gettempdir(), 410 ".grail-unix") 411 user = pwd.getpwuid(os.getuid())[0] 412 filename = os.path.join(tempdir, user + "-*") 413 maybes = glob.glob(filename) 414 if not maybes: 415 return None 416 s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 417 for fn in maybes: 418 # need to PING each one until we find one that's live 419 try: 420 s.connect(fn) 421 except socket.error: 422 # no good; attempt to clean it out, but don't fail: 423 try: 424 os.unlink(fn) 425 except IOError: 426 pass 427 else: 428 return s 429 430 def _remote(self, action): 431 s = self._find_grail_rc() 432 if not s: 433 return 0 434 s.send(action) 435 s.close() 436 return 1 437 438 def open(self, url, new=0, autoraise=True): 439 if new: 440 ok = self._remote("LOADNEW " + url) 441 else: 442 ok = self._remote("LOAD " + url) 443 return ok 444 445 446# 447# Platform support for Unix 448# 449 450# These are the right tests because all these Unix browsers require either 451# a console terminal or an X display to run. 452 453def register_X_browsers(): 454 455 # use xdg-open if around 456 if _iscommand("xdg-open"): 457 register("xdg-open", None, BackgroundBrowser("xdg-open")) 458 459 # The default GNOME3 browser 460 if "GNOME_DESKTOP_SESSION_ID" in os.environ and _iscommand("gvfs-open"): 461 register("gvfs-open", None, BackgroundBrowser("gvfs-open")) 462 463 # The default GNOME browser 464 if "GNOME_DESKTOP_SESSION_ID" in os.environ and _iscommand("gnome-open"): 465 register("gnome-open", None, BackgroundBrowser("gnome-open")) 466 467 # The default KDE browser 468 if "KDE_FULL_SESSION" in os.environ and _iscommand("kfmclient"): 469 register("kfmclient", Konqueror, Konqueror("kfmclient")) 470 471 if _iscommand("x-www-browser"): 472 register("x-www-browser", None, BackgroundBrowser("x-www-browser")) 473 474 # The Mozilla/Netscape browsers 475 for browser in ("mozilla-firefox", "firefox", 476 "mozilla-firebird", "firebird", 477 "iceweasel", "iceape", 478 "seamonkey", "mozilla", "netscape"): 479 if _iscommand(browser): 480 register(browser, None, Mozilla(browser)) 481 482 # Konqueror/kfm, the KDE browser. 483 if _iscommand("kfm"): 484 register("kfm", Konqueror, Konqueror("kfm")) 485 elif _iscommand("konqueror"): 486 register("konqueror", Konqueror, Konqueror("konqueror")) 487 488 # Gnome's Galeon and Epiphany 489 for browser in ("galeon", "epiphany"): 490 if _iscommand(browser): 491 register(browser, None, Galeon(browser)) 492 493 # Skipstone, another Gtk/Mozilla based browser 494 if _iscommand("skipstone"): 495 register("skipstone", None, BackgroundBrowser("skipstone")) 496 497 # Google Chrome/Chromium browsers 498 for browser in ("google-chrome", "chrome", "chromium", "chromium-browser"): 499 if _iscommand(browser): 500 register(browser, None, Chrome(browser)) 501 502 # Opera, quite popular 503 if _iscommand("opera"): 504 register("opera", None, Opera("opera")) 505 506 # Next, Mosaic -- old but still in use. 507 if _iscommand("mosaic"): 508 register("mosaic", None, BackgroundBrowser("mosaic")) 509 510 # Grail, the Python browser. Does anybody still use it? 511 if _iscommand("grail"): 512 register("grail", Grail, None) 513 514# Prefer X browsers if present 515if os.environ.get("DISPLAY"): 516 register_X_browsers() 517 518# Also try console browsers 519if os.environ.get("TERM"): 520 if _iscommand("www-browser"): 521 register("www-browser", None, GenericBrowser("www-browser")) 522 # The Links/elinks browsers <http://artax.karlin.mff.cuni.cz/~mikulas/links/> 523 if _iscommand("links"): 524 register("links", None, GenericBrowser("links")) 525 if _iscommand("elinks"): 526 register("elinks", None, Elinks("elinks")) 527 # The Lynx browser <http://lynx.isc.org/>, <http://lynx.browser.org/> 528 if _iscommand("lynx"): 529 register("lynx", None, GenericBrowser("lynx")) 530 # The w3m browser <http://w3m.sourceforge.net/> 531 if _iscommand("w3m"): 532 register("w3m", None, GenericBrowser("w3m")) 533 534# 535# Platform support for Windows 536# 537 538if sys.platform[:3] == "win": 539 class WindowsDefault(BaseBrowser): 540 def open(self, url, new=0, autoraise=True): 541 try: 542 os.startfile(url) 543 except WindowsError: 544 # [Error 22] No application is associated with the specified 545 # file for this operation: '<URL>' 546 return False 547 else: 548 return True 549 550 _tryorder = [] 551 _browsers = {} 552 553 # First try to use the default Windows browser 554 register("windows-default", WindowsDefault) 555 556 # Detect some common Windows browsers, fallback to IE 557 iexplore = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"), 558 "Internet Explorer\\IEXPLORE.EXE") 559 for browser in ("firefox", "firebird", "seamonkey", "mozilla", 560 "netscape", "opera", iexplore): 561 if _iscommand(browser): 562 register(browser, None, BackgroundBrowser(browser)) 563 564# 565# Platform support for MacOS 566# 567 568if sys.platform == 'darwin': 569 # Adapted from patch submitted to SourceForge by Steven J. Burr 570 class MacOSX(BaseBrowser): 571 """Launcher class for Aqua browsers on Mac OS X 572 573 Optionally specify a browser name on instantiation. Note that this 574 will not work for Aqua browsers if the user has moved the application 575 package after installation. 576 577 If no browser is specified, the default browser, as specified in the 578 Internet System Preferences panel, will be used. 579 """ 580 def __init__(self, name): 581 self.name = name 582 583 def open(self, url, new=0, autoraise=True): 584 assert "'" not in url 585 # hack for local urls 586 if not ':' in url: 587 url = 'file:'+url 588 589 # new must be 0 or 1 590 new = int(bool(new)) 591 if self.name == "default": 592 # User called open, open_new or get without a browser parameter 593 script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser 594 else: 595 # User called get and chose a browser 596 if self.name == "OmniWeb": 597 toWindow = "" 598 else: 599 # Include toWindow parameter of OpenURL command for browsers 600 # that support it. 0 == new window; -1 == existing 601 toWindow = "toWindow %d" % (new - 1) 602 cmd = 'OpenURL "%s"' % url.replace('"', '%22') 603 script = '''tell application "%s" 604 activate 605 %s %s 606 end tell''' % (self.name, cmd, toWindow) 607 # Open pipe to AppleScript through osascript command 608 osapipe = os.popen("osascript", "w") 609 if osapipe is None: 610 return False 611 # Write script to osascript's stdin 612 osapipe.write(script) 613 rc = osapipe.close() 614 return not rc 615 616 class MacOSXOSAScript(BaseBrowser): 617 def __init__(self, name): 618 self._name = name 619 620 def open(self, url, new=0, autoraise=True): 621 if self._name == 'default': 622 script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser 623 else: 624 script = ''' 625 tell application "%s" 626 activate 627 open location "%s" 628 end 629 '''%(self._name, url.replace('"', '%22')) 630 631 osapipe = os.popen("osascript", "w") 632 if osapipe is None: 633 return False 634 635 osapipe.write(script) 636 rc = osapipe.close() 637 return not rc 638 639 640 # Don't clear _tryorder or _browsers since OS X can use above Unix support 641 # (but we prefer using the OS X specific stuff) 642 register("safari", None, MacOSXOSAScript('safari'), -1) 643 register("firefox", None, MacOSXOSAScript('firefox'), -1) 644 register("chrome", None, MacOSXOSAScript('chrome'), -1) 645 register("MacOSX", None, MacOSXOSAScript('default'), -1) 646 647 648# 649# Platform support for OS/2 650# 651 652if sys.platform[:3] == "os2" and _iscommand("netscape"): 653 _tryorder = [] 654 _browsers = {} 655 register("os2netscape", None, 656 GenericBrowser(["start", "netscape", "%s"]), -1) 657 658 659# OK, now that we know what the default preference orders for each 660# platform are, allow user to override them with the BROWSER variable. 661if "BROWSER" in os.environ: 662 _userchoices = os.environ["BROWSER"].split(os.pathsep) 663 _userchoices.reverse() 664 665 # Treat choices in same way as if passed into get() but do register 666 # and prepend to _tryorder 667 for cmdline in _userchoices: 668 if cmdline != '': 669 cmd = _synthesize(cmdline, -1) 670 if cmd[1] is None: 671 register(cmdline, None, GenericBrowser(cmdline), -1) 672 cmdline = None # to make del work if _userchoices was empty 673 del cmdline 674 del _userchoices 675 676# what to do if _tryorder is now empty? 677 678 679def main(): 680 import getopt 681 usage = """Usage: %s [-n | -t] url 682 -n: open new window 683 -t: open new tab""" % sys.argv[0] 684 try: 685 opts, args = getopt.getopt(sys.argv[1:], 'ntd') 686 except getopt.error, msg: 687 print >>sys.stderr, msg 688 print >>sys.stderr, usage 689 sys.exit(1) 690 new_win = 0 691 for o, a in opts: 692 if o == '-n': new_win = 1 693 elif o == '-t': new_win = 2 694 if len(args) != 1: 695 print >>sys.stderr, usage 696 sys.exit(1) 697 698 url = args[0] 699 open(url, new_win) 700 701 print "\a" 702 703if __name__ == "__main__": 704 main() 705