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