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