1 #! /usr/bin/env python3
2 """Interfaces for launching and remotely controlling web browsers."""
3 # Maintained by Georg Brandl.
4
5 import os
6 import shlex
7 import shutil
8 import sys
9 import subprocess
10 import threading
11 import warnings
12
13 __all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"]
14
15 class ESC[4;38;5;81mError(ESC[4;38;5;149mException):
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
23 def 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
38 def 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
72 def 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
91 def 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
98 def 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
106 def _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
140 class ESC[4;38;5;81mBaseBrowser(ESC[4;38;5;149mobject):
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
159 class ESC[4;38;5;81mGenericBrowser(ESC[4;38;5;149mBaseBrowser):
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
187 class ESC[4;38;5;81mBackgroundBrowser(ESC[4;38;5;149mGenericBrowser):
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
206 class ESC[4;38;5;81mUnixBrowser(ESC[4;38;5;149mBaseBrowser):
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
285 class ESC[4;38;5;81mMozilla(ESC[4;38;5;149mUnixBrowser):
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
295 class ESC[4;38;5;81mNetscape(ESC[4;38;5;149mUnixBrowser):
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
306 class ESC[4;38;5;81mGaleon(ESC[4;38;5;149mUnixBrowser):
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
316 class ESC[4;38;5;81mChrome(ESC[4;38;5;149mUnixBrowser):
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
325 Chromium = Chrome
326
327
328 class ESC[4;38;5;81mOpera(ESC[4;38;5;149mUnixBrowser):
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
338 class ESC[4;38;5;81mElinks(ESC[4;38;5;149mUnixBrowser):
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
352 class ESC[4;38;5;81mKonqueror(ESC[4;38;5;149mBaseBrowser):
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
405 class ESC[4;38;5;81mGrail(ESC[4;38;5;149mBaseBrowser):
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
459 def 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
525 def 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
604 if sys.platform[:3] == "win":
605 class ESC[4;38;5;81mWindowsDefault(ESC[4;38;5;149mBaseBrowser):
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
621 if sys.platform == 'darwin':
622 # Adapted from patch submitted to SourceForge by Steven J. Burr
623 class ESC[4;38;5;81mMacOSX(ESC[4;38;5;149mBaseBrowser):
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 ESC[4;38;5;81mMacOSXOSAScript(ESC[4;38;5;149mBaseBrowser):
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
710 def 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
734 if __name__ == "__main__":
735 main()