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 == "serenityos": 536 # SerenityOS webbrowser, simply called "Browser". 537 register("Browser", None, BackgroundBrowser("Browser")) 538 539 if sys.platform[:3] == "win": 540 # First try to use the default Windows browser 541 register("windows-default", WindowsDefault) 542 543 # Detect some common Windows browsers, fallback to IE 544 iexplore = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"), 545 "Internet Explorer\\IEXPLORE.EXE") 546 for browser in ("firefox", "firebird", "seamonkey", "mozilla", 547 "netscape", "opera", iexplore): 548 if shutil.which(browser): 549 register(browser, None, BackgroundBrowser(browser)) 550 else: 551 # Prefer X browsers if present 552 if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): 553 try: 554 cmd = "xdg-settings get default-web-browser".split() 555 raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 556 result = raw_result.decode().strip() 557 except (FileNotFoundError, subprocess.CalledProcessError, PermissionError, NotADirectoryError) : 558 pass 559 else: 560 global _os_preferred_browser 561 _os_preferred_browser = result 562 563 register_X_browsers() 564 565 # Also try console browsers 566 if os.environ.get("TERM"): 567 if shutil.which("www-browser"): 568 register("www-browser", None, GenericBrowser("www-browser")) 569 # The Links/elinks browsers <http://artax.karlin.mff.cuni.cz/~mikulas/links/> 570 if shutil.which("links"): 571 register("links", None, GenericBrowser("links")) 572 if shutil.which("elinks"): 573 register("elinks", None, Elinks("elinks")) 574 # The Lynx browser <http://lynx.isc.org/>, <http://lynx.browser.org/> 575 if shutil.which("lynx"): 576 register("lynx", None, GenericBrowser("lynx")) 577 # The w3m browser <http://w3m.sourceforge.net/> 578 if shutil.which("w3m"): 579 register("w3m", None, GenericBrowser("w3m")) 580 581 # OK, now that we know what the default preference orders for each 582 # platform are, allow user to override them with the BROWSER variable. 583 if "BROWSER" in os.environ: 584 userchoices = os.environ["BROWSER"].split(os.pathsep) 585 userchoices.reverse() 586 587 # Treat choices in same way as if passed into get() but do register 588 # and prepend to _tryorder 589 for cmdline in userchoices: 590 if cmdline != '': 591 cmd = _synthesize(cmdline, preferred=True) 592 if cmd[1] is None: 593 register(cmdline, None, GenericBrowser(cmdline), preferred=True) 594 595 # what to do if _tryorder is now empty? 596 597 598# 599# Platform support for Windows 600# 601 602if sys.platform[:3] == "win": 603 class WindowsDefault(BaseBrowser): 604 def open(self, url, new=0, autoraise=True): 605 sys.audit("webbrowser.open", url) 606 try: 607 os.startfile(url) 608 except OSError: 609 # [Error 22] No application is associated with the specified 610 # file for this operation: '<URL>' 611 return False 612 else: 613 return True 614 615# 616# Platform support for MacOS 617# 618 619if sys.platform == 'darwin': 620 # Adapted from patch submitted to SourceForge by Steven J. Burr 621 class MacOSX(BaseBrowser): 622 """Launcher class for Aqua browsers on Mac OS X 623 624 Optionally specify a browser name on instantiation. Note that this 625 will not work for Aqua browsers if the user has moved the application 626 package after installation. 627 628 If no browser is specified, the default browser, as specified in the 629 Internet System Preferences panel, will be used. 630 """ 631 def __init__(self, name): 632 self.name = name 633 634 def open(self, url, new=0, autoraise=True): 635 sys.audit("webbrowser.open", url) 636 assert "'" not in url 637 # hack for local urls 638 if not ':' in url: 639 url = 'file:'+url 640 641 # new must be 0 or 1 642 new = int(bool(new)) 643 if self.name == "default": 644 # User called open, open_new or get without a browser parameter 645 script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser 646 else: 647 # User called get and chose a browser 648 if self.name == "OmniWeb": 649 toWindow = "" 650 else: 651 # Include toWindow parameter of OpenURL command for browsers 652 # that support it. 0 == new window; -1 == existing 653 toWindow = "toWindow %d" % (new - 1) 654 cmd = 'OpenURL "%s"' % url.replace('"', '%22') 655 script = '''tell application "%s" 656 activate 657 %s %s 658 end tell''' % (self.name, cmd, toWindow) 659 # Open pipe to AppleScript through osascript command 660 osapipe = os.popen("osascript", "w") 661 if osapipe is None: 662 return False 663 # Write script to osascript's stdin 664 osapipe.write(script) 665 rc = osapipe.close() 666 return not rc 667 668 class MacOSXOSAScript(BaseBrowser): 669 def __init__(self, name): 670 self._name = name 671 672 def open(self, url, new=0, autoraise=True): 673 if self._name == 'default': 674 script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser 675 else: 676 script = ''' 677 tell application "%s" 678 activate 679 open location "%s" 680 end 681 '''%(self._name, url.replace('"', '%22')) 682 683 osapipe = os.popen("osascript", "w") 684 if osapipe is None: 685 return False 686 687 osapipe.write(script) 688 rc = osapipe.close() 689 return not rc 690 691 692def main(): 693 import getopt 694 usage = """Usage: %s [-n | -t] url 695 -n: open new window 696 -t: open new tab""" % sys.argv[0] 697 try: 698 opts, args = getopt.getopt(sys.argv[1:], 'ntd') 699 except getopt.error as msg: 700 print(msg, file=sys.stderr) 701 print(usage, file=sys.stderr) 702 sys.exit(1) 703 new_win = 0 704 for o, a in opts: 705 if o == '-n': new_win = 1 706 elif o == '-t': new_win = 2 707 if len(args) != 1: 708 print(usage, file=sys.stderr) 709 sys.exit(1) 710 711 url = args[0] 712 open(url, new_win) 713 714 print("\a") 715 716if __name__ == "__main__": 717 main() 718