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