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 14 15class Error(Exception): 16 pass 17 18 19_lock = threading.RLock() 20_browsers = {} # Dictionary of available browser controllers 21_tryorder = None # Preference order of available browsers 22_os_preferred_browser = None # The preferred browser 23 24 25def register(name, klass, instance=None, *, preferred=False): 26 """Register a browser connector.""" 27 with _lock: 28 if _tryorder is None: 29 register_standard_browsers() 30 _browsers[name.lower()] = [klass, instance] 31 32 # Preferred browsers go to the front of the list. 33 # Need to match to the default browser returned by xdg-settings, which 34 # may be of the form e.g. "firefox.desktop". 35 if preferred or (_os_preferred_browser and f'{name}.desktop' == _os_preferred_browser): 36 _tryorder.insert(0, name) 37 else: 38 _tryorder.append(name) 39 40 41def get(using=None): 42 """Return a browser launcher instance appropriate for the environment.""" 43 if _tryorder is None: 44 with _lock: 45 if _tryorder is None: 46 register_standard_browsers() 47 if using is not None: 48 alternatives = [using] 49 else: 50 alternatives = _tryorder 51 for browser in alternatives: 52 if '%s' in browser: 53 # User gave us a command line, split it into name and args 54 browser = shlex.split(browser) 55 if browser[-1] == '&': 56 return BackgroundBrowser(browser[:-1]) 57 else: 58 return GenericBrowser(browser) 59 else: 60 # User gave us a browser name or path. 61 try: 62 command = _browsers[browser.lower()] 63 except KeyError: 64 command = _synthesize(browser) 65 if command[1] is not None: 66 return command[1] 67 elif command[0] is not None: 68 return command[0]() 69 raise Error("could not locate runnable browser") 70 71 72# Please note: the following definition hides a builtin function. 73# It is recommended one does "import webbrowser" and uses webbrowser.open(url) 74# instead of "from webbrowser import *". 75 76def open(url, new=0, autoraise=True): 77 """Display url using the default browser. 78 79 If possible, open url in a location determined by new. 80 - 0: the same browser window (the default). 81 - 1: a new browser window. 82 - 2: a new browser page ("tab"). 83 If possible, autoraise raises the window (the default) or not. 84 85 If opening the browser succeeds, return True. 86 If there is a problem, return False. 87 """ 88 if _tryorder is None: 89 with _lock: 90 if _tryorder is None: 91 register_standard_browsers() 92 for name in _tryorder: 93 browser = get(name) 94 if browser.open(url, new, autoraise): 95 return True 96 return False 97 98 99def open_new(url): 100 """Open url in a new window of the default browser. 101 102 If not possible, then open url in the only browser window. 103 """ 104 return open(url, 1) 105 106 107def open_new_tab(url): 108 """Open url in a new page ("tab") of the default browser. 109 110 If not possible, then the behavior becomes equivalent to open_new(). 111 """ 112 return open(url, 2) 113 114 115def _synthesize(browser, *, preferred=False): 116 """Attempt to synthesize a controller based on existing controllers. 117 118 This is useful to create a controller when a user specifies a path to 119 an entry in the BROWSER environment variable -- we can copy a general 120 controller to operate using a specific installation of the desired 121 browser in this way. 122 123 If we can't create a controller in this way, or if there is no 124 executable for the requested browser, return [None, None]. 125 126 """ 127 cmd = browser.split()[0] 128 if not shutil.which(cmd): 129 return [None, None] 130 name = os.path.basename(cmd) 131 try: 132 command = _browsers[name.lower()] 133 except KeyError: 134 return [None, None] 135 # now attempt to clone to fit the new name: 136 controller = command[1] 137 if controller and name.lower() == controller.basename: 138 import copy 139 controller = copy.copy(controller) 140 controller.name = browser 141 controller.basename = os.path.basename(browser) 142 register(browser, None, instance=controller, preferred=preferred) 143 return [None, controller] 144 return [None, None] 145 146 147# General parent classes 148 149class BaseBrowser: 150 """Parent class for all browsers. Do not use directly.""" 151 152 args = ['%s'] 153 154 def __init__(self, name=""): 155 self.name = name 156 self.basename = name 157 158 def open(self, url, new=0, autoraise=True): 159 raise NotImplementedError 160 161 def open_new(self, url): 162 return self.open(url, 1) 163 164 def open_new_tab(self, url): 165 return self.open(url, 2) 166 167 168class GenericBrowser(BaseBrowser): 169 """Class for all browsers started with a command 170 and without remote functionality.""" 171 172 def __init__(self, name): 173 if isinstance(name, str): 174 self.name = name 175 self.args = ["%s"] 176 else: 177 # name should be a list with arguments 178 self.name = name[0] 179 self.args = name[1:] 180 self.basename = os.path.basename(self.name) 181 182 def open(self, url, new=0, autoraise=True): 183 sys.audit("webbrowser.open", url) 184 cmdline = [self.name] + [arg.replace("%s", url) 185 for arg in self.args] 186 try: 187 if sys.platform[:3] == 'win': 188 p = subprocess.Popen(cmdline) 189 else: 190 p = subprocess.Popen(cmdline, close_fds=True) 191 return not p.wait() 192 except OSError: 193 return False 194 195 196class BackgroundBrowser(GenericBrowser): 197 """Class for all browsers which are to be started in the 198 background.""" 199 200 def open(self, url, new=0, autoraise=True): 201 cmdline = [self.name] + [arg.replace("%s", url) 202 for arg in self.args] 203 sys.audit("webbrowser.open", url) 204 try: 205 if sys.platform[:3] == 'win': 206 p = subprocess.Popen(cmdline) 207 else: 208 p = subprocess.Popen(cmdline, close_fds=True, 209 start_new_session=True) 210 return p.poll() is None 211 except OSError: 212 return False 213 214 215class UnixBrowser(BaseBrowser): 216 """Parent class for all Unix browsers with remote functionality.""" 217 218 raise_opts = None 219 background = False 220 redirect_stdout = True 221 # In remote_args, %s will be replaced with the requested URL. %action will 222 # be replaced depending on the value of 'new' passed to open. 223 # remote_action is used for new=0 (open). If newwin is not None, it is 224 # used for new=1 (open_new). If newtab is not None, it is used for 225 # new=3 (open_new_tab). After both substitutions are made, any empty 226 # strings in the transformed remote_args list will be removed. 227 remote_args = ['%action', '%s'] 228 remote_action = None 229 remote_action_newwin = None 230 remote_action_newtab = None 231 232 def _invoke(self, args, remote, autoraise, url=None): 233 raise_opt = [] 234 if remote and self.raise_opts: 235 # use autoraise argument only for remote invocation 236 autoraise = int(autoraise) 237 opt = self.raise_opts[autoraise] 238 if opt: 239 raise_opt = [opt] 240 241 cmdline = [self.name] + raise_opt + args 242 243 if remote or self.background: 244 inout = subprocess.DEVNULL 245 else: 246 # for TTY browsers, we need stdin/out 247 inout = None 248 p = subprocess.Popen(cmdline, close_fds=True, stdin=inout, 249 stdout=(self.redirect_stdout and inout or None), 250 stderr=inout, start_new_session=True) 251 if remote: 252 # wait at most five seconds. If the subprocess is not finished, the 253 # remote invocation has (hopefully) started a new instance. 254 try: 255 rc = p.wait(5) 256 # if remote call failed, open() will try direct invocation 257 return not rc 258 except subprocess.TimeoutExpired: 259 return True 260 elif self.background: 261 if p.poll() is None: 262 return True 263 else: 264 return False 265 else: 266 return not p.wait() 267 268 def open(self, url, new=0, autoraise=True): 269 sys.audit("webbrowser.open", url) 270 if new == 0: 271 action = self.remote_action 272 elif new == 1: 273 action = self.remote_action_newwin 274 elif new == 2: 275 if self.remote_action_newtab is None: 276 action = self.remote_action_newwin 277 else: 278 action = self.remote_action_newtab 279 else: 280 raise Error("Bad 'new' parameter to open(); " 281 f"expected 0, 1, or 2, got {new}") 282 283 args = [arg.replace("%s", url).replace("%action", action) 284 for arg in self.remote_args] 285 args = [arg for arg in args if arg] 286 success = self._invoke(args, True, autoraise, url) 287 if not success: 288 # remote invocation failed, try straight way 289 args = [arg.replace("%s", url) for arg in self.args] 290 return self._invoke(args, False, False) 291 else: 292 return True 293 294 295class Mozilla(UnixBrowser): 296 """Launcher class for Mozilla browsers.""" 297 298 remote_args = ['%action', '%s'] 299 remote_action = "" 300 remote_action_newwin = "-new-window" 301 remote_action_newtab = "-new-tab" 302 background = True 303 304 305class Epiphany(UnixBrowser): 306 """Launcher class for Epiphany browser.""" 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 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 Edge(UnixBrowser): 406 """Launcher class for Microsoft Edge browser.""" 407 408 remote_args = ['%action', '%s'] 409 remote_action = "" 410 remote_action_newwin = "--new-window" 411 remote_action_newtab = "" 412 background = True 413 414 415# 416# Platform support for Unix 417# 418 419# These are the right tests because all these Unix browsers require either 420# a console terminal or an X display to run. 421 422def register_X_browsers(): 423 424 # use xdg-open if around 425 if shutil.which("xdg-open"): 426 register("xdg-open", None, BackgroundBrowser("xdg-open")) 427 428 # Opens an appropriate browser for the URL scheme according to 429 # freedesktop.org settings (GNOME, KDE, XFCE, etc.) 430 if shutil.which("gio"): 431 register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"])) 432 433 xdg_desktop = os.getenv("XDG_CURRENT_DESKTOP", "").split(":") 434 435 # The default GNOME3 browser 436 if (("GNOME" in xdg_desktop or 437 "GNOME_DESKTOP_SESSION_ID" in os.environ) and 438 shutil.which("gvfs-open")): 439 register("gvfs-open", None, BackgroundBrowser("gvfs-open")) 440 441 # The default KDE browser 442 if (("KDE" in xdg_desktop or 443 "KDE_FULL_SESSION" in os.environ) and 444 shutil.which("kfmclient")): 445 register("kfmclient", Konqueror, Konqueror("kfmclient")) 446 447 # Common symbolic link for the default X11 browser 448 if shutil.which("x-www-browser"): 449 register("x-www-browser", None, BackgroundBrowser("x-www-browser")) 450 451 # The Mozilla browsers 452 for browser in ("firefox", "iceweasel", "seamonkey", "mozilla-firefox", 453 "mozilla"): 454 if shutil.which(browser): 455 register(browser, None, Mozilla(browser)) 456 457 # Konqueror/kfm, the KDE browser. 458 if shutil.which("kfm"): 459 register("kfm", Konqueror, Konqueror("kfm")) 460 elif shutil.which("konqueror"): 461 register("konqueror", Konqueror, Konqueror("konqueror")) 462 463 # Gnome's Epiphany 464 if shutil.which("epiphany"): 465 register("epiphany", None, Epiphany("epiphany")) 466 467 # Google Chrome/Chromium browsers 468 for browser in ("google-chrome", "chrome", "chromium", "chromium-browser"): 469 if shutil.which(browser): 470 register(browser, None, Chrome(browser)) 471 472 # Opera, quite popular 473 if shutil.which("opera"): 474 register("opera", None, Opera("opera")) 475 476 if shutil.which("microsoft-edge"): 477 register("microsoft-edge", None, Edge("microsoft-edge")) 478 479 480def register_standard_browsers(): 481 global _tryorder 482 _tryorder = [] 483 484 if sys.platform == 'darwin': 485 register("MacOSX", None, MacOSXOSAScript('default')) 486 register("chrome", None, MacOSXOSAScript('chrome')) 487 register("firefox", None, MacOSXOSAScript('firefox')) 488 register("safari", None, MacOSXOSAScript('safari')) 489 # OS X can use below Unix support (but we prefer using the OS X 490 # specific stuff) 491 492 if sys.platform == "ios": 493 register("iosbrowser", None, IOSBrowser(), preferred=True) 494 495 if sys.platform == "serenityos": 496 # SerenityOS webbrowser, simply called "Browser". 497 register("Browser", None, BackgroundBrowser("Browser")) 498 499 if sys.platform[:3] == "win": 500 # First try to use the default Windows browser 501 register("windows-default", WindowsDefault) 502 503 # Detect some common Windows browsers, fallback to Microsoft Edge 504 # location in 64-bit Windows 505 edge64 = os.path.join(os.environ.get("PROGRAMFILES(x86)", "C:\\Program Files (x86)"), 506 "Microsoft\\Edge\\Application\\msedge.exe") 507 # location in 32-bit Windows 508 edge32 = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"), 509 "Microsoft\\Edge\\Application\\msedge.exe") 510 for browser in ("firefox", "seamonkey", "mozilla", "chrome", 511 "opera", edge64, edge32): 512 if shutil.which(browser): 513 register(browser, None, BackgroundBrowser(browser)) 514 if shutil.which("MicrosoftEdge.exe"): 515 register("microsoft-edge", None, Edge("MicrosoftEdge.exe")) 516 else: 517 # Prefer X browsers if present 518 # 519 # NOTE: Do not check for X11 browser on macOS, 520 # XQuartz installation sets a DISPLAY environment variable and will 521 # autostart when someone tries to access the display. Mac users in 522 # general don't need an X11 browser. 523 if sys.platform != "darwin" and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): 524 try: 525 cmd = "xdg-settings get default-web-browser".split() 526 raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 527 result = raw_result.decode().strip() 528 except (FileNotFoundError, subprocess.CalledProcessError, 529 PermissionError, NotADirectoryError): 530 pass 531 else: 532 global _os_preferred_browser 533 _os_preferred_browser = result 534 535 register_X_browsers() 536 537 # Also try console browsers 538 if os.environ.get("TERM"): 539 # Common symbolic link for the default text-based browser 540 if shutil.which("www-browser"): 541 register("www-browser", None, GenericBrowser("www-browser")) 542 # The Links/elinks browsers <http://links.twibright.com/> 543 if shutil.which("links"): 544 register("links", None, GenericBrowser("links")) 545 if shutil.which("elinks"): 546 register("elinks", None, Elinks("elinks")) 547 # The Lynx browser <https://lynx.invisible-island.net/>, <http://lynx.browser.org/> 548 if shutil.which("lynx"): 549 register("lynx", None, GenericBrowser("lynx")) 550 # The w3m browser <http://w3m.sourceforge.net/> 551 if shutil.which("w3m"): 552 register("w3m", None, GenericBrowser("w3m")) 553 554 # OK, now that we know what the default preference orders for each 555 # platform are, allow user to override them with the BROWSER variable. 556 if "BROWSER" in os.environ: 557 userchoices = os.environ["BROWSER"].split(os.pathsep) 558 userchoices.reverse() 559 560 # Treat choices in same way as if passed into get() but do register 561 # and prepend to _tryorder 562 for cmdline in userchoices: 563 if cmdline != '': 564 cmd = _synthesize(cmdline, preferred=True) 565 if cmd[1] is None: 566 register(cmdline, None, GenericBrowser(cmdline), preferred=True) 567 568 # what to do if _tryorder is now empty? 569 570 571# 572# Platform support for Windows 573# 574 575if sys.platform[:3] == "win": 576 class WindowsDefault(BaseBrowser): 577 def open(self, url, new=0, autoraise=True): 578 sys.audit("webbrowser.open", url) 579 try: 580 os.startfile(url) 581 except OSError: 582 # [Error 22] No application is associated with the specified 583 # file for this operation: '<URL>' 584 return False 585 else: 586 return True 587 588# 589# Platform support for macOS 590# 591 592if sys.platform == 'darwin': 593 class MacOSXOSAScript(BaseBrowser): 594 def __init__(self, name='default'): 595 super().__init__(name) 596 597 def open(self, url, new=0, autoraise=True): 598 sys.audit("webbrowser.open", url) 599 url = url.replace('"', '%22') 600 if self.name == 'default': 601 script = f'open location "{url}"' # opens in default browser 602 else: 603 script = f''' 604 tell application "{self.name}" 605 activate 606 open location "{url}" 607 end 608 ''' 609 610 osapipe = os.popen("osascript", "w") 611 if osapipe is None: 612 return False 613 614 osapipe.write(script) 615 rc = osapipe.close() 616 return not rc 617 618# 619# Platform support for iOS 620# 621if sys.platform == "ios": 622 from _ios_support import objc 623 if objc: 624 # If objc exists, we know ctypes is also importable. 625 from ctypes import c_void_p, c_char_p, c_ulong 626 627 class IOSBrowser(BaseBrowser): 628 def open(self, url, new=0, autoraise=True): 629 sys.audit("webbrowser.open", url) 630 # If ctypes isn't available, we can't open a browser 631 if objc is None: 632 return False 633 634 # All the messages in this call return object references. 635 objc.objc_msgSend.restype = c_void_p 636 637 # This is the equivalent of: 638 # NSString url_string = 639 # [NSString stringWithCString:url.encode("utf-8") 640 # encoding:NSUTF8StringEncoding]; 641 NSString = objc.objc_getClass(b"NSString") 642 constructor = objc.sel_registerName(b"stringWithCString:encoding:") 643 objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_char_p, c_ulong] 644 url_string = objc.objc_msgSend( 645 NSString, 646 constructor, 647 url.encode("utf-8"), 648 4, # NSUTF8StringEncoding = 4 649 ) 650 651 # Create an NSURL object representing the URL 652 # This is the equivalent of: 653 # NSURL *nsurl = [NSURL URLWithString:url]; 654 NSURL = objc.objc_getClass(b"NSURL") 655 urlWithString_ = objc.sel_registerName(b"URLWithString:") 656 objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p] 657 ns_url = objc.objc_msgSend(NSURL, urlWithString_, url_string) 658 659 # Get the shared UIApplication instance 660 # This code is the equivalent of: 661 # UIApplication shared_app = [UIApplication sharedApplication] 662 UIApplication = objc.objc_getClass(b"UIApplication") 663 sharedApplication = objc.sel_registerName(b"sharedApplication") 664 objc.objc_msgSend.argtypes = [c_void_p, c_void_p] 665 shared_app = objc.objc_msgSend(UIApplication, sharedApplication) 666 667 # Open the URL on the shared application 668 # This code is the equivalent of: 669 # [shared_app openURL:ns_url 670 # options:NIL 671 # completionHandler:NIL]; 672 openURL_ = objc.sel_registerName(b"openURL:options:completionHandler:") 673 objc.objc_msgSend.argtypes = [ 674 c_void_p, c_void_p, c_void_p, c_void_p, c_void_p 675 ] 676 # Method returns void 677 objc.objc_msgSend.restype = None 678 objc.objc_msgSend(shared_app, openURL_, ns_url, None, None) 679 680 return True 681 682 683def parse_args(arg_list: list[str] | None): 684 import argparse 685 parser = argparse.ArgumentParser(description="Open URL in a web browser.") 686 parser.add_argument("url", help="URL to open") 687 688 group = parser.add_mutually_exclusive_group() 689 group.add_argument("-n", "--new-window", action="store_const", 690 const=1, default=0, dest="new_win", 691 help="open new window") 692 group.add_argument("-t", "--new-tab", action="store_const", 693 const=2, default=0, dest="new_win", 694 help="open new tab") 695 696 args = parser.parse_args(arg_list) 697 698 return args 699 700 701def main(arg_list: list[str] | None = None): 702 args = parse_args(arg_list) 703 704 open(args.url, args.new_win) 705 706 print("\a") 707 708 709if __name__ == "__main__": 710 main() 711