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