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