Selecting the Web Browser

Table of Contents

Conventionally, desktop environments suck at starting web browsers. There are plenty of situations in which technical (read: demanding) users need more than one browser: if you are developing for the web or are privacy concious or simply use few web applications. In each of these situations you’ll most likely need at least 2 or 3 different browsers.

Now, how to choose which one opens links?

Miserable State of Desktop Environments

Most desktop environments (DE) are pretty dumb when it comes to this. I can’t blame them as their options are limited. They won’t read minds of their users after all.

Default Browsers in Desktop Environments

Usually users have an option to configure a default browser. The configuration details are DE-specific, but any changes are usually reflected by xdg-open, the opener application often used by programs which are not a part of DE suite.

Xdg-open does quite good job for detecting user settings for big DEs like KDE, Gnome and XFCE. But when you use a system without DE, you’re on your own. Without a visual presentation1, which DEs give, it is very hard to guess which application xdg-open will choose for a certain file. Even more confusing, it’s quite easy to unwillingly reconfigure default applications. Underneath, xdg-open uses MIME types to choose between applications, meaning that installing another program which handles the same MIME type might change the default one. Happened to me for PDFs more than once3.

Back to web browsers, there’s also special $BROWSER environment variable, which by various command line applications prefer, completely sidestepping delicate xdg-settings.

This of course leads to discrepancies in handling of files and URLs. After years of using damn things, I am still unable to fully comprehend how they work and how to reconfigure them effectively. Frankly, I stopped trying.

yaxo

Because of this poor experience, I wrote my own opener, yaxo2, which I’ve been using for several years now. I symlinked it as xdg-open so any application which tries to use the original will use my program instead.

Yaxo has simple configuration file. It contains a set of rules, matched against the passed arguments from top to bottom. First one which matches wins and tells yaxo how to open the file/URL.

Thanks to yaxo, web browser can be easily set for a whole system in a predictable way. This web browser can be a script which adds some logic for launching the most appropriate browser.

It looks like this this:

mime text/html, if graphical: webbrowser -- "$@"
mime text/html, if terminal:  w3m -- "$@"

scheme https?, if graphical: webbrowser -- "$@"
scheme https?, if terminal:  w3m -- "$@"

ext .*x?html?, if graphical: webbrowser -- "$@"
ext .*x?html?, if terminal:  w3m -- "$@"

ext .*, if terminal:  vim -- "$@"
ext .*, if !terminal: alacritty -e vim -- "$@"

Reading:

  • check MIME type for passed argument, then its scheme, then its extension;
  • if any of above are things handled by web browser:
    • pass the arguments to the webbrowser script inside a graphical environment (either X11 or Wayland);
    • if we’re inside the terminal, pass the arguments to w3m;
  • otherwise just open received argument in vim for editing.

Algorithm of Choosing the Web Browser

What is this magical logic which webbrowser script has?

The idea is to keep things simple and predictable. If certain web browser is visible on the screen, there’s 99% probability that it will be the one which should be used. If no browser is visible, the script should search for rarely used browsers running in the background (because having such browser open, eating precious RAM, must mean something, right?). Otherwise, proceed with the default browser.

The actual algorithm is:

  1. set up a priority list of supported web browsers:
    • browsers are searched from the top of the list;
    • list is reversed, meaning that the least popular browsers are on top of the list and the default one is last;
  2. compare the list against the currently focused window;
  3. compare the list against all visible windows (current workspace);
  4. compare the list against all the windows, including background workspaces;
  5. use ${BROWSER} or, if not set, last item from the list.

Querying the Windows

I am sway user in which all queries to Window Manager go through its IPC mechanism4. I am not interested in writing generic version of the script, but here are 2 hints for anyone who would like to port it to other graphical environments:

  • in Wayland, you must rely on capabilities of your compositor, period. If your compositor doesn’t give you a list of managed windows, there’s nothing you can do;
  • in X11 similar data can be obtained by parsing outputs of xprop -root _NET_ACTIVE_WINDOW (window with focus) and xwininfo -tree -root (list of windows).

Necessity for Detaching

One thing to keep in mind is that creating a new instance of web browser usually is blocking operation, meaning that the script exits only once we close the web browser. And contrary, spawning the same web browser second time usually results in opening a new tab in the original process, meaning that the script exits immediately. This, of course, is browser-specific behaviour but I found out that it’s the same for all mainstream browsers.

Usually I want the script to exit as soon as possible no matter what. So when necessary I wrap the script with dtach, which is a lightweight program emulating the detach feature of screen or tmux:

sock="$(mktemp --suffix .dtach.sock -u)"
dtach -n "$sock" mg webbrowser "$url"

Source code

The source code of the script below is provided under the terms of GPLv3, contrary to the general license of the snippets presented on this page.
#!/usr/bin/env python3

# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2021 Michał Góral.

# Usage: set this script as a default web browser in your environment and
# modify BROWSERS dictionary to your liking. When many web browsers are
# matched, they will be used in order of BROWSERS entries (ordered built-in
# dictionaries are a feature of Python 3.6+, otherwise you should change it
# to collections.OrderedDict). Your least used browser probably should be on
# top of that list.

import os
import sys
import argparse
import shlex
import subprocess
from fnmatch import fnmatch
from functools import partial
from i3ipc import Connection

# key: executable name which should be in $PATH
# value: list of globs which are matched against window class (X11) or app_id
#        (Wayland)
BROWSERS = {
    "chromium": ["*chromium*"],
    "firefox": ["*firefox*"],
    "qutebrowser": ["*qutebrowser*"],
}

PRIORITIES = {key: i for i, key in enumerate(BROWSERS)}


def find_browsers(windows):
    for window in windows:
        checkattr = window.app_id if window.app_id else window.window_class
        if not checkattr:
            continue

        for name, matchers in BROWSERS.items():
            if any(fnmatch(checkattr, pattern) for pattern in matchers):
                yield name


def get_browser(iterable):
    found = sorted(iterable, key=lambda name: PRIORITIES[name])
    if found:
        return found[0]
    return None


def current_window(tree, focused):
    if focused:
        return [focused]
    return None


def current_workspace(tree, focused):
    if focused:
        return focused.workspace().leaves()
    return None


def all_windows(tree):
    return tree.leaves()


def quote_args(cmd):
    def _q(arg):
        if arg in BROWSERS:
            return arg
        return shlex.quote(arg)

    return [_q(arg) for arg in cmd]


def prepare_args():
    parser = argparse.ArgumentParser(description="Select currently used browser")

    parser.add_argument("url", nargs="*")
    parser.add_argument(
        "--print", help="only print browser name/command", action="store_true"
    )

    return parser.parse_args()


def main():
    args = prepare_args()

    i3 = Connection()
    tree = i3.get_tree()

    focused = tree.find_focused()
    scopes = [
        partial(current_window, focused=focused),
        partial(current_workspace, focused=focused),
        all_windows,
    ]

    browser = None
    for scope_fn in scopes:
        windows = scope_fn(tree)
        browser = get_browser(find_browsers(windows))
        if browser:
            break

    if not browser:
        browser = os.environ.get("BROWSER", list(BROWSERS)[-1])

    cmd = [browser]
    if args.url:
        cmd.extend(args.url)

    if args.print:
        print(*quote_args(cmd))
    else:
        subprocess.run(cmd)


sys.exit(main())

  1. Settings window. 

  2. Yet Another Xdg-Open. 

  3. Apparently Zathura isn’t very popular choice for reading PDFs. 

  4. Obviously, the same mechanism is available for i3.