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