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.

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:

Hence, to change the browser spawned on behalf of Slack we could
- change
xdg-openconfiguration, or - change
xdg-openimplementation.
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.

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"
;;
esacWe 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
'';
};- create our custom
xdg-openproxy and store it somewhere in Nix store (instead of our home) - counter-intuitively, disable shell script checks.
trueis an app exiting with 0, which signalls that all checks have passed - since this proxy is only used for Slack, we can hardcode the desired browser right there — Firefox with the profile named "work"
- finally, we define our own Slack package
- symlink everything from the original Slack package to the new one
- the name of the new package can be anything, we don’t care
- wrap
bin/slackinto a script, that prependsxdgOpenProxytoPATHbefore delegating to the original binary - 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…

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.

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:

or this guy:

Cheers! 🏝️🏖