codekingpro/portable-devtools
114k
1"""Interfaces for launching and remotely controlling web browsers."""2# Maintained by Georg Brandl.3 4import os5import shlex6import shutil7import sys8import subprocess9import threading10 11__all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"]12 13 14class Error(Exception):15 pass16 17 18_lock = threading.RLock()19_browsers = {} # Dictionary of available browser controllers20_tryorder = None # Preference order of available browsers21_os_preferred_browser = None # The preferred browser22 23 24def register(name, klass, instance=None, *, preferred=False):25 """Register a browser connector."""26 with _lock:27 if _tryorder is None:28 register_standard_browsers()29 _browsers[name.lower()] = [klass, instance]30 31 # Preferred browsers go to the front of the list.32 # Need to match to the default browser returned by xdg-settings, which33 # may be of the form e.g. "firefox.desktop".34 if preferred or (_os_preferred_browser and f'{name}.desktop' == _os_preferred_browser):35 _tryorder.insert(0, name)36 else:37 _tryorder.append(name)38 39 40def get(using=None):41 """Return a browser launcher instance appropriate for the environment."""42 if _tryorder is None:43 with _lock:44 if _tryorder is None:45 register_standard_browsers()46 if using is not None:47 alternatives = [using]48 else:49 alternatives = _tryorder50 for browser in alternatives:51 if '%s' in browser:52 # User gave us a command line, split it into name and args53 browser = shlex.split(browser)54 if browser[-1] == '&':55 return BackgroundBrowser(browser[:-1])56 else:57 return GenericBrowser(browser)58 else:59 # User gave us a browser name or path.60 try:61 command = _browsers[browser.lower()]62 except KeyError:63 command = _synthesize(browser)64 if command[1] is not None:65 return command[1]66 elif command[0] is not None:67 return command[0]()68 raise Error("could not locate runnable browser")69 70 71# Please note: the following definition hides a builtin function.72# It is recommended one does "import webbrowser" and uses webbrowser.open(url)73# instead of "from webbrowser import *".74 75def open(url, new=0, autoraise=True):76 """Display url using the default browser.77 78 If possible, open url in a location determined by new.79 - 0: the same browser window (the default).80 - 1: a new browser window.81 - 2: a new browser page ("tab").82 If possible, autoraise raises the window (the default) or not.83 84 If opening the browser succeeds, return True.85 If there is a problem, return False.86 """87 if _tryorder is None:88 with _lock:89 if _tryorder is None:90 register_standard_browsers()91 for name in _tryorder:92 browser = get(name)93 if browser.open(url, new, autoraise):94 return True95 return False96 97 98def open_new(url):99 """Open url in a new window of the default browser.100 101 If not possible, then open url in the only browser window.102 """103 return open(url, 1)104 105 106def open_new_tab(url):107 """Open url in a new page ("tab") of the default browser.108 109 If not possible, then the behavior becomes equivalent to open_new().110 """111 return open(url, 2)112 113 114def _synthesize(browser, *, preferred=False):115 """Attempt to synthesize a controller based on existing controllers.116 117 This is useful to create a controller when a user specifies a path to118 an entry in the BROWSER environment variable -- we can copy a general119 controller to operate using a specific installation of the desired120 browser in this way.121 122 If we can't create a controller in this way, or if there is no123 executable for the requested browser, return [None, None].124 125 """126 cmd = browser.split()[0]127 if not shutil.which(cmd):128 return [None, None]129 name = os.path.basename(cmd)130 try:131 command = _browsers[name.lower()]132 except KeyError:133 return [None, None]134 # now attempt to clone to fit the new name:135 controller = command[1]136 if controller and name.lower() == controller.basename:137 import copy138 controller = copy.copy(controller)139 controller.name = browser140 controller.basename = os.path.basename(browser)141 register(browser, None, instance=controller, preferred=preferred)142 return [None, controller]143 return [None, None]144 145 146# General parent classes147 148class BaseBrowser:149 """Parent class for all browsers. Do not use directly."""150 151 args = ['%s']152 153 def __init__(self, name=""):154 self.name = name155 self.basename = name156 157 def open(self, url, new=0, autoraise=True):158 raise NotImplementedError159 160 def open_new(self, url):161 return self.open(url, 1)162 163 def open_new_tab(self, url):164 return self.open(url, 2)165 166 @staticmethod167 def _check_url(url):168 """Ensures that the URL is safe to pass to subprocesses as a parameter"""169 if url and url.lstrip().startswith("-"):170 raise ValueError(f"Invalid URL (leading dash disallowed): {url!r}")171 172 173class GenericBrowser(BaseBrowser):174 """Class for all browsers started with a command175 and without remote functionality."""176 177 def __init__(self, name):178 if isinstance(name, str):179 self.name = name180 self.args = ["%s"]181 else:182 # name should be a list with arguments183 self.name = name[0]184 self.args = name[1:]185 self.basename = os.path.basename(self.name)186 187 def open(self, url, new=0, autoraise=True):188 sys.audit("webbrowser.open", url)189 self._check_url(url)190 cmdline = [self.name] + [arg.replace("%s", url)191 for arg in self.args]192 try:193 if sys.platform[:3] == 'win':194 p = subprocess.Popen(cmdline)195 else:196 p = subprocess.Popen(cmdline, close_fds=True)197 return not p.wait()198 except OSError:199 return False200 201 202class BackgroundBrowser(GenericBrowser):203 """Class for all browsers which are to be started in the204 background."""205 206 def open(self, url, new=0, autoraise=True):207 cmdline = [self.name] + [arg.replace("%s", url)208 for arg in self.args]209 sys.audit("webbrowser.open", url)210 self._check_url(url)211 try:212 if sys.platform[:3] == 'win':213 p = subprocess.Popen(cmdline)214 else:215 p = subprocess.Popen(cmdline, close_fds=True,216 start_new_session=True)217 return p.poll() is None218 except OSError:219 return False220 221 222class UnixBrowser(BaseBrowser):223 """Parent class for all Unix browsers with remote functionality."""224 225 raise_opts = None226 background = False227 redirect_stdout = True228 # In remote_args, %s will be replaced with the requested URL. %action will229 # be replaced depending on the value of 'new' passed to open.230 # remote_action is used for new=0 (open). If newwin is not None, it is231 # used for new=1 (open_new). If newtab is not None, it is used for232 # new=3 (open_new_tab). After both substitutions are made, any empty233 # strings in the transformed remote_args list will be removed.234 remote_args = ['%action', '%s']235 remote_action = None236 remote_action_newwin = None237 remote_action_newtab = None238 239 def _invoke(self, args, remote, autoraise, url=None):240 raise_opt = []241 if remote and self.raise_opts:242 # use autoraise argument only for remote invocation243 autoraise = int(autoraise)244 opt = self.raise_opts[autoraise]245 if opt:246 raise_opt = [opt]247 248 cmdline = [self.name] + raise_opt + args249 250 if remote or self.background:251 inout = subprocess.DEVNULL252 else:253 # for TTY browsers, we need stdin/out254 inout = None255 p = subprocess.Popen(cmdline, close_fds=True, stdin=inout,256 stdout=(self.redirect_stdout and inout or None),257 stderr=inout, start_new_session=True)258 if remote:259 # wait at most five seconds. If the subprocess is not finished, the260 # remote invocation has (hopefully) started a new instance.261 try:262 rc = p.wait(5)263 # if remote call failed, open() will try direct invocation264 return not rc265 except subprocess.TimeoutExpired:266 return True267 elif self.background:268 if p.poll() is None:269 return True270 else:271 return False272 else:273 return not p.wait()274 275 def open(self, url, new=0, autoraise=True):276 sys.audit("webbrowser.open", url)277 self._check_url(url)278 if new == 0:279 action = self.remote_action280 elif new == 1:281 action = self.remote_action_newwin282 elif new == 2:283 if self.remote_action_newtab is None:284 action = self.remote_action_newwin285 else:286 action = self.remote_action_newtab287 else:288 raise Error("Bad 'new' parameter to open(); "289 f"expected 0, 1, or 2, got {new}")290 291 args = [arg.replace("%s", url).replace("%action", action)292 for arg in self.remote_args]293 args = [arg for arg in args if arg]294 success = self._invoke(args, True, autoraise, url)295 if not success:296 # remote invocation failed, try straight way297 args = [arg.replace("%s", url) for arg in self.args]298 return self._invoke(args, False, False)299 else:300 return True301 302 303class Mozilla(UnixBrowser):304 """Launcher class for Mozilla browsers."""305 306 remote_args = ['%action', '%s']307 remote_action = ""308 remote_action_newwin = "-new-window"309 remote_action_newtab = "-new-tab"310 background = True311 312 313class Epiphany(UnixBrowser):314 """Launcher class for Epiphany browser."""315 316 raise_opts = ["-noraise", ""]317 remote_args = ['%action', '%s']318 remote_action = "-n"319 remote_action_newwin = "-w"320 background = True321 322 323class Chrome(UnixBrowser):324 """Launcher class for Google Chrome browser."""325 326 remote_args = ['%action', '%s']327 remote_action = ""328 remote_action_newwin = "--new-window"329 remote_action_newtab = ""330 background = True331 332 333Chromium = Chrome334 335 336class Opera(UnixBrowser):337 """Launcher class for Opera browser."""338 339 remote_args = ['%action', '%s']340 remote_action = ""341 remote_action_newwin = "--new-window"342 remote_action_newtab = ""343 background = True344 345 346class Elinks(UnixBrowser):347 """Launcher class for Elinks browsers."""348 349 remote_args = ['-remote', 'openURL(%s%action)']350 remote_action = ""351 remote_action_newwin = ",new-window"352 remote_action_newtab = ",new-tab"353 background = False354 355 # elinks doesn't like its stdout to be redirected -356 # it uses redirected stdout as a signal to do -dump357 redirect_stdout = False358 359 360class Konqueror(BaseBrowser):361 """Controller for the KDE File Manager (kfm, or Konqueror).362 363 See the output of ``kfmclient --commands``364 for more information on the Konqueror remote-control interface.365 """366 367 def open(self, url, new=0, autoraise=True):368 sys.audit("webbrowser.open", url)369 self._check_url(url)370 # XXX Currently I know no way to prevent KFM from opening a new win.371 if new == 2:372 action = "newTab"373 else:374 action = "openURL"375 376 devnull = subprocess.DEVNULL377 378 try:379 p = subprocess.Popen(["kfmclient", action, url],380 close_fds=True, stdin=devnull,381 stdout=devnull, stderr=devnull)382 except OSError:383 # fall through to next variant384 pass385 else:386 p.wait()387 # kfmclient's return code unfortunately has no meaning as it seems388 return True389 390 try:391 p = subprocess.Popen(["konqueror", "--silent", url],392 close_fds=True, stdin=devnull,393 stdout=devnull, stderr=devnull,394 start_new_session=True)395 except OSError:396 # fall through to next variant397 pass398 else:399 if p.poll() is None:400 # Should be running now.401 return True402 403 try:404 p = subprocess.Popen(["kfm", "-d", url],405 close_fds=True, stdin=devnull,406 stdout=devnull, stderr=devnull,407 start_new_session=True)408 except OSError:409 return False410 else:411 return p.poll() is None412 413 414class Edge(UnixBrowser):415 """Launcher class for Microsoft Edge browser."""416 417 remote_args = ['%action', '%s']418 remote_action = ""419 remote_action_newwin = "--new-window"420 remote_action_newtab = ""421 background = True422 423 424#425# Platform support for Unix426#427 428# These are the right tests because all these Unix browsers require either429# a console terminal or an X display to run.430 431def register_X_browsers():432 433 # use xdg-open if around434 if shutil.which("xdg-open"):435 register("xdg-open", None, BackgroundBrowser("xdg-open"))436 437 # Opens an appropriate browser for the URL scheme according to438 # freedesktop.org settings (GNOME, KDE, XFCE, etc.)439 if shutil.which("gio"):440 register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"]))441 442 xdg_desktop = os.getenv("XDG_CURRENT_DESKTOP", "").split(":")443 444 # The default GNOME3 browser445 if (("GNOME" in xdg_desktop or446 "GNOME_DESKTOP_SESSION_ID" in os.environ) and447 shutil.which("gvfs-open")):448 register("gvfs-open", None, BackgroundBrowser("gvfs-open"))449 450 # The default KDE browser451 if (("KDE" in xdg_desktop or452 "KDE_FULL_SESSION" in os.environ) and453 shutil.which("kfmclient")):454 register("kfmclient", Konqueror, Konqueror("kfmclient"))455 456 # Common symbolic link for the default X11 browser457 if shutil.which("x-www-browser"):458 register("x-www-browser", None, BackgroundBrowser("x-www-browser"))459 460 # The Mozilla browsers461 for browser in ("firefox", "iceweasel", "seamonkey", "mozilla-firefox",462 "mozilla"):463 if shutil.which(browser):464 register(browser, None, Mozilla(browser))465 466 # Konqueror/kfm, the KDE browser.467 if shutil.which("kfm"):468 register("kfm", Konqueror, Konqueror("kfm"))469 elif shutil.which("konqueror"):470 register("konqueror", Konqueror, Konqueror("konqueror"))471 472 # Gnome's Epiphany473 if shutil.which("epiphany"):474 register("epiphany", None, Epiphany("epiphany"))475 476 # Google Chrome/Chromium browsers477 for browser in ("google-chrome", "chrome", "chromium", "chromium-browser"):478 if shutil.which(browser):479 register(browser, None, Chrome(browser))480 481 # Opera, quite popular482 if shutil.which("opera"):483 register("opera", None, Opera("opera"))484 485 if shutil.which("microsoft-edge"):486 register("microsoft-edge", None, Edge("microsoft-edge"))487 488 489def register_standard_browsers():490 global _tryorder491 _tryorder = []492 493 if sys.platform == 'darwin':494 register("MacOSX", None, MacOSXOSAScript('default'))495 register("chrome", None, MacOSXOSAScript('google chrome'))496 register("firefox", None, MacOSXOSAScript('firefox'))497 register("safari", None, MacOSXOSAScript('safari'))498 # macOS can use below Unix support (but we prefer using the macOS499 # specific stuff)500 501 if sys.platform == "ios":502 register("iosbrowser", None, IOSBrowser(), preferred=True)503 504 if sys.platform == "serenityos":505 # SerenityOS webbrowser, simply called "Browser".506 register("Browser", None, BackgroundBrowser("Browser"))507 508 if sys.platform[:3] == "win":509 # First try to use the default Windows browser510 register("windows-default", WindowsDefault)511 512 # Detect some common Windows browsers, fallback to Microsoft Edge513 # location in 64-bit Windows514 edge64 = os.path.join(os.environ.get("PROGRAMFILES(x86)", "C:\\Program Files (x86)"),515 "Microsoft\\Edge\\Application\\msedge.exe")516 # location in 32-bit Windows517 edge32 = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"),518 "Microsoft\\Edge\\Application\\msedge.exe")519 for browser in ("firefox", "seamonkey", "mozilla", "chrome",520 "opera", edge64, edge32):521 if shutil.which(browser):522 register(browser, None, BackgroundBrowser(browser))523 if shutil.which("MicrosoftEdge.exe"):524 register("microsoft-edge", None, Edge("MicrosoftEdge.exe"))525 else:526 # Prefer X browsers if present527 #528 # NOTE: Do not check for X11 browser on macOS,529 # XQuartz installation sets a DISPLAY environment variable and will530 # autostart when someone tries to access the display. Mac users in531 # general don't need an X11 browser.532 if sys.platform != "darwin" and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_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 PermissionError, NotADirectoryError):539 pass540 else:541 global _os_preferred_browser542 _os_preferred_browser = result543 544 register_X_browsers()545 546 # Also try console browsers547 if os.environ.get("TERM"):548 # Common symbolic link for the default text-based browser549 if shutil.which("www-browser"):550 register("www-browser", None, GenericBrowser("www-browser"))551 # The Links/elinks browsers <http://links.twibright.com/>552 if shutil.which("links"):553 register("links", None, GenericBrowser("links"))554 if shutil.which("elinks"):555 register("elinks", None, Elinks("elinks"))556 # The Lynx browser <https://lynx.invisible-island.net/>, <http://lynx.browser.org/>557 if shutil.which("lynx"):558 register("lynx", None, GenericBrowser("lynx"))559 # The w3m browser <http://w3m.sourceforge.net/>560 if shutil.which("w3m"):561 register("w3m", None, GenericBrowser("w3m"))562 563 # OK, now that we know what the default preference orders for each564 # platform are, allow user to override them with the BROWSER variable.565 if "BROWSER" in os.environ:566 userchoices = os.environ["BROWSER"].split(os.pathsep)567 userchoices.reverse()568 569 # Treat choices in same way as if passed into get() but do register570 # and prepend to _tryorder571 for cmdline in userchoices:572 if all(x not in cmdline for x in " \t"):573 # Assume this is the name of a registered command, use574 # that unless it is a GenericBrowser.575 try:576 command = _browsers[cmdline.lower()]577 except KeyError:578 pass579 580 else:581 if not isinstance(command[1], GenericBrowser):582 _tryorder.insert(0, cmdline.lower())583 continue584 585 if cmdline != '':586 cmd = _synthesize(cmdline, preferred=True)587 if cmd[1] is None:588 register(cmdline, None, GenericBrowser(cmdline), preferred=True)589 590 # what to do if _tryorder is now empty?591 592 593#594# Platform support for Windows595#596 597if sys.platform[:3] == "win":598 class WindowsDefault(BaseBrowser):599 def open(self, url, new=0, autoraise=True):600 sys.audit("webbrowser.open", url)601 self._check_url(url)602 try:603 os.startfile(url)604 except OSError:605 # [Error 22] No application is associated with the specified606 # file for this operation: '<URL>'607 return False608 else:609 return True610 611#612# Platform support for macOS613#614 615if sys.platform == 'darwin':616 class MacOSXOSAScript(BaseBrowser):617 def __init__(self, name='default'):618 super().__init__(name)619 620 def open(self, url, new=0, autoraise=True):621 sys.audit("webbrowser.open", url)622 self._check_url(url)623 url = url.replace('"', '%22')624 if self.name == 'default':625 proto, _sep, _rest = url.partition(":")626 if _sep and proto.lower() in {"http", "https"}:627 # default web URL, don't need to lookup browser628 script = f'open location "{url}"'629 else:630 # if not a web URL, need to lookup default browser to ensure a browser is launched631 # this should always work, but is overkill to lookup http handler632 # before launching http633 script = f"""634 use framework "AppKit"635 use AppleScript version "2.4"636 use scripting additions637 638 property NSWorkspace : a reference to current application's NSWorkspace639 property NSURL : a reference to current application's NSURL640 641 set http_url to NSURL's URLWithString:"https://python.org"642 set browser_url to (NSWorkspace's sharedWorkspace)'s ¬643 URLForApplicationToOpenURL:http_url644 set app_path to browser_url's relativePath as text -- NSURL to absolute path '/Applications/Safari.app'645 646 tell application app_path647 activate648 open location "{url}"649 end tell650 """651 else:652 script = f'''653 tell application "{self.name}"654 activate655 open location "{url}"656 end657 '''658 659 osapipe = os.popen("/usr/bin/osascript", "w")660 if osapipe is None:661 return False662 663 osapipe.write(script)664 rc = osapipe.close()665 return not rc666 667#668# Platform support for iOS669#670if sys.platform == "ios":671 from _ios_support import objc672 if objc:673 # If objc exists, we know ctypes is also importable.674 from ctypes import c_void_p, c_char_p, c_ulong675 676 class IOSBrowser(BaseBrowser):677 def open(self, url, new=0, autoraise=True):678 sys.audit("webbrowser.open", url)679 self._check_url(url)680 # If ctypes isn't available, we can't open a browser681 if objc is None:682 return False683 684 # All the messages in this call return object references.685 objc.objc_msgSend.restype = c_void_p686 687 # This is the equivalent of:688 # NSString url_string =689 # [NSString stringWithCString:url.encode("utf-8")690 # encoding:NSUTF8StringEncoding];691 NSString = objc.objc_getClass(b"NSString")692 constructor = objc.sel_registerName(b"stringWithCString:encoding:")693 objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_char_p, c_ulong]694 url_string = objc.objc_msgSend(695 NSString,696 constructor,697 url.encode("utf-8"),698 4, # NSUTF8StringEncoding = 4699 )700 701 # Create an NSURL object representing the URL702 # This is the equivalent of:703 # NSURL *nsurl = [NSURL URLWithString:url];704 NSURL = objc.objc_getClass(b"NSURL")705 urlWithString_ = objc.sel_registerName(b"URLWithString:")706 objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p]707 ns_url = objc.objc_msgSend(NSURL, urlWithString_, url_string)708 709 # Get the shared UIApplication instance710 # This code is the equivalent of:711 # UIApplication shared_app = [UIApplication sharedApplication]712 UIApplication = objc.objc_getClass(b"UIApplication")713 sharedApplication = objc.sel_registerName(b"sharedApplication")714 objc.objc_msgSend.argtypes = [c_void_p, c_void_p]715 shared_app = objc.objc_msgSend(UIApplication, sharedApplication)716 717 # Open the URL on the shared application718 # This code is the equivalent of:719 # [shared_app openURL:ns_url720 # options:NIL721 # completionHandler:NIL];722 openURL_ = objc.sel_registerName(b"openURL:options:completionHandler:")723 objc.objc_msgSend.argtypes = [724 c_void_p, c_void_p, c_void_p, c_void_p, c_void_p725 ]726 # Method returns void727 objc.objc_msgSend.restype = None728 objc.objc_msgSend(shared_app, openURL_, ns_url, None, None)729 730 return True731 732 733def parse_args(arg_list: list[str] | None):734 import argparse735 parser = argparse.ArgumentParser(736 description="Open URL in a web browser.", color=True,737 )738 parser.add_argument("url", help="URL to open")739 740 group = parser.add_mutually_exclusive_group()741 group.add_argument("-n", "--new-window", action="store_const",742 const=1, default=0, dest="new_win",743 help="open new window")744 group.add_argument("-t", "--new-tab", action="store_const",745 const=2, default=0, dest="new_win",746 help="open new tab")747 748 args = parser.parse_args(arg_list)749 750 return args751 752 753def main(arg_list: list[str] | None = None):754 args = parse_args(arg_list)755 756 open(args.url, args.new_win)757 758 print("\a")759 760 761if __name__ == "__main__":762 main()763 