Running NixOS on the Librem 5

03 November 2023

I set up NixOS on my new phone, following a NixCon 2023 talk by Sophie Tauchert.

Introduction

So, I got a used Purism Librem 5. Bought it used with the grand idea of actually doing something useful: contributing to the mobile GNU/Linux ecosystem.

This was definitely inspired by Sophie Tauchert's talk at NixCon 2023, where she goes over her work and the current status. I thought, since she's already upstreamed that work to nixos-hardware, it should be easy enough to reproduce, right?

NixOS seems a good match for a mobile phone. In particular, the ability to easily rollback to a previous known-good system configuration is a key feature. Traditionally this is done at the OS-level with an A/B partitioning system, but with NixOS this works for the entire system configuration. Theoretically, should I break things while I'm tinkering, it's easy to return to a known good state.

Something I personally find important in my smartphone is stability. While I've dabbled plenty in custom ROMs, I've mostly stuck to well-supported and less flavourful distributions such LineageOS and its predecessor CyanogenMod. Ultimately I've never felt comfortable leaving my phone in an unusable state while tinkering with it, which has caused me plenty of stress while modding my phones.

Less recently, I purchased another used Android smartphone to replace my LG G3 which had served me well for almost a decade at that point. With it, I could still meet my daily needs in a smartphone, but its age was starting to show. It had undergone a motherboard replacement, consumed a handful of (removable!) batteries, and still functions normally to this day. However it struggled with some of the more intensive applications - in particular Organic Maps, it struggled to survive a full day's usage, and most importantly, custom ROM support had dried up, no longer being officially supported by LineageOS.

While I was tempted to dabble in the Android ROM development scene and bring it back to life, that seemed like a lot of work for a phone I'd most likely not use again. I can hardly hack on my daily driver, right? On top of that, I'm aware of what a mess Android distributions are. Kernel forks, non-mainlined drivers, proprietary firmware blobs. It takes a small army of volunteers to maintain all these devices - with a lot of redundant effort across ROM development groups - to keep these old devices running long after their manufacturers have dropped support.

I'm immensely grateful for all these volunteers (especially HardStyl3r, who supported the LG G3 across several versions of LineageOS), but I can't help viewing the Android ecosystem as a whole with a degree of fatalism. Google has made some attempts at improving the software support situation for Android devices, e.g. with Project Treble, but in the years since, device manufacturers haven't improved their support guarantees much. If, like myself, you choose to go through the rigmarole of granting yourself root access to fully control your device, you'll often find yourself locked out of automatic updates anyway. Being effectively punished for wanting to truly own my phone does not engender feelings of satisfaction.

This is all while ignoring the hulking elephant in the room that is Google itself, whose motives and priorities are usually misaligned with my own.

So, enter the extreme option: mobile GNU/Linux. I find this much more appealing, especially given I already use this on my desktops every day. Less proprietary, more Free, and independent from Google. Mobile GNU/Linux has picked up some momentum more recently, with manufacturers such as PINE64 and Purism releasing smartphone with (mostly) open hardware with (almost) mainline Kernel support. Minimise the work needed for each new device - now that seems like a more sustainable approach.

And so, as is commonly the case with me, I opted to act out of principle rather than practicality.

My somewhat recent delve into Nix and NixOS has given me the impression that it's a much better way to build software and GNU/Linux distributions. Theoretically, there's nothing I depend upon a smartphone for that couldn't run on GNU/Linux. Well, with the exception of my banking, but I'd happily drop my bank for one that allows me to access my account through a (relatively) more open platform like the Web.

Starting point

The phone arrived with Purism's PureOS distribution installed, version 10 (Byzantium). Full-disk encryption was enabled, with a pretty unlock screen on boot. Judging from the few megabytes of free space before the real partitions, u-boot was already installed (more on that later).

It all seemed to be working normally, though I didn't have a spare activated SIM at hand to test telephony. A deactivated one I had lying around did get picked up, but of course couldn't connect to the network. It did briefly show 3G in the status bar, which sounded promising. The kill switch also appeared to work correctly: disabling and then re-enabling the switch made the mobile network grey out and then reconnect respectively. Unfortunately, when I remove the SIM tray the phone consistently rebooted, which I suspect is an intentional hardware feature. I ordered a new SIM from a cheap UK provider and left testing telephony for later.

In Sophie's talk, she mentions the work she upstreamed into nixos-hardware, so that's where I started.

The first step mentions setting up Jumpdrive, a small system you can flash to the phone to expose its storage over USB. Given that the phone already had PureOS installed and functioning on it, I opted to skip this for now, as I could potentially install NixOS over SSH with nixos-anywhere, discussed in another NixCon 2023 talk. Fingers crossed I don't have to get into recovery mode at some point.

The next step was to install u-boot - I suppose a different version than was already installed? nixos-hardware does not expose these packages as a regular Flake would, with the instructions using the traditional nix-build instead of Flake-y nix build. Not knowing how to enable cross compilation with nix-build, I decided to make a quick and dirty Flake in my checkout of the repository:

$ git clone https://github.com/NixOS/nixos-hardware
$ cd purism/librem/5r4/
$ nix flake init
$ cat flake.nix
{
  description = "A very basic flake";

  outputs = { self, nixpkgs }: {

    packages.aarch64-linux.u-boot = let
      pkgs = import nixpkgs { system = "aarch64-linux"; };
    in pkgs.callPackage ./u-boot {};

  };
}
$ nix flake lock
$ NIXPKGS_ALLOW_UNFREE=1 nix build --impure .#packages.aarch64-linux.u-boot

I built it on my x86-64 desktop with aarch64 binfmt emulation enabled, it didn't take too long.

The result is a script which writes the built image to the root of the main disk. To get this on the device, I simply copied the script and the referenced image over WiFi onto the device, then executed it:

$ sudo TARGET=. ./u-boot-install-librem5 /dev/mmcblk0

There was some output from dd which all indicated success. After a bit of fretful procrastination, I finally rebooted the device and... was pleasantly met with the full-disk encryption prompt as normal. Success!

Building a configuration

First things first: I needed a NixOS system configuration for the phone.

I started off with the suggested approach of enabling Phosh, but quickly stumbled once I realised I had to deal with full-disk encryption, which I hadn't used before on NixOS. This is definitely a hard requirement for a smartphone personally - anyone grabbing my phone should not have immediate trivial access to it contents. The regular cryptsetup method suits me just fine, I won't worry about more in-depth systems like boot verification for now.

On NixOS, cryptsetup is normally controlled with boot.initrd.luks.devices, but I was unsure of how to actually input the decryption password. I was also unsure if simply plugging in a USB-C keyboard would work; my main keyboard does use USB-C, but it requires the hid_apple kernel module to be enabled in the initial ramdisk. It does work in the existing PureOS installation, so it was probably excessive caution. In any case, having to plug in a keyboard to boot a phone is not ideal, so I continued researching entirely sure I dug around on PureOS and discovered that it's using Plymouth with an on-screen keyboard extension, osk-sdl. An attempted package patch for nixpkgs was discarded, with Samuel Dionne-Riel mentioning the Mobile NixOS project had rejected osk-sdl.

Of course, I'd completely forgotten that the Mobile NixOS project existed. Its main goals are providing a touchscreen-based boot process, and to automatically sort out any device quirks for you. The latter is already provided by Sophie's nixos-hardware module, but the former sounds like a great solution to me.

Mobile NixOS doesn't currently have any support for the Librem 5, but I thought it shouldn't be too difficult to port it. My first evaluable system derivation is chronicled in 275174d3. I don't think I quite got the hardware config right, but I wanted to get something building first. The configuration used nixpkgs unstable, as the Mobile NixOS docs stated:

Note: Mobile NixOS is only expected to build succesfully against the unstable branch of Nixpkgs

$ nix build .#nixosConfigurations.maia.config.system.build.toplevel

The first build took quite a while, with over 300 dependencies to be built. After getting through about 1000, it finally fell over on gdm, the GNOME desktop manager. At this point my only solution seemed to be updating my nixpkgs pins. But knowing the Mobile NixOS project existed, and aarch64 was supported by NixOS, there was surely some nixpkgs commit where all these dependencies where already in the public Nix cache. And so to hydra I went, eventually finding the Mobile NixOS project, and found a recent build that evaluated the Phosh example successfulyl. In the inputs tab I found the nixpkgs commit used: 5e4c2ad.

So I updated my nixpkgs-unstable pin accordingly in 544a1ea6:

$ nix flake lock --override-input nixpkgs-unstable github:nixos/nixpkgs/5e4c2ada4fcd54b99d56d7bd62f384511a7e2593 --update-input nixpkgs-unstable

I then hit another evaluation error:

$ nix build .#nixosConfigurations.maia.config.system.build.toplevel
error: systemdStage1 cannot be found in pkgs

This confounded me for quite a while. Especially since I could find and build this in the pkgs attribute of the derivation:

nix-repl> :b outputs.nixosConfigurations.maia.pkgs.systemdStage1
:b outputs.nixosConfigurations.maia.pkgs.systemdStage1

The only useful information the trace (--show-trace) gave me was that this was a dependency of plymouth. After some unsuccessful attempts at debugging (the module system remains as opaque as ever) I eventually realised this was a problem with the NixOS system definition. It was being defined with the stable nixpkgs flake, but I was overriding pkgs to use the imported unstable nixpkgs flake. I fixed the problem in 236b756c by using the unstable nixpkgs lib instead, leaving myself a note to research how nixpkgs works in NixOS configurations.

Starting the build again, the dependencies were substituted from the cache instead of built locally! But then it hit the kernel, which I knew was going to be a little tough.

Building the kernel

The nixos-hardware repo contains a package for Purism's kernel fork for the Librem 5, so that's probably the kernel package I need to use.

This is actually the first time I've tried to build Linux itself properly, aside from when I first tried to put NixOS on a Raspberry Pi. That first attempt, I ran into disk space - in reality, /tmp space - due to the sheer quantity of code being compiled. In particular, I remember attempting to compile several GPU drivers - things that I definitely don't need on an ARM SOC. On top of that, Nix is compiling using an aarch64 emulator, which slows building down tremendously.

Last time I hit the disk space issues I also recall researching and discovering that you can direct Nix where to put the build directory, which would be a workaround. It was something like TEMP, TEMPFS, TMPFS. I try it out for a few builds but nothing seems to work - the build directories are generated in /tmp regardless.

So I first tried starting a build just to check it wouldn't immediately fail. It started building successfully, but slowly. At this point I figured I should turn on compilation caching for the derivation, in case I run into the same disk space issue. It's a bit confusing, but following the wiki and a discourse thread, I eventually got it working in ea9246cd.

Even with the compilation cache the build speed doesn't seem to be much improved, even when interrupting and re-running the start of the build. But hey, it's something, and I figure it may come in handy as a mess with the package.

The next thing I want to figure out is how to turn off those drivers I definitely don't need. Time to give nix develop a whirl:

$ nix develop .#nixosConfigurations.maia.config.system.build.kernel
$ mkdir /tmp/kernel
$ unpackPhase
unpacking source archive /nix/store/hsqvhjlchdc93mbmsaxjb7sm9vs3cks7-source
source root is source

$ cd ./source
$ patchPhase
applying patch /nix/store/23728y7zgh1jb55kpwxv5qnjbq0ykca6-randstruct-provide-seed-5.19.patch
patching file scripts/gen-randstruct-seed.sh
substituteStream(): WARNING: pattern '/bin/pwd' doesn't match anything in file 'Makefile'
substituteStream(): WARNING: pattern '/bin/pwd' doesn't match anything in file 'tools/scripts/Makefile.include'
patching script interpreter paths in scripts/ld-version.sh
scripts/ld-version.sh: interpreter directive changed from "#!/bin/sh" to "/nix/store/4qp96hwq3wqkqhv99m76g2rp8sdjcl4v-bash-5.2-p15/bin/sh"
patching script interpreter paths in scripts
...
arch/arm64/boot/install.sh: interpreter directive changed from "#!/bin/sh" to "/nix/store/4qp96hwq3wqkqhv99m76g2rp8sdjcl4v-bash-5.2-p15/bin/sh"

$ configurePhase
no configure script, doing nothing

$ buildPhase
build flags: -j12 SHELL=/bin/bash O=\$\(buildRoot\) CC=/nix/store/ahrxi3smaz2k98dsilzfjh5kbqya4bil-ccache-links-wrapper-4.8.3/bin/cc HOSTCC=/nix/store/ahrxi3smaz2k98dsilzfjh5kbqya4bil-ccache-links-wrapper-4.8.3/bin/cc HOSTLD=/nix/store/azdignpplcnd1gw87a3zb8nyabqnfqi9-binutils-wrapper-2.40/bin/ld ARCH=arm64 KBUILD_BUILD_VERSION=1-NixOS Image vmlinux modules dtbs DTC_FLAGS=-@
***
*** Configuration file ".config" not found!
***
*** Please run some configurator (e.g. "make oldconfig" or
*** "make menuconfig" or "make xconfig").
***
Makefile:770: include/config/auto.conf.cmd: No such file or directory
make: *** [Makefile:779: .config] Error 1

Okay, the build falls over immediately, meaning I've missed a step. The configuraPhase doing nothing is suspicious, as normally the sequence of unpack, patch, configure, build should Just Work. The fact that configurePhase bailed is suspicious. As I've never built the kernel manually before, I need to understand the build process before I can proceed.

I start by investigating the regular derivation with nix edit nixpkgs#linux. This opens up pkgs/os-specific/linux/kernel/mainline.nix, which looks like some unhelpful boilerplate. But the directory looks about right, so I go up a level and inspect some of the neighbouring files. In generic.nix I find more useful derivation derivation. It's more complex than most I've seen, which makes sense for the complexity of the kernel. There's a lot relating to configuration, which involves importing an auxiliary derivation in ./manual-config.nix.

It's strange that there's extra patches like this in the generic derivation, yet it doesn't seem to be applied in my source directory:

{
  # ...

  postPatch = kernel.postPatch + ''
    # Patch kconfig to print "###" after every question so that
    # generate-config.pl from the generic builder can answer them.
    sed -e '/fflush(stdout);/i\printf("###");' -i scripts/kconfig/conf.c
  '';
}

The Librem kernel derivation specifically uses the buildLinux function, so I go hunting for it. nix edit nixpkgs#buildLinux doesn't find anything. A simply search for buildLinux = finds...

{
  buildLinux = attrs: callPackage ../os-specific/linux/kernel/generic.nix attrs;
}

So I'm looking in the right place I guess.

I look at the build log of my previous attempt:

$ nix log /nix/store/7m0x5qgxw9g2xzbhvrn87izp0grnhpdx-linux-6.4.14-librem5.drv
@nix { "action": "setPhase", "phase": "unpackPhase" }
unpacking sources
unpacking source archive /nix/store/hsqvhjlchdc93mbmsaxjb7sm9vs3cks7-source
source root is source
@nix { "action": "setPhase", "phase": "patchPhase" }
patching sources
...
@nix { "action": "setPhase", "phase": "updateAutotoolsGnuConfigScriptsPhase" }
updateAutotoolsGnuConfigScriptsPhase
@nix { "action": "setPhase", "phase": "configurePhase" }
configuring
...

There's a curious build phase I've never seen before. I run it in my source tree and get no output, same as the log. But configurePhase still says no configure script, doing nothing, what gives?

configurePhase has the log message at the end:

$ type configurePhase
configurePhase is a function
configurePhase ()
{
    runHook preConfigure;
    : "${configureScript=}";
    if [[ -z "$configureScript" && -x ./configure ]]; then
        configureScript=./configure;
    fi;
    if [ -z "${dontFixLibtool:-}" ]; then
        export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
        local i;
        find -L . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
            echo "fixing libtool script $i";
            fixLibtool "$i";
        done;
        CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
        find -L . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
        rm -I -f "$CONFIGURE_MTIME_REFERENCE";
    fi;
    if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
        prependToVar configureFlags "${prefixKey:---prefix=}$prefix";
    fi;
    if [[ -f "$configureScript" ]]; then
        if [ -z "${dontAddDisableDepTrack:-}" ]; then
            if grep -q dependency-tracking "$configureScript"; then
                prependToVar configureFlags --disable-dependency-tracking;
            fi;
        fi;
        if [ -z "${dontDisableStatic:-}" ]; then
            if grep -q enable-static "$configureScript"; then
                prependToVar configureFlags --disable-static;
            fi;
        fi;
    fi;
    if [ -n "$configureScript" ]; then
        local -a flagsArray;
        _accumFlagsArray configureFlags configureFlagsArray;
        echoCmd 'configure flags' "${flagsArray[@]}";
        $configureScript "${flagsArray[@]}";
        unset flagsArray;
    else
        echo "no configure script, doing nothing";
    fi;
    runHook postConfigure
}

Which means that configureScript must be undefined, and sure enough it is. But where should it come from, and what should it be defined to.

For sanity I try a different approach, running nix develop .#nixosConfigurations.maia.pkgs.linuxPackages_librem5.kernel instead. No dice, configurePhase has the same output. Curiously this and .#nixosConfigurations.maia.config.system.build.kernel are different derivations, I'm not sure why.

At this point I give in and search up how others use Nix to hack on the kernel. I find this NixOS & Flakes Book page with a sample flake. The key difference with their approach is in their use of linuxPackages_thead.kernel.dev, the dev attribute of the derivation. I try nix develop ~/.config/nix#nixosConfigurations.maia.config.system.build.kernel.dev but there's no difference in running configurePhase.

Another thing that's confusing me is the different between the configurePhase function and $configurePhase variable:

$ echo $configurePhase
runHook preConfigure mkdir build export buildRoot="$(pwd)/build" echo "manual-config configurePhase buildRoot=$buildRoot pwd=$PWD" if [ -f "$buildRoot/.config" ]; then echo "Could not link $buildRoot/.config : file exists" exit 1 fi ln -sv /nix/store/4hvv1ywdp5k7p0mb4sd9azhywgqpb8cv-linux-config-6.4.14-librem5 $buildRoot/.config # reads the existing .config file and prompts the user for options in # the current kernel source that are not found in the file. make $makeFlags "${makeFlagsArray[@]}" oldconfig runHook postConfigure make $makeFlags "${makeFlagsArray[@]}" prepare actualModDirVersion="$(cat $buildRoot/include/config/kernel.release)" if [ "$actualModDirVersion" != "6.4.14-librem5" ]; then echo "Error: modDirVersion 6.4.14-librem5 specified in the Nix expression is wrong, it should be: $actualModDirVersion" exit 1 fi buildFlagsArray+=("KBUILD_BUILD_TIMESTAMP=$(date -u -d @$SOURCE_DATE_EPOCH)") cd $buildRoot

The variable contains that manual-config line that is in the derivation, but running it produces no output if I run $configurePhase. (set -x; $configurePhase) shows a bunch of flags being built before an exit 0.

This is driving me a bit mad. Let's try building the kernel the traditional way.

Building the kernel, take 2

Some searching brings me to the Kernel docs which link to this "admin guide". The file path and title are a bit misleading, but the breadcrumb says The Linux kernel user’s and administrator’s guide, which is a bit more accurate.

This guide soon directs you to build menuconfig, a tool for configuring the kernel. It quickly falls over due to ncurses being unavailable.

$ make O=build/kernel menuconfig
  GEN     Makefile
  HOSTCC  scripts/basic/fixdep
  HOSTCC  scripts/kconfig/mconf.o
In file included from /tmp/kernel/source/scripts/kconfig/mconf.c:23:
/tmp/kernel/source/scripts/kconfig/lxdialog/dialog.h:19:10: fatal error: ncurses.h: No such file or directory
   19 | #include <ncurses.h>
      |          ^~~~~~~~~~~
compilation terminated.
make[2]: *** [/tmp/kernel/source/scripts/Makefile.host:131: scripts/kconfig/mconf.o] Error 1
make[1]: *** [/tmp/kernel/source/Makefile:692: menuconfig] Error 2
make: *** [Makefile:226: __sub-make] Error 2

Which means I need to have ncurses as a shell input. It's rather difficult to make an impromptu shell including a particular package for development, so I think I have to bodge one into my Flake. I end up overriding my existing override for the kernel package to add the build input:

{
  # ...

  cached_linuxPackages_librem5 = (super.linuxPackages_librem5.kernel.override {
    stdenv = self.ccacheStdenv;
    buildPackages = super.buildPackages // {
      stdenv = self.ccacheStdenv;
    };
  }).overrideAttrs(old: {
    buildInputs = [super.ncurses];
  });
}

Nastier, but easier. I nix develop into the configuration attribute and now the build proceeds further, but fails with missing symbol errors:

$ make O=build/kernel menuconfig
  GEN     Makefile
  HOSTCC  scripts/kconfig/mconf.o
  HOSTCC  scripts/kconfig/lxdialog/checklist.o
  HOSTCC  scripts/kconfig/lxdialog/inputbox.o
  HOSTCC  scripts/kconfig/lxdialog/menubox.o
  HOSTCC  scripts/kconfig/lxdialog/textbox.o
  HOSTCC  scripts/kconfig/lxdialog/util.o
  HOSTCC  scripts/kconfig/lxdialog/yesno.o
  HOSTCC  scripts/kconfig/confdata.o
  HOSTCC  scripts/kconfig/expr.o
  LEX     scripts/kconfig/lexer.lex.c
  YACC    scripts/kconfig/parser.tab.[ch]
  HOSTCC  scripts/kconfig/lexer.lex.o
  HOSTCC  scripts/kconfig/menu.o
  HOSTCC  scripts/kconfig/parser.tab.o
  HOSTCC  scripts/kconfig/preprocess.o
  HOSTCC  scripts/kconfig/symbol.o
  HOSTCC  scripts/kconfig/util.o
  HOSTLD  scripts/kconfig/mconf
/nix/store/as2izmyw2771n7p60nlm0f6a84kmnyin-binutils-2.40/bin/ld: scripts/kconfig/mconf.o: in function `show_help':
mconf.c:(.text+0xa18): undefined reference to `stdscr'
/nix/store/as2izmyw2771n7p60nlm0f6a84kmnyin-binutils-2.40/bin/ld: mconf.c:(.text+0xa20): undefined reference to `stdscr'
...

I took a brief diversion to test whether this is an issue with cross-compiling by testing out building the kernel on the native x86-64 architecture. I added the kernel to my Flake's packages like so:

{
  # ...

  linuxPackages_librem5 = pkgs.cached_linuxPackages_librem5;
}

I can't even open the dev shell however, because the config dependency fails to build:

$ nix develop ~/.config/nix#linuxPackages_librem5
warning: Git tree '/home/william/.config' is dirty
error: builder for '/nix/store/m2wx8d43gnqhjg9k4khwpdvy97224gfj-linux-config-6.4.14-librem5.drv' failed with exit code 2;
       last 10 log lines:
       >   HOSTCC  scripts/kconfig/symbol.o
       >   HOSTCC  scripts/kconfig/util.o
       >   HOSTLD  scripts/kconfig/conf
       > ***
       > *** Can't find default configuration "arch/x86/configs/librem5_defconfig"!
       > ***
       > make[2]: *** [../scripts/kconfig/Makefile:94: librem5_defconfig] Error 1
       > make[1]: *** [/build/source/Makefile:692: librem5_defconfig] Error 2
       > make: *** [Makefile:226: __sub-make] Error 2
       > make: Leaving directory '/build/source'
       For full logs, run 'nix log /nix/store/m2wx8d43gnqhjg9k4khwpdvy97224gfj-linux-config-6.4.14-librem5.drv'.
error: 1 dependencies of derivation '/nix/store/s05nyymk5bsf7gmxfzl1mbas4y7m23lr-linux-6.4.14-librem5-env.drv' failed to build

Clearly the configuration file is only specified for the aarch64 architecture, and sure enough I find it under arch/arm64/configs/librem5_defconfig. This does complicate things, if I want to build this package I'll have to patch the package. I tried to extend postPatch in my overlay:

{
  # ...

  cached_linuxPackages_librem5 = (super.linuxPackages_librem5.kernel.override {
    stdenv = self.ccacheStdenv;
    buildPackages = super.buildPackages // {
      stdenv = self.ccacheStdenv;
    };
  }).overrideAttrs(old: {
    buildInputs = [super.ncurses];
    postPatch = old.postPatch + ''
      cp arch/arm64/configs/librem5_defconfig arch/x86/configs/
    '';
  });
}

But this failed the same as before. Apparently this isn't enough to override the config derivation.

I do a bit more reading and digging around, and find out that these config values are "tristate", being either y (yes, on), no (no, off), or m (module, as in loaded separately at runtime as a kernel module). I find the appropriate config in drivers/gpu/drm/amd/amdgpu/Kconfig: DRM_AMDGPU. So I want to set CONFIG_DRM_AMDGPU=n somehow.

At this point I noticed something in the generic Linux derivation:

{
  # ...

  # Adds dependencies needed to edit the config:
  # nix-shell '<nixpkgs>' -A linux.configEnv --command 'make nconfig'
  configEnv = kernel.overrideAttrs (old: {
    nativeBuildInputs = old.nativeBuildInputs or [] ++ (with buildPackages; [
      pkg-config ncurses
    ]);
  });
}

permalink

This... sounds like exactly what I need. I couldn't find it in the system.build.kernel attribute, but it is in the package proper:

$ nix develop ~/.config/nix#nixosConfigurations.maia.config.system.build.kernel.configEnv
$ make V=1 O=build/kernel nconfig

Huzzah! I get a beautiful ncurses GUI.

In this GUI I can load the Librem 5 config file, and navigating to Device Drivers > Graphics support I do indeed see the AMDGPU and Radeon drivers enabled. I try saving my configuration to compare it to the existing one, but wherever nconfig is saving them, it isn't in the working directory. Turns out it was in the build directory, because I was running it with make. The new config is very different to the existing one, being more verbose and thus less diffable. But I do find this in it:

# CONFIG_DRM_AMDGPU is not set

I guess it's off by default? I don't really get why it's being built then. On another note, I find the NixOS package has the argument structuredExtraConfig, which lets you specify config overrides. Handy. I think I'll give that a shot and give up on building the kernel manually.

{
  # ...

  cached_linuxPackages_librem5 = super.linuxPackages_librem5.kernel.override {
    stdenv = self.ccacheStdenv;
    buildPackages = super.buildPackages // {
      stdenv = self.ccacheStdenv;
    };
    structuredExtraConfig = {
      CONFIG_DRM_AMDGPU = "n";
      CONFIG_DRM_RADEON = "n";
      CONFIG_DRM_NOUVEAU = "n";
    };
  };
}

Okay, let's give building another shot... once I figure out how to change the build directory.

Changing the build directory

Going off my previous knowledge, I search for TMPDIR in the NixOS Matrix rooms and find this message in the NixOS ARM Matrix room. Turns out I was right, that was the environment variable I needed, but the key thing I missed was that this doesn't affect builds run through the Nix daemon. This includes building as my unprivileged user with Nix installed in multi-user mode.

A workaround therefore, is to run the build as root. Not ideal, but it'll do for now:

$ sudo TMPDIR=/data/random/nix nix build ~/.config/nix#nixosConfigurations.maia.config.system.build.kernel
$ ls /data/random/nix
nix-build-linux-6.4.14-librem5.drv-0

Finally! Now, time to wait for the build to complete.

Halfway through I dig into the build directory and find the config file under /source/build/.config. That the AMDGPU etc. options are still set to m, hmm. I inspect the Librem buildLinux call and see that they don't have the CONFIG_ prefix. Whoops. Time to try again. Hopefully that build cache works.

{
  # ...

  cached_linuxPackages_librem5 = super.linuxPackages_librem5.kernel.override {
    stdenv = self.ccacheStdenv;
    buildPackages = super.buildPackages // {
      stdenv = self.ccacheStdenv;
    };
    structuredExtraConfig = {
      DRM_AMDGPU = "n";
      DRM_RADEON = "n";
      DRM_NOUVEAU = "n";
    };
  };
}

I try again but the config file is wrong, again.

$ nix eval ~/.config/nix#nixosConfigurations.maia.config.system.build.kernel.structuredExtraConfig
warning: Git tree '/home/william/.config' is dirty
{ CMA_SIZE_MBYTES = { _type = "override"; content = { freeform = "320"; optional = false; }; priority = 50; }; }

This is clearly wrong - somehow my override isn't being applied.

I faff around a bunch with builtins.trace, during which I find stdenv is being overridden correctly. I eventually notice some weirdness around the Librem kernel derivation involving overrides, and particularly this comment:

  structuredExtraConfig = with lib.kernel; {
    # buildLinux overrides this and defaults to 32, so go back to the value defined librem5_defconfig
    # this is required for millipixels to take photos, otherwise the VIDIOC_REQ_BUFS ioctl returns ENOMEM
    CMA_SIZE_MBYTES = lib.mkForce (freeform "320");
  };

So they had to perform some hijinks to force an option value. Thankfully they left in a workaround:

{ lib
, buildLinux
, fetchFromGitLab
, ...
} @ args:
buildLinux (args
  // rec {
  defconfig = "librem5_defconfig";
  version = "6.4.14-librem5";
  modDirVersion = version;
  src = fetchFromGitLab {
    domain = "source.puri.sm";
    owner = "Librem5";
    repo = "linux";
    rev = "pureos/6.4.14pureos1";
    hash = "sha256-PzRG6czWLMahklceuaWGK1QJ+m9FAKDa/m1jp87h62k=";
  };
  kernelPatches = [ ];
  structuredExtraConfig = with lib.kernel; {
    # buildLinux overrides this and defaults to 32, so go back to the value defined librem5_defconfig
    # this is required for millipixels to take photos, otherwise the VIDIOC_REQ_BUFS ioctl returns ENOMEM
    CMA_SIZE_MBYTES = lib.mkForce (freeform "320");
  };
}
  // args.argsOverride or { })

I try using argsOverride instead:

{
  # ...

  cached_linuxPackages_librem5 = (super.linuxPackages_librem5.kernel.override (old: {
    stdenv = self.ccacheStdenv;
    buildPackages = super.buildPackages // {
      stdenv = self.ccacheStdenv;
    };
    argsOverride.structuredExtraConfig = builtins.trace "structuredExtraConfig" (with super.lib.kernel; {
      DRM_AMDGPU = no;
      DRM_RADEON = no;
      DRM_NOUVEAU = no;
    });
  }));
}

And it works! The configuration rebuilds itself, then the full kernel build begins proper. It's noticeably slower than before. Inspecting the compiler cache, I can see a tiny number of hits:

$ nix run nixpkgs#ccache -- -s -d /data/random/ccache/
Cacheable calls:    1037 / 1766 (58.72%)
  Hits:               10 / 1037 ( 0.96%)
    Direct:           10 /   10 (100.0%)
    Preprocessed:      0 /   10 ( 0.00%)
  Misses:           1027 / 1037 (99.04%)
Uncacheable calls:   729 / 1766 (41.28%)
Local storage:
  Cache size (GiB):  3.7 /  5.0 (74.64%)
  Hits:               10 / 1037 ( 0.96%)
  Misses:           1027 / 1037 (99.04%)

A shame, but fingers crossed the build finishes this time. The first attempt failed because my root disk filled up while the result was being copied to the Nix store. After a nix store gc and cleanup, it succeeds! Thankfully the compilation cache sped things up quite a bit on the second attempt.

Now that I have a built system configuration, time to figure out how to install it.

Installing

The nixos-hardware README suggests flashing Jumpdrive to mount the internal storage on another device, but I'd prefer to avoid flashing if possible.

Trying out nixos-anywhere and disko

So I go back and look into nixos-anywhere a bit more deeply. The quickstart guide looks pretty simple: build a NixOS configuration, then give it an SSH connection and it does all the rest.

Of course, it wasn't going to be that simple. I already had a building configuration, so I tried the nixos-anywhere command immediately. This first attempt was met with

error: flake 'git+file:///home/william/.config?dir=nix' does not provide attribute 'packages.x86_64-linux.nixosConfigurations."maia".config.system.build.diskoScript', 'legacyPackages.x86_64-linux.nixosConfigurations."maia".config.system.build.diskoScript' or 'nixosConfigurations."maia".config.system.build.diskoScript'

So clearly I'd missed a step. And indeed an earlier step mentions including the disko module for automatic disk partitioning. Problem is, the phone's disk was already reasonably partitioned, so I'd really prefer if I could skip the partitioning step.

After a bit of digging around I failed to find this as an option, so I gave it and gave disko a shot. I do quite like the idea of declarative partitioning, but it's rather disconcerting off the bat - when exactly does the partitioning take place? What if I add more partitions in the future - will it reformat everything automatically? A bit of reading around suggests it's only done with the nixos-anywhere command or manually with some generated scripts. Okay, fine, let's convert the existing configuration.

purism@pureos:~$ sudo fdisk -l
Disk /dev/mmcblk0: 29.12 GiB, 31268536320 bytes, 61071360 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x2c38c7d5

Device         Boot  Start      End  Sectors  Size Id Type
/dev/mmcblk0p1 *     10240   962559   952320  465M 83 Linux
/dev/mmcblk0p2      962560 61071326 60108767 28.7G 83 Linux


Disk /dev/mmcblk0boot0: 4 MiB, 4194304 bytes, 8192 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes


Disk /dev/mmcblk0boot1: 4 MiB, 4194304 bytes, 8192 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes


Disk /dev/mtdblock0: 192 KiB, 196608 bytes, 384 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes


Disk /dev/mtdblock1: 1.81 MiB, 1900544 bytes, 3712 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes


Disk /dev/mapper/crypt_root: 28.65 GiB, 30758911488 bytes, 60075999 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes


Disk /dev/zram0: 1.45 GiB, 1555038208 bytes, 379648 sectors
Units: sectors of 1 * 4096 = 4096 bytes
Sector size (logical/physical): 4096 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes

What I ended up with is in b5707ab6. As expected, figuring out the edge cases was a bit weird. Not knowing too much about disk partitioning made things harder to work out.

For the LUKS setup, there were plenty of examples to look at, but I couldn't quite figure out how to set the cipher for the disk declaratively. Turns out the extraFormatArgs option lets you do that. I really missed having something like the NixOS options search to explore the option. Tab completion in the nix repl really doesn't cut it, especially with heavy usage of submodule option types in the disko module. The lack of option documented is something the disko project is aware of, but the examples don't quite cover this case. Mental note: explore a Nix language server as mentioned in this NixCon 2023 talk.

The other thing I found difficult to work out was the DOS mode (aka. MBR/BIOS mode?), as the fdisk output includes Disklabel type: dos. I do recall this when installing Arch Linux manually - GPT is the more modern thing relating to the UEFI boot mode. Searching through the disko source, I sometimes saw msdos besides gpt. So I thought I simply had to change the type: "gpt" in the main disk attrset to msdos. That failed to build, so I eventually found this "compat" example for supporting both boot modes. This looks similar to the existing partition scheme, with a traditional boot EFI partition. The boot partition notably leaves some free space before it, 10240 bytes, for the MBR and u-boot I expect. In the disko example, it leaves just one MB. The Librem README suggests 2MB, but I double it to 4MB to be on the safe side.

I also found this "standalone" example which uses BIOS mode only, but this is quite different from the existing scheme, so I ignored it. It did lead me to research the compat mode however.

I then built the diskoScript attribute directly to test that out, but ran into a ShellCheck build error. Good to see they're checking their scripts, but apparently I've run into something which the project doesn't test for. I cloned the project locally, fixed it, and locked my flake to use the local checkout instead as a quick workaround. I later raised a quick patch, which was very promptly merged.

The nixos-anywhere guide mentions the --build-vm option for testing out the configuration. I did try running this with nixos-anywhere --flake .#maia --vm-test. Unfortunately it to a very long time, and I eventually aborted it. It seems to involve booting NixOS several times, I assume once with an initial NixOS image to partition the disk, then again once everything's installed. The configuration being an emulated aarch64 definitely contributed to it being so slow.

I'm still not sure if I got the partitioning right, since I couldn't build the VM.

Failed attempt:

...
+ /root/kexec/kexec/kexec --load /root/kexec/kexec/bzImage --kexec-syscall-auto --initrd=/root/kexec/kexec/initrd --no-checks --command-line init=/nix/store/bn1085m7ssvj31a065krrhn827sxz2fq-nixos-system-nixos-23.05pre-git/init console=tty0 console=ttyAMA0,115200 console=ttyS0,115200 loglevel=4
Can't open (/proc/kcore).
Warning, can't get the VA_BITS from kcore
Can't open (/proc/kcore).
kexec_load failed: Function not implemented
entry       = 0x575e46a0 flags = 0xb70000
nr_segments = 4
segment[0].buf   = 0xffff7aa17100
segment[0].bufsz = 0x379d200
segment[0].mem   = 0x41000000
segment[0].memsz = 0x3860000
segment[1].buf   = 0xffff67c83170
segment[1].bufsz = 0x12d7571c
segment[1].mem   = 0x44860000
segment[1].memsz = 0x12d76000
segment[2].buf   = 0xffff67c67190
segment[2].bufsz = 0xda37
segment[2].mem   = 0x575d6000
segment[2].memsz = 0xe000
segment[3].buf   = 0xffff67c621b0
segment[3].bufsz = 0x3558
segment[3].mem   = 0x575e4000
segment[3].memsz = 0x4000
+ echo kexec failed, dumping dmesg
...

After the fact I find an (as of writing) open PR to explicitly skip the disko partitioning, which I think is a step in the right direction.

With Jumpdrive

Initially tried building it myself following the repository's instructions. But I quickly ran into issues when compiling the arm-trusted-provider dependency of u-boot. These (as far as I can tell) incorrect errors about dereferencing likely-zero pointers, but these were hard-coded addresses which definitely weren't 0. I overrode build flags in the Makefile, setting CFLAGS=-Wno-error. But it failed anyway, with an error I couldn't understand.

So I tried replacing it with the u-boot from the package in the nixos-hardware repo, moving things around to more or less reproduce the layout the uuu script expected.

I had to try a different USB-C cable before uuu detected the device. And also sudo, I think. This seemed to work until it got to an unzip command to extract the kernel. After several attempts, I give up and consider my bodge a failure, possibly due to the version of u-boot I flashed. Then I realised Jumpdrive already provides pre-built archives for the Librem 5, so I just used that. And it actually worked this time.

Thankfully Jumpdrive doesn't overwrite the internal partitions, it simply uploads and boots a tiny GNU/Linux operating system, exposing the internal storage over USB without touching it.

Cool, the internal storage of the device now shows up as /dev/sdc or /dev/sdd as expected. I run the regular nixos-install command to install the built configuration:

$ nix build .#nixosConfigurations.maia.config.system.build.toplevel --out-link configurations/maia/result
$ sudo cryptsetup open /dev/sdc2 phone_root
...
$ sudo mount /dev/mapper/phone_root /mnt
$ sudo mount /dev/sdc1 /mnt/boot
$ sudo nixos-install -v --system ./configurations/maia/result --root /mnt
...
Warning: do not know how to make this configuration bootable; please enable a boot loader.
/nix/var/nix/profiles/system/sw/bin/bash: line 10: umount: command not found

Guess I messed up the bootloader config.

It was quite confusing to figure out what I should put into /boot. It's not done automatically by nixos-install. I tried enabling GRUB with boot.loader.grub.enable = true, but that rendered the device unbootable.

There's a lot of mention of the special stage-1 for Mobile NixOS, but little mention of how to actually install it. E.g. going off the Pinephone README, the options mentioned are 1) flashing the installer image, and 2) flashing a full disk image. There's a mention of flashing only the boot partition, but the link is dead.

I find this issue which mentions a boot-partition attribute. That attribute no longer exists, but I find similar ones by poking into the system configuration:

$ nix build .#nixosConfigurations.maia.config.mobile.outputs.u-boot.boot-partition
warning: Git tree '/home/william/.config' is dirty
error: cannot coerce null to a string

       at /nix/store/fkf6sxbwp0phwbrlw7n8bj8s5lpl6y3k-source/modules/system-types/u-boot/default.nix:13:18:

           12|   kernel = stage-0.mobile.boot.stage-1.kernel.package;
           13|   kernel_file = "${kernel}/${if kernel ? file then kernel.file else pkgs.stdenv.hostPlatform.linux-kernel.target}";
             |                  ^
           14|   inherit (config.mobile.generatedFilesystems) rootfs;
(use '--show-trace' to show detailed

Promising, but the kernel is missing. This must be set especially for the device's mobile config. I find it in the Pinephone config, so I must have to do this manually too:

  mobile.boot.stage-1.kernel.package = pkgs.cached_linuxPackages_librem5;

And now the image builds. An ext4 image, fine. I write it with the suggested command:

sudo dd if=result of=/dev/<disk>1 bs=8M oflag=sync,direct status=progress

Moment of truth - I reboot, and... it works! I see a boot log! And eventually, a decryption prompt!

Sadly the touchscreen keyboard doesn't work. Or a keyboard for that matter, not sure why that's the case. The prompt acts as if I pressed enter when I press the power button, funny.

Digging into the boot log through the low-tech method of recording it with another phone, I see lots of errors. Right at the start is a bit suspect:

Running Tasks::Environment...
/proc/mounts: _get_sysfs_dir fopen failed: No such file or directory

That's followed by a bunch of modprobe errors, including core modules like devpts, devtmpfs, proc, tmpfs, sysfs, dm_mod. All of the errors say not found in directory /lib/modules/6.4.14-librem5. Quite strange, sounds like no modules are available?

I tried comparing the kernel configuration in Mobile NixOS to the config for my kernel. I noticed some possibly related config options, so added them to my kernel's config overrides:

  cached_linuxPackages_librem5 = (super.linuxPackages_librem5.kernel.override (old: {
    stdenv = self.ccacheStdenv;
    buildPackages = super.buildPackages // {
      stdenv = self.ccacheStdenv;
    };
    argsOverride.structuredExtraConfig = with super.lib.kernel; {
      DRM_AMDGPU = no;
      DRM_RADEON = no;
      DRM_NOUVEAU = no;
      KERNFS = yes;
      SYSFS = yes;
      TMPFS = yes;
    };
  }));

And after a long kernel recompilation, I flashed the boot image again, and... no change.

Clearly something else was going wrong. So I assume kernel modules live in the initial ramdisk, so I tried building that separately and extracting it. After a bunch of reading of the mobile-nixos source, I eventually found the attribute for it:

$ nix build .#nixosConfigurations.maia.config.system.build.initialRamdisk
$ file result/initrd
result/initrd: gzip compressed data, max compression, from Unix, original size modulo 2^32 43448320

I extracted this file, which produced this a CPIO archive, and doing some quick searching found out how to extract it:

$ file /tmp/initrd
/tmp/initrd: ASCII cpio archive (SVR4 with no CRC)
$ cpio -idm -F /tmp/initrd
84850 blocks
$ tree -L 2
.
├── applets
│   ├── boot-error.mrb -> /nix/store/w6jryprm0r9q64c9jb91zbzj94dkpc2h-boot-error.mrb/libexec/boot-error.mrb
│   ├── boot-selection.mrb -> /nix/store/4rsrp0flb19jvm3wzm8m50xmvkl6khbf-boot-recovery-menu.mrb/libexec/boot-recovery-menu.mrb
│   └── boot-splash.mrb -> /nix/store/wni188k1gghr8rf4c1r6lp9gpayzd2mw-boot-splash.mrb/libexec/boot-splash.mrb
├── bin
│   └── sh -> /nix/store/6rpafppbl0y5i7j0j3xszy173iqx7isg-extra-utils-purism-librem5-extra-utils/bin/sh
├── dev
├── etc
│   ├── boot
│   ├── logo.svg -> /nix/store/y9nh2w9bqnl2h9fwdv5wci8hqpc2wj70-logo.white.svg
│   ├── udev
│   └── X11 -> /nix/store/cng1lrqy9d11x2w81fdvv4dpkwvf76cb-minimalX11Config
├── init -> /nix/store/sxf72hnc9kqrrw5ndz12cqz4cqakz3h0-init-wrapper
├── init.mrb -> /nix/store/azq5psyan11i8ly2n9r5racdh6hs5m5n-mobile-nixos-init-0.1.0/libexec/init.mrb
├── lib
│   ├── firmware -> /nix/store/fkvh8bmwyccylc540vvyk2h00kfjphbd-firmware/lib/firmware
│   └── modules -> /nix/store/bjkk524f7d3yphibd9kdd8z0w04acirc-null-modules/lib/modules
├── loader -> /nix/store/6rpafppbl0y5i7j0j3xszy173iqx7isg-extra-utils-purism-librem5-extra-utils/bin/loader
├── nix
│   └── store
├── proc
└── sys

So, lib/modules sounds like the cause of the problem. It points to ...-null-modules/, which both sounds wrong and is empty. Some more digging in the mobile-nixos sources, I found the null-modules thing is related to the mobile.boot.stage-1.kernel.modular option, which for some reason is false by default. If I set it to true, the build log suggests some modules are actually being built. I also try mobile.boot.stage-1.useNixOSKernel = true;, since I am, after all using a kernel from the regular NixOS configuration. Unfortunately, the same errors occur, and the touchscreen still isn't usable.

However... plugging in a USB-C keyboard actually lights up the keyboard and notification light. I think the USB drivers are working now, but I still can't type. I try a phone dock, but it's the same story. But I give it one last shot, typing with the dock as the phone boots, and it works! I enter the encryption key and it proceeds to NixOS stage 2. Things look good until it gets to the systemd "Show Plymouth Boot Screen" service, which seems to get stuck . Then it leaves me with the following:

Cannot open access to console, the root account is locked.
See sulogin(8) man page for more details.

Still, this is promising. I'd like to at least get the phone booting, even if the touchscreen unlock isn't currently working.

During the boot process, I see an error when systemd tried to mount /boot:

couldn't mount because of unspported optional features (40)

Turns out it's because the filesystem from the generated boot partition is ext4, not ext2. Whoops. Amended, re-ran nixos-install, and finally... it boots to the UI!

It looks very similar to how it looked on PureOS. I swipe up to the unlock prompt... and realise I never set the password. One final reflash to Jumpdrive, nixos-enter, passwd, and a reboot, and finally I can unlock the device into the Phosh desktop.

Fixing the touchscreen in stage 1

The touchscreen not working for the unlock prompt was a rather large usability issue - I didn't want to keep plugging in a USB keyboard and hoping that the appropriate keyboard driver would actually be loaded in time.

I assumed this was a case of the necessary kernel module not being loaded in the initramfs.

In some initial research, I came across the Multitouch displays page on the ArchWiki. This one mentions the hid_multitouch module, but this one isn't loaded on the booted device, according to lsmod. The Touchscreen page mentions cating the /dev/inputs/event* files while touching the screen until you find the one for the touchscreen. Unfortunately I forgot how these special files work and used tail -f on them, and confused myself by finding that none of them produced output.

Thankfully I had more luck with the other method, reading /proc/bus/input/devices. None of the entries have "touch" in the name, but this one stuck out with the obscure name and the fact is has a mouse output:

I: Bus=0018 Vendor=0000 Product=0000 Version=0000
N: Name="EP0700M09"
P: Phys=
S: Sysfs=/devices/platform/soc@0/30800000.bus/30a40000.i2c/i2c-2/2-0038/input/input5
U: Uniq=
H: Handlers=event5 mouse1
B: PROP=2
B: EV=b
B: KEY=400 0 0 0 0 0
B: ABS=260800000000003

A quick search for EP0700M09 brought up a schematic from the "Emerging Display Technologies Corp", so this must be the display. And indeed cat /dev/input/event5 did show events while touching the screen. And finally, after searching "linux find module for device", this answer led to:

$ udevadm info -a -n /dev/input/event5|ag DRIVERS
    DRIVERS==""
    DRIVERS=="edt_ft5x06"
    DRIVERS==""
    DRIVERS=="imx-i2c"
    DRIVERS==""
    DRIVERS==""
    DRIVERS==""

edt = Emerging Display Technologies? That sounds about right.

I added edt_ft5x06 to boot.initrd.kernelModules and rebooted, and found I could finally type on the on-screen keyboard!

First impressions of Mobile NixOS

I didn't have to go through the fancy setup that PureOS had - unlocking immediately showed the Phosh UI. Said UI looks very similar to the PureOS version, I guess not much has changed in recent updates.

There's notably fewer apps preinstalled, really the bare minimum. A dialer, texts, browser (Epiphany), terminal, and camera app (Megapixels).

I also installed a few more applications from the GNOME core suite with my home manager configuration.

I'll intersperse this section with some pretty screenshots.

Mobile features

Obviously one of the primary functions of a phone is texting and calling, so I wanted to test those out. At this point I'd received a SIM from one of the "cheaper" pay-as-you-go providers in the UK. Annoyingly the phone once again rebooted when I inserted the SIM card - this may be a hardware "feature" in the end. However after rebooting, I was quite surprised to almost immediately receive a text from the provider with registration instructions - that meant the modem automatically registered itself as you'd expect. Interestingly the settings defaulted to 2G & 3G only, and I had to manually enable 2G, 3G, and 4G. Reception seemed reasonable for being indoors.

Mobile data took a bit more work, requiring me to manually set up the Access Point Names, by searching up my provider's details on another computer. After setting up the APNs, the 4G icon popped up and I could load websites at what seemed like reasonable speeds. I didn't spend too long testing it because of the overpriced mobile data usage costs.

I then tested sending a text to my primary phone, which worked perfectly fine. Phone calls were less functional, I couldn't hear my voice on the Librem nor my Android phone, no matter what audio devices I selected on the Librem. Interestingly if I selected the Analog Output - Modem device, opened the regular GNOME sound test menu, and clicked the front left speaker, I heard it on the other side of the call! That seems a bit backwards, but whatever. I didn't want to spend too long on the initial test.

Quick aside/rand about UK providers: that single text and a sub 3 minute call cost me a whole 78 pence! That's 10p for the text, and 25p per minute. I'd decided to put just £10 on a pay-as-you-go SIM because I didn't expect to be using it enough to justify a £10/mo contract, but I'm wondering just how long that'll last now. Phone carriers continue to be complete rip-offs, I see. I'll probably look for a better value PAYG SIM after I've exhausted this credit.

Phosh

Phosh's UI borrows heavily from Android et al., which is perfectly reasonable, and it looks pretty slick by itself. It feels smooth to swipe around, which is naturally an important feature for a touchscreen interface. I think it looks pretty slick too, and it even makes use of your GTK theme, allowing for some customisability.


  A familiar phone lockscreen UI, with a clock and calendar in the center, and a
  'swipe up to unlock' prompt.
  The top bar has several indicators including for WiFi, Bluetooth, and battery
  indicator.
A reasonably contemporary design for a smartphone lockscreen, if minimal.

  A keypad prompting the user to 'Enter Passcode', with a big unlock button at the
  bottom of the screen.
Similar story for the unlock screen.

One thing really irks me with Phosh's unlock screen, through no fault of its own. Android has conditioned me to expect the "enter" button in the bottom right of the keypad. However, in Phosh this is where the backspace button lives, and the unlock button instead lives at the bottom of the screen. I would love if this were configurable for poor Android users like myself.

Another issue I've noticed is that you can swipe down on the keypad to return to the lock screen, which I've accidentally triggered multiple times while typing in my passcode. A simple solution would be adding an element behind the keypad which consumes touch inputs, and have the keypad buttons themselves consume the swipe inputs.


  The main Phosh app drawer, showing a row of open app windows along the top,
  and a grid of app icons at the bottom, with a search bar in between.
The main Phosh UI after unlocking the screen.
Reminds me of a combination of the Android recent apps screen and app drawer.

An interesting choice was to combine the recent apps with the app drawer. I don't particularly disagree with this design, but I think it'll take some time for me to get used to it. There are some minor things that break my expectations, one of them being swiping down on an app does not bring it into the foreground, it instead hides the drawer to reveal the app that was previously open.

Another fun one is the bottom bar that indicates that you swipe up to open the drawer again. This is pretty handy, and they've included a little button in the corner to toggle the keyboard, which may be necessary for less touch-compatible applications. An oversight is that the keyboard button isn't available in the drawer itself, so if you search for applications, you can't close the keyboard again.


  A phone notification drawer, from top to bottom, left to right:
  A padlock icon in the top left, the date & time in the center, and a power icon
  in the top right.
  Two horizontal sliders for brightness and volume, the latter has an expansion
  panel.
  A grid of quick settings, including cellular, WiFi, Bluetooth, battery,
  rotation, notifications, and a torch.
The notifications panel, which features familiar Android style quick options, including sliders for volume and brightness.

  The same notification drawer, but with the audio panel expanded. It shows both
  input and output devices and allows swapping between them.
Screenshot of the same notifications panel, but with the audio selector expanded to show options for changing audio devices.

The notifications / quick options panel also looks pretty slick and heavily inspired by Android. Quick settings are a must-have for phones in my opinion, and it's a nice touch to include the GNOME audio device selector. Audio device selection can be finicky on Android - on my previous Lineage OS phone it was easily accessible by expanding the volume indicator, but Samsung in their infinite wisdom decided to implement this as a separate application, opened from the quick panel. Just that second it takes to cold launch irks me.

There are some fairly simple oversights as of writing. One such oversight is long pressing on the quick options to open the related menu in settings. However, doing so 1) doesn't hide the app drawer if it's open already, and 2) doesn't focus the settings application if it's not already focused.

While the audio device selector is super handy, it doesn't reset when you close the notification panel, so it will be open when you slide the notification panel down again. I imagine most users won't like having its state persisted like this.


  A popup on top of the WiFi menu, titled 'Turn On Wi-Fi Hots...'.
  In the top bar there's a cancel button on the left, and 'turn on' button on
  the right.
  The main body is a form allowing you to set a hotspot name and password.
The WiFi hotspot GNOME popup, which works okay in Phosh.

  A popup on top of the WiFi menu.
  This is the connection settings menu, which is far too wide for the phone
  screen, so only the center is visible. It has tabs for the different sections,
  including Identity, IPv4, IPv6, and Security.
  The main form has a bunch of options which are pretty unintelligible due to
  being cropped on the small screen.
The WiFi connection settings GNOME popup, which does not work well.

Popups are something Phosh hasn't quite worked out. Of course, these are designed for a desktop, where having windows overlap is commonplace. But this model doesn't work for a window manager which expects only one window to be visible at a time. How this currently works is that the popup simply displays on top of the current window, and it's just luck whether that particular menu will fit on the screen. The popup displays as its own entry in the "open app list", so you can swipe it away to close it if you can't reach a close button. The first issue is the fundamental behaviour of popups. If we consider Android, a popup menu will take over the whole screen, dimming what's in the background. To close it, one can usually tap outside of the popup, or use the back button / gesture. That back button is missing in Phosh; the bottom bar only has a swipe up to access the app drawer and window list, and a keyboard button in the bottom right.

The issue of overflowing menus is a much larger one to tackle. A lot of menus are designed with desktop aspect ratios in mind, and certainly not for 200% resolution scaling on such small screens. A lot of effort has been put into making some GNOME applications - particularly the settings - reactive to thinner screens. In some places like the display settings, this reactivity is buggy, in this case the menu seems to be fighting its own layout and jumping around every repaint.
Other menus have been left completely neglected, such as the WiFi connection settings. This one uses a tabbed layout to separate the separate sub-menus. A generic solution might perhaps be automatically replacing this tabbed menu with a vertical sub-menu system, where each tab is its own menu entry. Similarly, instead of a sub-menu, the tab entries could expand vertically to reveal its sub-menu.
But another problem with this connection settings menu is the multi-column layout used to spread options horizontally. Making this layout dynamic won't be trivial.

I've never worked with GTK to this level, let alone GNOME in particular, so I can't say just how easy these things would be to implement. But I'll give it a try anyway. A lot of people have put a lot of hard work into this project already, and I'd like to help if possible. Given how the window system is the very fundamental to the user experience of a touchscreen device, getting it right will do wonders for the usability of mobile GNU/Linux. Expectations have been set by the Android and iOS operating systems, which have both seen decades of development in touchscreen UX. While there are many valid complaints to make about Apple and Google's design choices in places, it's safe to say that they are both setting the bar, and it's been set quite high. Ultimately, we've all been using these phone operating for years now, so we're all quite accustomed to their patterns. It's probably best to start with the familiar before making opportunistic improvements where appropriate.

Epiphany

The app initially refused to launch, dying immediately. Running it through the terminal, it complained about not being able to write to ~/.local/state. For some reason, (possibly from nixos-install?) this was owned by root. I chown -Red the directory to resolve that.


  A browser window rendering another one of my blog entries, A Journey into Nix and NixOS.
  At the top is a standard URL bar, displaying the URL, and a hamburger menu icon in the top right.
  At the bottom is another bar, there's a back and forward button at the left, and on the right are icons for favourites, bookmark, and open tabs.
My website rendered in Epiphany. Please ignore the broken icons, my icon pack seems to be missing some.

Epiphany looks notably better than the PureOS version. The UI looks very much inspired by Chrome on Android (though I haven't used that in forever). It even has Firefox sync support! I tried logging in, but I got a keyring setup prompt, followed by some error relating to the keyring. Funnily enough, the second password prompt to confirm the password had the "Store passwords unencrypted?" text.

The GNOME swipe from the left to go back gesture comes quite handy here, but it does trigger accidentally while browsing quite often. It should probably be restricted to a thin trigger zone on the side in these sorts of applications.

Annoyingly swipe gestures are used inconsistently, e.g. in the tab list menu, I'd expect to be able to swipe away tabs, but only the close button works.

I'd also prefer if the URL bar was on the bottom, as is the modern trend in browsers. That is much more accessible to my fingers, given how tall modern phones are. I'd like to go to a new site without awkwardly stretching my hand, or using a second one.

Camera

Mobile NixOS installs megapixels by default. Unfortunately this app also crashes on launch, but it's a less transient error. It complains about not being able to find a config file with the phone's model in the name. Clearly the app is specifically configured for each device, and you can find each config file here. I'll have to do some tinkering and create one for the Librem 5, then I can submit a patch for it.

Other apps


  The GNOME calendar app, clearly not rendering properly with the right side of
  the month grid cut off, hiding the Sunday column. It's notably missing the
  bottom mobile navigation bar for swapping between day, week, and month views
  that exists on PureOS.
The GNOME Calendar app doesn't fare too well, with the right side cropped.

  The GNOME activity monitor app, also clearly not rendering properly. The tabs
  at the top for swapping between processes, resources, and disks are cropped,
  completely hiding the disks tab. The resource usage graph is also poorly
  cropped, making it pretty unusable.
Similarly, the Activity Monitor app isn't adjusted for mobile at all, and most of the window renders off screen.

Apps like Calendar, Activity Monitor, and the standard GNOME text editor are curiously displayed as non-mobile applications, and look different than on PureOS. Compared to this showcase back in 2022, on NixOS these apps are notably missing a mobile-style bottom navigation bar. That suggests to me that Librem have forked these apps to adjust the UI for phone use. I'll have to look at packaging these for NixOS.


  The GNOME clocks application, featuring a bottom mobile navigation bar. It's on
  the World tab, displaying the current time in London, England.
The GNOME clocks application looks pretty similar to PureOS, and is very usable on the Librem 5

The Alarm application also has a good mobile interface, but unfortunately it doesn't work well as an alarm clock. It doesn't appear to set the RTC wakeup alarm, so the phone doesn't wake up to sound the alarm. There's an open ticket for this which is 3 years old as of writing.

NixOS problems

A weird issue I've been grappling with is some lingering garbage collection roots pointing to /proc for processes that no longer exist, I suspect from previous boots. Nix doesn't seem to clean those up no matter what I do, and I've yet to work out how to fix this. If unresolved, it's a big issue on the phone given the limited storage space (32GB), of which the Nix store is taking up 7GB. There's effectively two full systems in there, one of which is completely unnecessary.

Next steps

I'd like to start by making it easier for others to get to this point. I didn't quite know how easy this was going to be from the start, and I certainly complicated things for myself with the nixos-anywhere tangent. However, I think there are some opportunistic improvements to documentation, particularly:

After that I'd like to try adding support for the Librem 5 in the Mobile NixOS project. It seems like a more appropriate home for it, instead of the nixos-hardware repository. I don't know quite how it'll work to reproduce Sophie's work elsewhere.

I also need to work out some more things in Mobile NixOS, such as how kernel upgrades and recovery works. Surely there's a smarter system than re-writing the boot partition each time.

After that, I think I'll try resolving some of the problems I described in the first impressions. Starting with the easier ones, of course.

I hope I won't have to do much compiling, or figure out a faster method. Emulated compilation is painfully slow, and a compilation cache doesn't always help.

Anyway, we'll see how it goes. I hope to make some meaningful contributions to bring the dream of a feasible GNU/Linux phone a reality.