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. 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:
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
.
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
'';
};
- create our custom
xdg-open
proxy and store it somewhere in Nix store (instead of our home) - counter-intuitively, disable shell script checks.
true
is 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/slack
into a script, that prependsxdgOpenProxy
toPATH
before 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…
Rick & Morty. Season 3, Episode 6. TM & © Adult SwimMeet 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:
Original picture from Rick & Morty. TM & © Adult Swimor this guy:
Original picture from Rick & Morty. Season 1, Episode 3. TM & © Adult SwimCheers! 🏝️🏖