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;81mEpiphany(ESC[4;38;5;149mUnixBrowser):
296 """Launcher class for Epiphany browser."""
297
298 raise_opts = ["-noraise", ""]
299 remote_args = ['%action', '%s']
300 remote_action = "-n"
301 remote_action_newwin = "-w"
302 background = True
303
304
305 class ESC[4;38;5;81mChrome(ESC[4;38;5;149mUnixBrowser):
306 "Launcher class for Google Chrome browser."
307
308 remote_args = ['%action', '%s']
309 remote_action = ""
310 remote_action_newwin = "--new-window"
311 remote_action_newtab = ""
312 background = True
313
314 Chromium = Chrome
315
316
317 class ESC[4;38;5;81mOpera(ESC[4;38;5;149mUnixBrowser):
318 "Launcher class for Opera browser."
319
320 remote_args = ['%action', '%s']
321 remote_action = ""
322 remote_action_newwin = "--new-window"
323 remote_action_newtab = ""
324 background = True
325
326
327 class ESC[4;38;5;81mElinks(ESC[4;38;5;149mUnixBrowser):
328 "Launcher class for Elinks browsers."
329
330 remote_args = ['-remote', 'openURL(%s%action)']
331 remote_action = ""
332 remote_action_newwin = ",new-window"
333 remote_action_newtab = ",new-tab"
334 background = False
335
336 # elinks doesn't like its stdout to be redirected -
337 # it uses redirected stdout as a signal to do -dump
338 redirect_stdout = False
339
340
341 class ESC[4;38;5;81mKonqueror(ESC[4;38;5;149mBaseBrowser):
342 """Controller for the KDE File Manager (kfm, or Konqueror).
343
344 See the output of ``kfmclient --commands``
345 for more information on the Konqueror remote-control interface.
346 """
347
348 def open(self, url, new=0, autoraise=True):
349 sys.audit("webbrowser.open", url)
350 # XXX Currently I know no way to prevent KFM from opening a new win.
351 if new == 2:
352 action = "newTab"
353 else:
354 action = "openURL"
355
356 devnull = subprocess.DEVNULL
357
358 try:
359 p = subprocess.Popen(["kfmclient", action, url],
360 close_fds=True, stdin=devnull,
361 stdout=devnull, stderr=devnull)
362 except OSError:
363 # fall through to next variant
364 pass
365 else:
366 p.wait()
367 # kfmclient's return code unfortunately has no meaning as it seems
368 return True
369
370 try:
371 p = subprocess.Popen(["konqueror", "--silent", url],
372 close_fds=True, stdin=devnull,
373 stdout=devnull, stderr=devnull,
374 start_new_session=True)
375 except OSError:
376 # fall through to next variant
377 pass
378 else:
379 if p.poll() is None:
380 # Should be running now.
381 return True
382
383 try:
384 p = subprocess.Popen(["kfm", "-d", url],
385 close_fds=True, stdin=devnull,
386 stdout=devnull, stderr=devnull,
387 start_new_session=True)
388 except OSError:
389 return False
390 else:
391 return (p.poll() is None)
392
393
394 class ESC[4;38;5;81mEdge(ESC[4;38;5;149mUnixBrowser):
395 "Launcher class for Microsoft Edge browser."
396
397 remote_args = ['%action', '%s']
398 remote_action = ""
399 remote_action_newwin = "--new-window"
400 remote_action_newtab = ""
401 background = True
402
403
404 #
405 # Platform support for Unix
406 #
407
408 # These are the right tests because all these Unix browsers require either
409 # a console terminal or an X display to run.
410
411 def register_X_browsers():
412
413 # use xdg-open if around
414 if shutil.which("xdg-open"):
415 register("xdg-open", None, BackgroundBrowser("xdg-open"))
416
417 # Opens an appropriate browser for the URL scheme according to
418 # freedesktop.org settings (GNOME, KDE, XFCE, etc.)
419 if shutil.which("gio"):
420 register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"]))
421
422 # Equivalent of gio open before 2015
423 if "GNOME_DESKTOP_SESSION_ID" in os.environ and shutil.which("gvfs-open"):
424 register("gvfs-open", None, BackgroundBrowser("gvfs-open"))
425
426 # The default KDE browser
427 if "KDE_FULL_SESSION" in os.environ and shutil.which("kfmclient"):
428 register("kfmclient", Konqueror, Konqueror("kfmclient"))
429
430 # Common symbolic link for the default X11 browser
431 if shutil.which("x-www-browser"):
432 register("x-www-browser", None, BackgroundBrowser("x-www-browser"))
433
434 # The Mozilla browsers
435 for browser in ("firefox", "iceweasel", "seamonkey", "mozilla-firefox",
436 "mozilla"):
437 if shutil.which(browser):
438 register(browser, None, Mozilla(browser))
439
440 # Konqueror/kfm, the KDE browser.
441 if shutil.which("kfm"):
442 register("kfm", Konqueror, Konqueror("kfm"))
443 elif shutil.which("konqueror"):
444 register("konqueror", Konqueror, Konqueror("konqueror"))
445
446 # Gnome's Epiphany
447 if shutil.which("epiphany"):
448 register("epiphany", None, Epiphany("epiphany"))
449
450 # Google Chrome/Chromium browsers
451 for browser in ("google-chrome", "chrome", "chromium", "chromium-browser"):
452 if shutil.which(browser):
453 register(browser, None, Chrome(browser))
454
455 # Opera, quite popular
456 if shutil.which("opera"):
457 register("opera", None, Opera("opera"))
458
459
460 if shutil.which("microsoft-edge"):
461 register("microsoft-edge", None, Edge("microsoft-edge"))
462
463
464 def register_standard_browsers():
465 global _tryorder
466 _tryorder = []
467
468 if sys.platform == 'darwin':
469 register("MacOSX", None, MacOSXOSAScript('default'))
470 register("chrome", None, MacOSXOSAScript('chrome'))
471 register("firefox", None, MacOSXOSAScript('firefox'))
472 register("safari", None, MacOSXOSAScript('safari'))
473 # OS X can use below Unix support (but we prefer using the OS X
474 # specific stuff)
475
476 if sys.platform == "serenityos":
477 # SerenityOS webbrowser, simply called "Browser".
478 register("Browser", None, BackgroundBrowser("Browser"))
479
480 if sys.platform[:3] == "win":
481 # First try to use the default Windows browser
482 register("windows-default", WindowsDefault)
483
484 # Detect some common Windows browsers, fallback to Microsoft Edge
485 # location in 64-bit Windows
486 edge64 = os.path.join(os.environ.get("PROGRAMFILES(x86)", "C:\\Program Files (x86)"),
487 "Microsoft\\Edge\\Application\\msedge.exe")
488 # location in 32-bit Windows
489 edge32 = os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"),
490 "Microsoft\\Edge\\Application\\msedge.exe")
491 for browser in ("firefox", "seamonkey", "mozilla", "chrome",
492 "opera", edge64, edge32):
493 if shutil.which(browser):
494 register(browser, None, BackgroundBrowser(browser))
495 if shutil.which("MicrosoftEdge.exe"):
496 register("microsoft-edge", None, Edge("MicrosoftEdge.exe"))
497 else:
498 # Prefer X browsers if present
499 if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
500 try:
501 cmd = "xdg-settings get default-web-browser".split()
502 raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
503 result = raw_result.decode().strip()
504 except (FileNotFoundError, subprocess.CalledProcessError, PermissionError, NotADirectoryError) :
505 pass
506 else:
507 global _os_preferred_browser
508 _os_preferred_browser = result
509
510 register_X_browsers()
511
512 # Also try console browsers
513 if os.environ.get("TERM"):
514 # Common symbolic link for the default text-based browser
515 if shutil.which("www-browser"):
516 register("www-browser", None, GenericBrowser("www-browser"))
517 # The Links/elinks browsers <http://links.twibright.com/>
518 if shutil.which("links"):
519 register("links", None, GenericBrowser("links"))
520 if shutil.which("elinks"):
521 register("elinks", None, Elinks("elinks"))
522 # The Lynx browser <https://lynx.invisible-island.net/>, <http://lynx.browser.org/>
523 if shutil.which("lynx"):
524 register("lynx", None, GenericBrowser("lynx"))
525 # The w3m browser <http://w3m.sourceforge.net/>
526 if shutil.which("w3m"):
527 register("w3m", None, GenericBrowser("w3m"))
528
529 # OK, now that we know what the default preference orders for each
530 # platform are, allow user to override them with the BROWSER variable.
531 if "BROWSER" in os.environ:
532 userchoices = os.environ["BROWSER"].split(os.pathsep)
533 userchoices.reverse()
534
535 # Treat choices in same way as if passed into get() but do register
536 # and prepend to _tryorder
537 for cmdline in userchoices:
538 if cmdline != '':
539 cmd = _synthesize(cmdline, preferred=True)
540 if cmd[1] is None:
541 register(cmdline, None, GenericBrowser(cmdline), preferred=True)
542
543 # what to do if _tryorder is now empty?
544
545
546 #
547 # Platform support for Windows
548 #
549
550 if sys.platform[:3] == "win":
551 class ESC[4;38;5;81mWindowsDefault(ESC[4;38;5;149mBaseBrowser):
552 def open(self, url, new=0, autoraise=True):
553 sys.audit("webbrowser.open", url)
554 try:
555 os.startfile(url)
556 except OSError:
557 # [Error 22] No application is associated with the specified
558 # file for this operation: '<URL>'
559 return False
560 else:
561 return True
562
563 #
564 # Platform support for MacOS
565 #
566
567 if sys.platform == 'darwin':
568 # Adapted from patch submitted to SourceForge by Steven J. Burr
569 class ESC[4;38;5;81mMacOSX(ESC[4;38;5;149mBaseBrowser):
570 """Launcher class for Aqua browsers on Mac OS X
571
572 Optionally specify a browser name on instantiation. Note that this
573 will not work for Aqua browsers if the user has moved the application
574 package after installation.
575
576 If no browser is specified, the default browser, as specified in the
577 Internet System Preferences panel, will be used.
578 """
579 def __init__(self, name):
580 warnings.warn(f'{self.__class__.__name__} is deprecated in 3.11'
581 ' use MacOSXOSAScript instead.', DeprecationWarning, stacklevel=2)
582 self.name = name
583
584 def open(self, url, new=0, autoraise=True):
585 sys.audit("webbrowser.open", url)
586 assert "'" not in url
587 # hack for local urls
588 if not ':' in url:
589 url = 'file:'+url
590
591 # new must be 0 or 1
592 new = int(bool(new))
593 if self.name == "default":
594 # User called open, open_new or get without a browser parameter
595 script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
596 else:
597 # User called get and chose a browser
598 if self.name == "OmniWeb":
599 toWindow = ""
600 else:
601 # Include toWindow parameter of OpenURL command for browsers
602 # that support it. 0 == new window; -1 == existing
603 toWindow = "toWindow %d" % (new - 1)
604 cmd = 'OpenURL "%s"' % url.replace('"', '%22')
605 script = '''tell application "%s"
606 activate
607 %s %s
608 end tell''' % (self.name, cmd, toWindow)
609 # Open pipe to AppleScript through osascript command
610 osapipe = os.popen("osascript", "w")
611 if osapipe is None:
612 return False
613 # Write script to osascript's stdin
614 osapipe.write(script)
615 rc = osapipe.close()
616 return not rc
617
618 class ESC[4;38;5;81mMacOSXOSAScript(ESC[4;38;5;149mBaseBrowser):
619 def __init__(self, name='default'):
620 super().__init__(name)
621
622 @property
623 def _name(self):
624 warnings.warn(f'{self.__class__.__name__}._name is deprecated in 3.11'
625 f' use {self.__class__.__name__}.name instead.',
626 DeprecationWarning, stacklevel=2)
627 return self.name
628
629 @_name.setter
630 def _name(self, val):
631 warnings.warn(f'{self.__class__.__name__}._name is deprecated in 3.11'
632 f' use {self.__class__.__name__}.name instead.',
633 DeprecationWarning, stacklevel=2)
634 self.name = val
635
636 def open(self, url, new=0, autoraise=True):
637 if self.name == 'default':
638 script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
639 else:
640 script = f'''
641 tell application "%s"
642 activate
643 open location "%s"
644 end
645 '''%(self.name, url.replace('"', '%22'))
646
647 osapipe = os.popen("osascript", "w")
648 if osapipe is None:
649 return False
650
651 osapipe.write(script)
652 rc = osapipe.close()
653 return not rc
654
655
656 def main():
657 import getopt
658 usage = """Usage: %s [-n | -t | -h] url
659 -n: open new window
660 -t: open new tab
661 -h, --help: show help""" % sys.argv[0]
662 try:
663 opts, args = getopt.getopt(sys.argv[1:], 'ntdh',['help'])
664 except getopt.error as msg:
665 print(msg, file=sys.stderr)
666 print(usage, file=sys.stderr)
667 sys.exit(1)
668 new_win = 0
669 for o, a in opts:
670 if o == '-n': new_win = 1
671 elif o == '-t': new_win = 2
672 elif o == '-h' or o == '--help':
673 print(usage, file=sys.stderr)
674 sys.exit()
675 if len(args) != 1:
676 print(usage, file=sys.stderr)
677 sys.exit(1)
678
679 url = args[0]
680 open(url, new_win)
681
682 print("\a")
683
684 if __name__ == "__main__":
685 main()