My messaging app of choice recently has been Beeper, which is trying to build a “one chat app to rule them all”. I’ve been pretty deep in the Apple ecosystem for many years, so for me the huge appeal of Beeper is being able to use iMessage from non-Apple platforms. I just recently started using NixOS on a Framework laptop as my daily driver, and I want to be able to continue using iMessage despite no longer using a Mac. To do that, I need to package Beeper’s Linux app for NixOS.
Beeper for Linux is distributed as an AppImage. An AppImage is a Linux ELF binary that includes a filesystem with the app and everything it needs to be able to run. The idea is that you can build a single binary that can run on any Linux distribution. Unfortunately, these binaries aren’t fully statically-linked: they assume some basic libraries will be present on the system in the typical locations according to the FHS. NixOS doesn’t follow the FHS, so trying to run an AppImage as-is on NixOS will fail. Thankfully, Nixpkgs includes tools to handle packaging AppImages.
The basics: getting the app to launch
The first thing to do is figure out which type of AppImage Beeper is packaged as.
There are two types, and the main difference is that a type 1 AppImage is both an ELF binary and an ISO 9660 file, while a type 2 AppImage is just an ELF binary with a filesystem stuck on the end.
I can use the file
command to see which one Beeper is:
$ nix run nixpkgs#file -- beeper-3.70.17.AppImage
beeper-3.70.17.AppImage: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.18, stripped
That’s a lot of information, but the important thing is that it makes no mention of ISO 9660, so it must be a type 2 AppImage. With that, I’m ready to make the basic skeleton of a package for Beeper.
{
appimageTools,
lib,
fetchurl,
}:
appimageTools.wrapType2 rec {
pname = "beeper";
version = "3.70.17";
src = fetchurl {
url = "https://download.beeper.com/linux/appImage/x64";
sha256 = lib.fakeSha256;
};
meta = with lib; {
description = "All your chats in one app.";
homepage = "https://beeper.com";
license = licenses.unfree;
maintainers = [];
platforms = ["x86_64-linux"];
};
}
There’s nothing too different here from most Nixpkgs derivations.
The main special thing is that I’m calling appimageTools.wrapType2
to create it.
This is a wrapper around buildFHSEnv
which is used to set up an FHS chroot environment that an application can run inside.
The environment built for AppImages makes many common libraries available, ones that experience has shown AppImages may rely on.
The AppImage builder for Nixpkgs also sets the script to run inside the FHS environment to be one that can extract and execute the app inside the image.
This is likely enough in most cases to properly run an AppImage.
I’ll go on to make some extra tweaks, but the bulk of the work is already done by relying on what has already been done in Nixpkgs.
I’ll add this package to the flake I use to build my systems and try to build it with nix build
:
$ nix build .#beeper
error: hash mismatch in fixed-output derivation '/nix/store/zfl3sf8drwfc5f0i598mndmr5afj06d1-x64.drv':
specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
got: sha256-Gx7Z99+FDV8x+GJnTbVnHCPmg5YdAAkf9lXyE0lHKLc=
error: 1 dependencies of derivation '/nix/store/rn566axpzqf4dphkc2irwk116bz73zm9-beeper-3.70.17-extracted.drv' failed to build
error: 1 dependencies of derivation '/nix/store/72lz3ibdj3qzawkmam7drsdaayqmm6cb-beeper-3.70.17-init.drv' failed to build
error: 1 dependencies of derivation '/nix/store/723b1q2zbbj76gjcbcihqa3nz89kipk3-beeper-3.70.17-bwrap.drv' failed to build
error: 1 dependencies of derivation '/nix/store/8rdx06c78l1qgravmdmcp71vi9729zfx-beeper-3.70.17.drv' failed to build
Oh right, I wasn’t sure what the hash of the AppImage was, so I just put a placeholder in with lib.fakeSha256
.
I should take the actual hash given here and replace it in the src
of the derivation.
src = fetchurl {
url = "https://download.beeper.com/linux/appImage/x64";
sha256 = "Gx7Z99+FDV8x+GJnTbVnHCPmg5YdAAkf9lXyE0lHKLc=";
};
Now let me try to build the package again.
$ nix build .#beeper
$ tree ./result
./result
└── bin
└── beeper-3.70.17 -> /nix/store/7g7sg1clizimnq2n5b70722nfwzjh4ma-beeper-3.70.17-bwrap
2 directories, 1 file
Cool! The build succeeded, and if I run ./result/bin/beeper-3.70.17
, the app launches!
I could stop here, but there are some things I can improve with a little more effort.
Fixing the command name
You might have noticed that the command generated in bin
includes the version number.
I don’t want that to be the command we have to run, so let me fix that by using the extraInstallCommands
parameter for buildFHSEnv
:
{
appimageTools,
lib,
fetchurl,
}:
appimageTools.wrapType2 rec {
pname = "beeper";
version = "3.70.17";
src = fetchurl {
url = "https://download.beeper.com/linux/appImage/x64";
sha256 = "Gx7Z99+FDV8x+GJnTbVnHCPmg5YdAAkf9lXyE0lHKLc=";
};
extraInstallCommands = ''
mv $out/bin/${pname}-${version} $out/bin/${pname}
'';
meta = with lib; {
description = "All your chats in one app.";
homepage = "https://beeper.com";
license = licenses.unfree;
maintainers = [];
platforms = ["x86_64-linux"];
};
}
Now when I rebuild, the command is just called beeper
.
$ nix build .#beeper
$ tree ./result
./result
└── bin
└── beeper -> /nix/store/7g7sg1clizimnq2n5b70722nfwzjh4ma-beeper-3.70.17-bwrap
2 directories, 1 file
Enabling Wayland support
I’m using Wayland with Sway as my compositor, and one of the first things I noticed when running Beeper is that it looked very pixelated. The display on my Framework laptop is pretty high resolution, so my apps are being scaled up to 2x. This looks nice and smooth for apps running in native Wayland, but apps running through Xwayland don’t scale as well. The pixelated text is a dead giveaway that Beeper is running through Xwayland. For apps I don’t use that much or that don’t involve a lot of text, that might be okay, but for Beeper that won’t do. Thankfully, Beeper is an Electron app, and it’s pretty easy to get Electron apps to run natively in Wayland with the right command-line arguments.
I’ll extend the extraInstallCommands
and use some Nixpkgs tooling to easily wrap beeper
with a script that passes those arguments:
{
appimageTools,
lib,
fetchurl,
makeWrapper,
}:
appimageTools.wrapType2 rec {
pname = "beeper";
version = "3.70.17";
src = fetchurl {
url = "https://download.beeper.com/linux/appImage/x64";
sha256 = "Gx7Z99+FDV8x+GJnTbVnHCPmg5YdAAkf9lXyE0lHKLc=";
};
extraInstallCommands = ''
source ${makeWrapper}/nix-support/setup-hook
mv $out/bin/${pname}-${version} $out/bin/${pname}
wrapProgram $out/bin/${pname} \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform=wayland}}"
'';
meta = with lib; {
description = "All your chats in one app.";
homepage = "https://beeper.com";
license = licenses.unfree;
maintainers = [];
platforms = ["x86_64-linux"];
};
}
I added a new parameter, makeWrapper
, to the top of the file.
This package defines shell functions that you can use to make wrappers to add flags or environment variables when calling a program.
In a normal derivation, you could just add makeWrapper
to the nativeBuildInputs
and then be able to just call a function like wrapProgram
from one of the build phases.
But buildFHSEnv
and therefore appimageTools.wrapType2
don’t have the normal build inputs or phases.
This doesn’t mean I can’t use makeWrapper
; I just have to source its setup hook manually to make its functions available to us.
From there, I use wrapProgram
to add the --ozone-platform=wayland
as long as both the WAYLAND_DISPLAY
and NIXOS_OZONE_WL
are defined.
Wrapping Electron apps this way is common in Nixpkgs.
The WAYLAND_DISPLAY
is set by the compositor, but the NIXOS_OZONE_WL
one is special to NixOS.
I set it in programs.sway.extraSessionCommands
in my NixOS config so that any Electron apps that are wrapped in the same way will use Wayland automatically.
With this change, I can rebuild and run Beeper and see that it now has nice, smooth text.
Using a script to update the version
Beeper seems to update their desktop app about once a week. I’d prefer to stay running the latest version in general, but there’s an even better reason to be on the ball about updating this package for new versions.
You may have noticed the URL I’m using to fetch Beeper’s AppImage doesn’t include the version. As best as I can tell, there’s only one download URL for Beeper for Linux, and it always provides the latest version. So once Beeper releases a new version, the existing Nix package won’t build anymore on a system that doesn’t already have the AppImage downloaded and in the store. It will try to download it, find that the hash doesn’t match, and fail the build.
All that is to say: I want to be able to update my package to the latest version with as little effort as possible. Of course, since Nixpkgs is a very large repository of packages, there’s an incentive to make updating those packages as easy as possible. You shouldn’t be surprised to hear that because of this, there’s existing tooling and conventions for updating packages automatically. I can use some of that for my Beeper package.
It’s common for packages to include a passthru.updateScript
attribute if they can be updated automatically.
The value of this attribute is a script that, if the package has a newer version, will update the file with the package definition so that it builds the new version.
If you’re not familiar with passthru
, it’s a set of attributes that will be present on the final derivation, but won’t be considered part of its inputs.
That means I can change the update script as much as I like without forcing the package to be rebuilt.
Let me add an update script to Beeper, and then we’ll talk about what it’s doing.
{
appimageTools,
lib,
fetchurl,
makeWrapper,
writeShellScript,
curl,
gnugrep,
pcre,
common-updater-scripts,
}:
appimageTools.wrapType2 rec {
pname = "beeper";
version = "3.71.16";
src = fetchurl {
url = "https://download.beeper.com/linux/appImage/x64";
sha256 = "Ho5zFmhNzkOmzo/btV+qZfP2GGx5XvV/1JncEKlH4vc=";
};
extraInstallCommands = ''
source ${makeWrapper}/nix-support/setup-hook
mv $out/bin/${pname}-${version} $out/bin/${pname}
wrapProgram $out/bin/${pname} \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform=wayland}}"
'';
passthru = {
updateScript = writeShellScript "update-beeper" ''
set -o errexit
export PATH="${lib.makeBinPath [curl gnugrep pcre common-updater-scripts]}"
version="$(curl -sI -X GET https://download.beeper.com/linux/appImage/x64 | grep -Fi 'content-disposition:' | pcregrep -o1 '(([0-9]\.?)+[0-9])')"
update-source-version beeper "$version"
'';
};
meta = with lib; {
description = "All your chats in one app.";
homepage = "https://beeper.com";
license = licenses.unfree;
maintainers = [];
platforms = ["x86_64-linux"];
};
}
The package has a few new inputs for tools I use both in and to create the update script.
I use writeShellScript
to build the script.
In the script, I first set a PATH
that includes all the various commands I want to use.
I use curl
, grep
, and pcregrep
to download the latest AppImage, find the “Content-Disposition” header which contains the filename, and then extract the version number from it.
Once I have it, I can call the update-source-version
script from the common-updater-scripts
package.
This is provided by Nixpkgs to support scripts like this!
To run the script, I can run nix build .#beeper.updateScript
.
The result
symlink will then just be the script and I can run that.
$ nix build .#beeper.updateScript && ./result
error: getting status of '/home/matt/src/nix-config/default.nix': No such file or directory
error: getting status of '/home/matt/src/nix-config/default.nix': No such file or directory
error: getting status of '/home/matt/src/nix-config/default.nix': No such file or directory
update-source-version: error: Could not find attribute 'beeper'!
Well that’s not great.
One complication with update-source-version
is that it’s really meant for running in the nixpkgs
repo, not in some random flake like mine.
So it’s trying to find the beeper
package in the repo in a non-flake way, by loading default.nix
and looking it up from there.
I can fix this though!
There’s a project called flake-compat that can be used to create a default.nix
that exposes the contents of your flake in a way that non-flake Nix stuff can use it.
This seems to work well enough for my purposes.
After adding it as a flake input and creating a default.nix
like it describes in its README, building and running our update script works now!
$ nix build .#beeper.updateScript && ./result
error: attribute 'beeper' in selection path 'beeper.name' not found
update-source-version: New version same as old version, nothing to do.
Don’t mind the error; I think it’s just that update-source-version tries to find the package in a few different ways, and some of them may not work. The last message indicates that it found the package and didn’t need to do anything to update it, since it was already the current version. I can show what happens when there is an update by changing the version and hash in the package to something else first.
$ nix build .#beeper.updateScript && ./result
error: attribute 'beeper' in selection path 'beeper.name' not found
No message from update-source-version
this time, but the command exited successfully.
If I check the file where the package is defined, I’ll see that the version and hash got updated back to their current values!
This is the kind of change it will make when there’s a new version.
I can commit those changes and rebuild the system to install the new version of Beeper.
Adding a flake app to run the script
I can make running the update script a little more convenient by adding an “app” to flake.nix
.
This will give me a command I can run with nix run
.
apps.${pkgs.system}.update-beeper.program = toString (pkgs.writeShellScript "update-beeper" ''
${pkgs.lib.getExe pkgs.nix} run .#beeper.updateScript && ./result
'');
$ nix run .#update-beeper
error: attribute 'beeper' in selection path 'beeper.name' not found
update-source-version: New version same as old version, nothing to do.
Perfect. For now, Beeper is the only package in my flake that needs updating regularly. If I add more in the future, I would probably change this to be one app for updating all of them.