How do you change browser in Slack anyway?

9 minutes read

Overriding the default browser in Slack is my vexatious personal itch. I have a dedicated browser just for work, and I use Slack solely for work. Any link from Slack, be it a GitHub pull request, an unlisted corporate video on YouTube, or an internal meme, I want to open it in my work-only browser. Everything else I want to open in my default browser. And if I get this to work, I’ll be awesome.

Of course, you may argue: but hey, why would you mix your work and personal environments? That’s not healthy! Get yourself a different laptop.

Wow, dear, wow! What discipline and work ethics you have. Let’s go. In and out, twenty-minute adventure.

rick morty in out
Rick & Morty. Season 3, Episode 6. TM & © Adult Swim

 

Superficial intro to xdg-open

Can’t we just set $BROWSER?

Among CLI applications, there’s a convention to respect the $EDITOR environment variable: whenever there’s a need to edit something, CLI apps launch the application specified in this environment variable.

~ ❯ env | grep EDITOR
EDITOR=nvim

For example, the visudo and sudoedit applications read $EDITOR and directly spawn the binary specified in this variable.

Similarly, $BROWSER may tell applications which browser to invoke. However, if you launch Slack like this:

~ ❯ BROWSER=firefox slack
~ ❯ BROWSER=qutebrowser slack

you’ll very likely see that it doesn’t change anything.

XDG MIME Applications specification

Let’s make a step back and mull over an idea of specifying all default applications using environment variables. So, you have $EDITOR for your default text editor. What about rich text editors and word processors like LibreOffice? We may have $WORD_PROCESSOR maybe? Graphic editors anyone? $RASTER_GRAPHIC_EDITOR may point to GIMP, and $VECTOR_GRAPHIC_EDITOR — to Inkscape. At this point, it becomes obvious that this approach does not scale. Ok, we could scale this better by defining environment variable per mimetype, for example

MIME_IMAGE_PNG="feh"
MIME_TEXT_HTML="firefox"

But another issue with environment variables is that whenever we spawn a new process, it inherits the entire environment from its parent. More specifically, the environment is copied from the parent process to the child process. After that, it is only possible to change the process’s environment from within this process. Any changes to the parent process environment won’t be reflected in any of its running children. Practically, this means that once you launch an application, you won’t be able to change its default browser without restarting the application (or even the entire graphical session, which is almost as bad as rebooting). This, arguably, would be a terrible user experience.

For a typical desktop we need something more robust and flexible than inherited environment variables. And the folks from freedesktop.org gave us just that in a form of XDG MIME Applications specification and a set of conventions surrounding it.

Whenever Slack or any other application needs to open a URL or a file, there’s a convention to call xdg-open, a small utility script from xdg-utils. This script inspects the passed URL or a file, picks the right application to handle it, and spawns it on behalf of Slack or any other application.

For URLs, xdg-open inspects the scheme part of the URL (that part in the beginnig, like http:// or magnet://), and for files, it inspects their mimetype. The association between a scheme or mimetype and an application to handle it is stored in mimeapps.list file (which may reside under /etc or inside $XDG_CONFIG_HOME). Perhaps, a small diagram will explain it better:

xdg open 01

Hence, to change the browser spawned on behalf of Slack we could

  • change xdg-open configuration, or
  • change xdg-open implementation.

We can’t normally[1] change mimeapps.list just for Slack (the changes will affect all other apps). Neither can we change the value of $XDG_CONFIG_HOME, to point xdg-open to a different config file, because this environment variable will affect Slack itself and any other app spawned on its behalf (remember, environment variables are inherited from the parent process).

We’d like also not to change xdg-open implementation globally in our system: ideally, the change should only affect Slack, not all other apps. But foremost, diverging from upstream is very unpractical. However, in the spirit of this solution, we can introduce a proxy implementation of xdg-open, which we’ll "inject" into Slack by adding it to PATH.

xdg open 02

For example, our proxy can open $BROWSER (if it is set) for all http:// and https:// URLs, and for everythign else delegate to the original xdg-open

#!/usr/bin/env bash
webhandler="''${BROWSER:-/usr/bin/xdg-open}"
case "$1" in
  http:*|https:*)
    $webhandler "$1"
    ;;
  *)
    /usr/bin/xdg-open "$1"
    ;;
esac

We can save this script as ~/.local/xdg-open/xdg-open. Then we can execute Slack with our script in $PATH to force it to respect $BROWSER

$ export PATH="~/.local/xdg-open:$PATH"
$ BROWSER="firefox -P work" slack

And with the power of Nix (I use NixOS btw[2]) any dirty hack can be turned into a gloried declarative masterpiece:

  xdgOpenProxy = pkgs.writeShellApplication { 1
    name = "xdg-open";
    checkPhase = "true"; 2
    text = ''
      case "$1" in
        http:*|https:*)
          firefox -P work "$1" 3
          ;;
        *)
          ${pkgs.xdg-utils}/bin/xdg-open "$1"
          ;;
      esac
    '';
  };

  slack = let 4
    pkg = pkgs.slack;
    pname = pkg.out.pname;
  in
  pkgs.symlinkJoin { 5
    name = slack-for-work; 6
    paths = [ pkg ];
    buildInputs = [ pkgs.makeWrapper ];
    postBuild = ''
      wrapProgram "$out/bin/${pname}" \
        --prefix PATH : "${xdgOpenProxy}/bin" 7
      for desktopFile in $out/share/applications/*; do 8
        cp --remove-destination $(readlink -e "$desktopFile") "$desktopFile"
        sed -i -e 's:${pkg}/bin/${pname}:${pname}:' "$desktopFile"
      done
    '';
  };
  1. create our custom xdg-open proxy and store it somewhere in Nix store (instead of our home)
  2. counter-intuitively, disable shell script checks. true is an app exiting with 0, which signalls that all checks have passed
  3. since this proxy is only used for Slack, we can hardcode the desired browser right there — Firefox with the profile named "work"
  4. finally, we define our own Slack package
  5. symlink everything from the original Slack package to the new one
  6. the name of the new package can be anything, we don’t care
  7. wrap bin/slack into a script, that prepends xdgOpenProxy to PATH before delegating to the original binary
  8. patch all *.desktop files shipped with the application. Otherwise, they may point to the binary from the original Slack package. That’s a Nix thing

That’s our 20-minute adventure. In and out.

Well, wait…​

Six days later…​

six days later
Rick & Morty. Season 3, Episode 6. TM & © Adult Swim

Meet xdg-override

I don’t know, why I keep doing this to myself, but I decided to "generify" the solution.

I mean, there’s some rationale: for instance, I realized that I always wanted spotify links to be handled by the Spotify app. But for whatever reason, I decided that adding 4 lines of code to my 20⁠-⁠minute hack was not good enough.

case "$1" in
  https://open.spotify.com/*)
    spotify-play "$1"
    ;;
  ...

Long story short, I wrote xdg-override.

xdg-override script

The script allows "injecting" xdg-open proxy into an arbitrary application

~ ❯ xdg-override \
  --match "^https?://open.spotify.com/" "spotify-open" \
  --match "^https?://" "firefox -P work" \
  slack

It is based on the idea described above, but the script won’t generate proxy implementation. Instead, xdg-override will copy itself to /tmp/xdg-override-$USER/xdg-open and will set a few $XDG_OVERRIDE_* variables and the $PATH:

XDG_OVERRIDE_MATCH="^https?://open.spotify.com/|spotify-open|^https?://|firefox -P work"
XDG_OVERRIDE_DELEGATE="$(which xdg-open)"
PATH="/tmp/xdg-override-$USER:$PATH"

When xdg-override is invoked from this new location as xdg-open, it’ll operate in a different mode, parsing $XDG_OVERRIDE_MATCH and dispatching the call appropriately. I tested this script briefly, but automated tests are missing, so expect some rough edges and bugs.

Declarative Nix goodness

Of course the script can be installed using the Nix flake, or tried out without installation

~ ❯ nix run github:koiuo/xdg-override --match "^https?://" "firefox -P work" slack

Additionally, the flake exposes a few library functions.

The wrapPackage allows "injecting" custom xdg-open proxy into a specific package:

# wraps a single application and injects custom xdg-open proxy
slack = (xdg-override.lib.wrapPackage {
  nameMatch = [
    { case = "^https?://open.spotify.com/"; command = "spotify-open" }
    { case = "^https?://"; command = "firefox -P work" }
  ];
} pkgs.slack);

The proxyPkg function generates an xdg-open proxy which then can be installed as a normal package:

xdg-open = (xdg-override.lib.proxyPkg {
  inherit pkgs;
  nameMatch = [
    { case = "^https?://open.spotify.com", command = "spotify-open" }
  ];
});
home.packages = [
  ...
  xdg-open
  ...
];

You may need to add xdg-open to PATH explicitly, it works on my machine, but I strongly suspect it relies on some implementation detail of home-manager so the proxy package appears in PATH above xdg-utils.

There’s also overlay function which generates an overlay with "patched" xdg-utils package. Adding this overlay will globally replace xdg-open with a proxy according to the specified configuration.

nixpkgs.overlays = [
  xdg-override.lib.overlay {
    nameMatch = [
      { case = "^https?://open.spotify.com/"; command = "spotify-open" }
    ];
  }
]

However, I discovered that doing so will cause almost entire system rebuild (derivations are immutable, remember?).

Please try those out and let me know if it’s useful, if you have any ideas/suggestions or need any features.

Closing thoughts

I didn’t share my experiments where I tried "reconfiguring" xdg-open by privately bind-mounitng a different mimeapps.list in Slack’s mount namespace. It would’ve been a very clever over-engineered solution to a simple problem, with interesting security gotchas, coding in C (or Rust, or Zig)…​ Alas, xdg-open is slightly more complex than I showed on that first diagram, so the mount-namespace-based solution would’ve been both over-engineered and brittle.

xdg open 03

Specifically, xdg-open will delegate to an "opener" that is native to the desktop environment it is running in, and each of those may have their own configuration. Under some very specific conditions (unknown desktop environment, no defined handler for http and https), xdg-open will even consider the $BROWSER variable…​

By the way, does anyone know why every desktop environment has to maintain its own implementation of "opener" along with its own registry of mimetypes and handlers? I have some ideas, but please let me know in the comments.

Oh, and don’t hesitate to be this guy:

slack browser
Original picture from Rick & Morty. TM & © Adult Swim

or this guy:

eBPF
Original picture from Rick & Morty. Season 1, Episode 3. TM & © Adult Swim

Cheers! 🏝️🏖


  1. It is possible to privately bind-mount a different file (see man 1 unshare). But that wouldn’t help us anyway for a different reason
  2. And before that I used Arch btw