wip: PinePhone: niri as the compositor, plus the shell work that follows #933

Draft
lytedev wants to merge 75 commits from pinephone-niri into main
Owner

Moves the PinePhone session from phoc to niri, and everything that fell out of that.

The detail is in the commit messages; the shape of it:

  • niri as the compositor, with a config it can read before unlock. /run/niri was root-only 0700 while niri runs as the user, so it silently fell back to defaults — it warns once to its own stderr and carries on.
  • Windows fill the width by default, which is the only sensible column width on a 360pt screen.
  • The app list is a set of resident windows, not a launcher. The old launcher is gone; vicinae is the launcher, reached from a button in the shade.
  • Resident apps are opt-in and empty by default — a program should be resident because it has background work, not because launching it is awkward.
  • A greeter session on babyflip that runs the phone shell on a touchscreen. Touch bugs cannot be found from a desk, and the phone loop is a cross-compile plus a closure copy over wifi.
  • vicinae sized for the phone: its default window is 770x480 against a 360pt screen, so most of the launcher was off the edge.

Also picks up the shell's shade/slider work and the scrollable notification list.

CI

The format check was failing on lib/modules/nixos/pinephone-shell.nix; 368b508f fixes it. Note that nixfmt also rewrapped pre-existing residentUnits/path code, so the file had drifted before this — the unformatted block just made the check notice. Whitespace only.

Verified by running the exact CI command (nix fmt -- --check .) in a clean jj workspace rather than in a working tree, which gives a false positive here because of a ./result symlink into a build output.

Moves the PinePhone session from phoc to niri, and everything that fell out of that. The detail is in the commit messages; the shape of it: - **niri as the compositor**, with a config it can read before unlock. `/run/niri` was root-only `0700` while niri runs as the user, so it silently fell back to defaults — it warns once to its own stderr and carries on. - **Windows fill the width** by default, which is the only sensible column width on a 360pt screen. - **The app list is a set of resident windows, not a launcher.** The old launcher is gone; vicinae is the launcher, reached from a button in the shade. - **Resident apps are opt-in and empty by default** — a program should be resident because it has background work, not because launching it is awkward. - **A greeter session on babyflip** that runs the phone shell on a touchscreen. Touch bugs cannot be found from a desk, and the phone loop is a cross-compile plus a closure copy over wifi. - **vicinae sized for the phone**: its default window is 770x480 against a 360pt screen, so most of the launcher was off the edge. Also picks up the shell's shade/slider work and the scrollable notification list. ### CI The format check was failing on `lib/modules/nixos/pinephone-shell.nix`; `368b508f` fixes it. Note that nixfmt also rewrapped pre-existing `residentUnits`/`path` code, so the file had drifted before this — the unformatted block just made the check notice. Whitespace only. Verified by running the exact CI command (`nix fmt -- --check .`) in a clean `jj workspace` rather than in a working tree, which gives a false positive here because of a `./result` symlink into a build output.
Adds lib/modules/nixos/cyberdeck-shell.nix: phoc plus our own shell and
nothing else, replacing the Phosh/GNOME half of lyte.mobile while keeping
the hardware half (modem, audio, haptics, sensors). Everything
Phosh-specific is disabled in configuration rather than killed at runtime
— killing it every boot is how this started, and that is not a
configuration.

The shell runs as its own transient user unit rather than being exec'd as
the session. Exec'ing it made the shell *be* the session, so replacing
the shell restarted phoc, which kills every Wayland client — the user's
terminal died on every single redeploy. As a separate unit the shell can
be swapped underneath a compositor that never notices.

The unit also carries an explicit PATH. The shell reads radios and the
modem through the programs that own them, and a unit with no PATH fails
every one of those calls with ENOENT, which presents as broken hardware.

Also fixes an undefined-variable bug in packages/hosts/pinephone.nix
(the module took no `config` argument) and re-enables the pinephone host.
This is the PinePhone stopgap, not the cyberdeck it is a stepping stone
toward. Renames the module, the option (`lyte.cyberdeck` ->
`lyte.pinephone`), the unit, and the runtime paths accordingly, so the
name stops claiming to be something it is not.
Dictation shells out to whisper-cli and pw-record. Without them on the
unit PATH the feature fails at the last step with ENOENT, which presents
as "dictation silently does nothing".
Adds Morph — UBports' browser — as the lightweight alternative to
Firefox, which stays as the default because it is complete. Measured on
this device, Firefox costs 1139 MB across 11 processes and ~50s to first
paint; whether Morph is actually lighter is worth measuring rather than
assuming, since it is QtWebEngine and therefore Chromium underneath.

That Chromium lineage is what makes forcing dark pages easy: Blink has
its own force-dark, so this is a launch flag rather than a Dark Reader
style extension. Inversion algorithm 4 preserves lightness, and image
policy 2 leaves images alone — without that, photographs come out as
negatives, which is worse than a light page.

Which browser the shell launches is now an option (`lyte.pinephone.
browser`) exported as `$BROWSER` to the shell unit. The shell already
reads `$BROWSER` rather than hardcoding, so switching is configuration.

Note for whoever deploys this: `lomiri.morph-browser` was removed from
nixpkgs for depending on a known-vulnerable libsForQt5 qtwebengine. This
uses `lomiri-qt6.morph-browser`, which is the maintained one. The
aarch64 binary substitutes from cache — cross-building it here would
mean compiling Chromium locally.
The session ran whatever was at /run/pinephone/shell, which is tmpfs —
so a reboot discarded it and phoc came up with nothing to run. That
presents as a black screen, not an error: the unit is active, the
compositor is fine, and there is simply no client. Recovering it needs
another machine, which is a poor property for a phone.

The path is now resolved at start rather than baked in: a development
build if one has been copied over, then a packaged shell if the option
is set, and failing both a terminal that explains what is missing and
how to fix it.

Handing over a terminal is the point. A phone with no shell is not much
of a phone, but a phone with a terminal can be repaired from the phone,
and a black screen cannot.

`shellCommand` still exists for pinning an exact path, but now defaults
to null — "work it out" — rather than to a tmpfs path that is only
correct until the next reboot.
Morph works, and force-dark works — confirmed on the device with a page
that is normally white rendering dark. Two earlier conclusions here were
wrong, and both were bad testing rather than bad software:

  - `su daniel` without `-` keeps the working directory at /root, which
    daniel cannot read, so the process died before exec. That produced
    exit status 0 with no output whatsoever — not even for `--help` —
    which reads exactly like a broken build. It is not.

  - It also needs writable XDG directories and a session bus. Missing
    either fails the same silent way.

The wrapper now sets all of that, and passes --app-id, which it wants as
a Lomiri application.

Recorded in the wrapper because it is the sort of thing that costs an
afternoon twice: do NOT add --disable-gpu. QtWebEngine logs
"eglCreateContext: Requested version is not supported" on Mali-400 —
Chromium asks for a GL version this GPU does not have — but it falls
back and renders correctly. Disabling the GPU makes it exit immediately.

Measured against Firefox on the same page: 494 MB across 3 processes
versus 1139 MB across 11. Less than half the memory on a 3 GB phone,
which is the whole reason for wanting it.

Still imperfect: force-dark applies to page content, not Morph's own
chrome, so the URL bar stays white. That is a Morph theming setting
rather than a Chromium flag.
The chat and mail apps are launched by name, so the directory deployed
builds land in has to be on the PATH — and first, so a development build
wins over a packaged one without the shell needing to know which it is.
Two settings did not work as labelled, and neither fault was in the
shell.

**The backlight was unwritable.** /sys/class/backlight/*/brightness is
root:root 0644. The user is in `video`, but the file is not group-owned
by it — so the shade's brightness slider and auto-brightness were both
writing to a file they could never open, failing silently. A udev rule
gives the `video` group the access that group membership implies.
Verified: root:video 664, and the user setting 55% successfully.

**The session never started on boot.** It was WantedBy=graphical.target,
and this system's default is multi-user.target — the graphical one is
never reached, precisely because this module removes the desktop that
would pull it in. So the phone booted to a text console and the shell
had to be started by hand. That had been happening after every reboot
all along and I had been treating it as incidental rather than as the
symptom it was.

Also puts firefox on the shell unit's PATH. It was missing, so launching
the browser failed with a command that did not exist — while the shell
logged a successful launch, because `systemd-run --scope` spawning
successfully says nothing about whether the program is there.
/dev/snd/* is root:audio 0660, and the ACLs logind hands the active seat never
arrive here — this session is a systemd user unit, not a seat login. So
WirePlumber found no cards at all: not a broken sink, an empty list. That is
one root cause behind two separate reports, dictation capturing nothing and
calls having no audio to route.

alsa-utils comes along for amixer: routing a call between the earpiece, the
speaker and a headset is a mixer control on this hardware, not a PipeWire sink
to select.
phoc implements wlr-output-management, so switching the panel between 1x and
2x is a live change rather than a session restart. The shell drives it through
wlr-randr rather than implementing the protocol a second time.
The development loop deploys to /run/pinephone, which is tmpfs: a reboot
cleared it and left the session with nothing to start. lyte.pinephone.package
existed for exactly this and had sat unset, because the derivation had never
actually built — the atlas is baked from fonts that are gitignored (36 MB of
font in a repo to be read once), and a hermetic build has no fetch-assets step,
so it died on a missing font every time anyone tried. Fixed upstream by making
the font a locked flake input.

The packaged build's bin goes on the session PATH alongside the development
directory, in that order, which is the whole of the rule: iterate there,
survive a reboot here. It carries chat, mail and calls too, so those resolve by
name after a reboot rather than only when tools/dev.sh has run.

The mobile host builder gains  in specialArgs: the phone's own
configuration reaches for a flake input directly, which no overlay can carry.
The session had no sinks and no sources at all, and nothing in any log said
why. Three diagnoses, and only the last one was right — worth recording all
three, because the first two were each plausible and each cost a reboot.

It was not permissions. Adding the user to the  group was necessary and
changed nothing on its own: with the group applied and the device ACLs in
place, WirePlumber still found no cards.

It was not the UCM path either. mobile-nixos already points ALSA_CONFIG_UCM2 at
a PinePhone-specific tree, and its absence from WirePlumber's unit environment
was real but not the blocker. Fixed anyway: it runs as a user unit and never
sourced the system profile, so the variable is passed to pipewire, wireplumber
and the shell explicitly. Their value, not one of ours — a generic
alsa-ucm-conf also produces verbs, but theirs is written for this hardware.

The blocker was that  with none of
alsa/pulse/jack makes NixOS decide PipeWire is not being used for audio, and it
writes a WirePlumber drop-in that says exactly that: , in a file called 90-nixos-no-audio.conf. The ALSA monitor never ran.
That is why the logs mentioned cameras and never mentioned ALSA, which is the
detail that should have pointed here an hour earlier.

Verified after the reboot: the codec takes its HiFi and Voice Call verbs,
WirePlumber applies 'HiFi (DigitalMic, Speaker)', and the session has a speaker
sink and an internal microphone. Voice Call being one of the verbs is what call
audio will route through.

Also stages the phone with `boot` and a reboot from now on, documented in
AGENTS.md: a live switch restarts the graphical session under whoever is
holding the phone, and the things that matter here — supplementary groups, the
packaged shell on tmpfs — only apply at session start. And a tmpfiles rule for
the development directory, which is tmpfs and so did not exist after a reboot;
a deploy straight after one failed with ENOENT on a path the script had no
reason to expect was missing.
environment.systemPackages does not satisfy the shell: its unit is started with
an explicit PATH built from a list in this module, so a tool installed
system-wide and not named there is invisible to it. wlr-randr was installed and
the scale toggle still reported a missing output — while wlr-randr worked
perfectly from a terminal on the same phone. alsa-utils had the same problem
waiting for whenever call audio was first routed.
The RTC alarm register is root-writable by default, so every scheduled wake
failed with EACCES. That means the periodic suspend wake had never once worked
— it was added, deployed, and silently did nothing, because nothing asks whether
a write to sysfs succeeded until something depends on it. A udev rule hands it
to the audio group, the same way the backlight is handed to video.

wlr-randr and alsa-utils move into the shell's PATH list. environment.systemPackages
does not satisfy that unit: it is started with an explicit PATH built from a
list in this module, so a tool installed system-wide and not named there is
invisible to the shell. wlr-randr was installed and the scale toggle still
reported a missing output, while wlr-randr worked perfectly from a terminal on
the same phone.
The packaged build is what a reboot falls back to, and it was five commits
behind — so rebooting would have come back to a phone with no clock, calendar or
files, and none of the reachability fixes. /run/pinephone is tmpfs; the pin is
the thing that decides what survives.
The shell unit is started with an explicit PATH, and /run/wrappers/bin was not
on it — so the lock screen could not run unix_chkpwd, and reported every
passphrase as wrong without ever checking one. Third time this list has hidden a
tool the shell needs, after wlr-randr and the ALSA utilities.

Also records two plans as issues rather than leaving them in a conversation:

- pinephone-encrypt-home: LUKS2 on /home, unlocked from the lock screen.
  Measured on the device first — AES-XTS runs at 281.9 MiB/s against 22.3 MB/s
  of storage, so crypto has about twelve times the headroom it needs and
  throughput is not the constraint. Full-disk would need an initrd passphrase
  prompt on a touchscreen-only phone, which is the thing most likely to leave it
  unbootable in a pocket. States plainly that this does not address evil-maid
  tampering, which no /home-only scheme can.

- pinetime-integration: the watch is reachable now (it advertises as InfiniTime
  and connects), but it drops an idle link before BlueZ resolves its GATT tree.
  The integration is a client that holds a connection, which is a new component
  rather than an addition to the bluetoothctl-driven settings page.
Researched the mechanisms modern phones use and wrote down which are worth
building here, in what order, and which are not.

The finding that outranks the rest: with no encryption at rest, the lock screen
is a curtain rather than a boundary — anyone who takes the phone can pull the
card and read /home, including the Stalwart app password. Every other mechanism
is worth less than the encryption plan, and several are meaningless without it.

The rest, briefly. Hardware-backed rate limiting is what Android relies on, and
the A64 has no secure element — so our throttling is a speed bump on the running
device and must be described as one. Getting back to Before First Unlock is the
highest-value item once there is encryption, because AFU yields around 95% of the
filesystem to an extraction and BFU yields metadata; GrapheneOS reboots after 18
hours by default for exactly this. A duress credential should erase the LUKS
keyslots rather than wipe the filesystem: milliseconds instead of minutes, and it
cannot be interrupted half way — with the header backup question answered first,
because a backup undoes it.

Deliberately not building: a full wipe, a hard attempt cap (no recovery path
exists on this device, so the failure mode is losing your own data to your own
fingers), and anything claiming hardware-backed guarantees.
Software throttling capped at an hour is enough: there is no secure element to do
better, and an hour per attempt on a four-digit code buys time to rotate the
secrets, the headscale admin tag and whatever else is on the phone — which is the
throttle's whole job.

Auto-reboot at two days rather than the hour I first suggested. That was wrong:
a reboot to BFU means Matrix, mail and everything else needing /home stops until
the passphrase is typed, and a phone that logs itself out hourly is one you stop
trusting to deliver anything. Two days keeps the property that matters — a phone
in someone else's pocket does not stay in AFU indefinitely — at a cost paid twice
a week rather than hourly.

The duress passphrase is optional and written down rather than scheduled.
Tried again with the aim of pushing the time to the watch. BlueZ never resolves
its services: ServicesResolved stayed false for twenty seconds after a connect
that reported success, and the device object has no characteristic children. A
GATT client cannot address a characteristic that was never discovered, so this is
not something more code fixes.

Almost certainly bonding — the watch is not paired, and InfiniTime confirms a
passkey on the watch itself, which needs a button pressed on the device. Recorded
so the next attempt starts from a pairing agent rather than from another client.
Three findings from the phone that changed the plan, all contradicting the first
draft. /home is not a separate mount — it is a directory on the root ext4
filesystem, which fills the disk, so there is no partition to encrypt and making
one means shrinking a mounted root. cryptsetup was not installed at all, so none
of this could have happened without a deploy first. And the kernel has both
FS_ENCRYPTION and DM_CRYPT, so either mechanism is available.

So: a LUKS2 container mounted over /home. fscrypt would be the better fit — it is
what Android does, needs no container, and removing the key is instant, which is
exactly what a duress code wants — but it needs the encrypt feature set on an
unmounted filesystem, which means booting the phone from something else. Worth
doing one day, not worth blocking on now.

The migration script is deliberately not wired into activation and not run by the
shell. It moves every file in /home and there is a window where they exist in one
place only, so it checks free space, refuses if anything holds a file open, makes
you type ENCRYPT, and says plainly that the backup it leaves behind is plaintext.
It tunes the PBKDF down from the argon2id defaults, which assume a desktop.

cryptsetup also goes on the shell unit's PATH, since the lock screen will open
the container. That list has now hidden three tools the shell needed — wlr-randr,
the ALSA utilities, and unix_chkpwd, which presented as a wrong passphrase for a
whole session — and this is the cheap way not to make it four.
The previous commit chose a LUKS container and said fscrypt was the better fit
but needed the filesystem unmounted, which sounded like it meant booting from
other media. The first half was right and the second was a failure of
imagination: the initrd runs before root is mounted.

So there is now a one-shot initrd unit that sets the ext4 encrypt feature on the
next boot, behind an option that defaults to false and is meant to be true for
exactly one boot. Verified rather than recalled: tune2fs -O encrypt succeeds on
an unmounted filesystem and is refused on a mounted one, tested against a
loopback image rather than the phone.

Taking the SD card out and doing it from a laptop remains the option with least
to go wrong — root really is the SD card here, mmcblk0, type SD, with the eMMC a
separate device holding the factory install — and it lets e2fsck be run and read
comfortably. Both paths are documented; neither needs a reinstall.

Disko does not help with this and the document now says why. It provisions: it
declares a layout and creates it, destroying what is there. It cannot flip a
feature flag in place. It would be the right tool for deciding to re-image the
card with a declared layout, which is a bigger and later decision.

The container script is deleted rather than left beside its replacement. fscrypt
wins on everything that mattered: nothing to mount, no fixed size to outgrow, no
second filesystem to fsck, and removing the key is a syscall — which is what
makes it the right substrate for the duress code.

Setting the feature is a one-way door and the module says so: encrypt is an
INCOMPAT feature, so afterwards only tools that understand it will mount the
filesystem.
You are happy to take the card out, so here is the path with least to go wrong: a
script that runs on another machine with the card in a reader, checks the
filesystem, and sets the encrypt feature.

It refuses hard before it does anything. The cost of naming the wrong device is
somebody's laptop, so it will not touch this machine's root, will not touch a
mounted filesystem, and asks for confirmation if the label is not NIXOS_SYSTEM.
It also checks the block size is 4096 to match the phone's page size — fscrypt
needs those equal, and finding that out here beats finding it out after moving a
home directory around.

On disko: you are right that it does mounting as well as provisioning, and it is
what the other hosts here use. It is still not the tool for this, and the reason
is specific rather than general — this phone's layout belongs to mobile-nixos,
which builds the image and places u-boot at the raw offset the SoC expects.
Replacing that with a disko layout is a real project whose failure mode is a
phone that does not boot, and the job at hand is one feature flag on a
filesystem that already exists. Disko would be the right answer to a different
question: re-imaging the card with a declared layout, which is also when a
separate /home partition or a LUKS root would be worth having.

fscrypt-experimental also joins the shell unit's explicit PATH, since the lock
screen now releases the home directory's key.
Two ways an encrypted home directory leaks onto unencrypted storage, both
present on the phone right now.

A 1 GB /swapfile on the root filesystem. Anonymous pages are exactly what is
worth protecting — decrypted message text, Matrix keys, a passphrase that was in
memory a moment ago — and paging them to a plaintext file undoes most of the
point of encrypting the directory they came from. It is gone.

zram stays and is the better answer anyway: compressed swap in RAM, which is
what Android does, never touching storage. At 60% of 3 GB it already provides
about 1.7 GB, more than the file it replaces, so this is not even a loss of swap.

/tmp was on the root filesystem too. Applications write scratch files there, and
a scratch file from an unlocked home directory is a copy of protected data on
unencrypted storage. It is a tmpfs now, which is also faster — on this hardware
that is not nothing.
The phone card now has the ext4 encrypt feature, done from a reader on a
laptop rather than through the initrd unit, so enableFeatureAtBoot stays
false and was never used.

Two things worth keeping from doing it:

Block size is 4096, which is what fscrypt needs to match the page size.
That had been an assumption; it is now measured.

Do not identify a mounted image by /etc/hostname. It is a symlink whose
absolute target escapes the mountpoint and resolves against the host, so
it reads back as the laptop name. The store path names the system profile
points at cannot escape, and those proved the card was the phone via
nixos-system-pinephone generations 88-90.

The card also arrived with needs_recovery set, so the e2fsck before
tune2fs is load-bearing rather than a formality. Without a terminal that
e2fsck refused to run at all and left the job half done, which is how
this gets invoked over ssh; it now preens when there is no tty and stays
interactive when there is.
The script refused unless free space exceeded the size of home. But it
moves /home/daniel to /var/lib/home-migrate, and both are directories on
the root ext4, which makes that a rename: instant, atomic, and needing no
free space at all.

It would not have blocked today (home is 13.3 GiB against 22.1 GiB free)
but it was measuring the wrong thing, and would have started refusing a
migration that works the moment home passed half the free space. It now
checks whether the two are on the same filesystem, which is the property
the move actually depends on, and demands the space only in the case
where the move really is a copy.
The bridge only ran while the chat app was open, and on the phone only
the shell is running. So texts were mirrored into Matrix roughly never,
and a reply sent from another client never reached the modem.

A user service runs chat --daemon, which starts the same sync with no
window. Wanted by default.target rather than the session, since the
point is that it outlives whatever is on screen, and Restart=always
unlike the shell: a dead shell should stay dead and leave SSH, but a
dead bridge just quietly stops carrying messages.

Its PATH is named explicitly, like the shell one. That list has hidden
four things so far, each of which presented as a feature that silently
did nothing rather than as an error.
Ran the whole chain on a throwaway directory with a throwaway
passphrase, then removed it, leaving 0 protectors and 0 policies.

Policy applies, a locked filename becomes ciphertext, reading contents
gives "Required key not available", the right passphrase brings it back
and a wrong one is refused. So the block size question is settled in
practice and not only on paper: the kernel accepted the policy.

Also recorded that fscrypt reports only root may create metadata on this
filesystem. Creating is root-only, unlocking is not, and the migration
makes the protector for the user — but that has not been shown with a
real home directory, and a lock screen is the wrong place to find out.

Swap and /tmp are confirmed done on the running system: zram only, tmpfs
/tmp, and the old 1 GiB swapfile deleted. Noted that rm unlinks it but
wear levelling may leave the blocks until reused, which is a thing
/home-only encryption does not fix.
Two faults, one of them long-standing.

curl was missing from the shell explicit PATH. Every network request in
this project shells out to it — Matrix, the SMS bridge, JMAP mail,
CalDAV and WebDAV — and that list is the PATH the shell hands to the
apps it launches. So an app started from the launcher could not reach
the network at all, while the same binary run from a terminal worked,
because a login PATH includes systemPackages and the shell one does not.
That is the fifth thing this list has hidden, after wlr-randr, the ALSA
utilities, unix_chkpwd and fscrypt, and it has the same shape every
time: a feature that silently does nothing rather than an error.

The bridge unit set environment.PATH directly, which collides with the
systemd user module own definition derived from `path` and made the
whole configuration refuse to evaluate. It uses `path` now.
Preparation, not a finished feature — call audio over earbuds still
needs a route from the modem PCM to the bluetooth transport, which does
not exist. But everything that can be set up front is set up now.

hardware.bluetooth.enable on its own gets pairing and nothing else:
bluetoothd advertises no audio profiles by default, so a headset pairs
and then offers nothing to connect to. Enable = Source,Sink,Media,Socket
turns them on, powerOnBoot means a phone does not need re-pairing after
a reboot, and Experimental is what carries earbud battery levels.

WirePlumber gets the bluez5 roles including hfp_hf, without which a
headset offers playback only and its microphone is invisible, plus mSBC
so a call does not sound like 1990s CVSD.

Also correcting a wrong conclusion recorded earlier today: pipewire
being inactive did not mean the audio stack was dead. It is socket
activated — TriggeredBy=pipewire.socket, and the socket is active — so
inactive is the normal idle state and it starts on the first client.
Both faults came out of the first real run on the phone.

The metadata was unreadable by the user. fscrypt encrypt runs as root,
so the policy and protector files land root-owned 0600 — and then
fscrypt unlock as the user fails with "permission denied" on the policy
file, before it ever asks for a passphrase. The lock screen runs as the
user, so the phone would have booted to a home it could not open with
ssh as the only way in. Confirmed by testing as the user with a
deliberately wrong passphrase: it now answers "incorrect key provided",
which is the mechanism working.

Given to the user rather than made world-readable. These hold the
wrapped key; an attacker holding the disk has them either way, but there
is no reason for every local account to.

And the move back stopped half way. mv refuses to move a directory onto
an existing non-empty one, and the new home does not stay empty on its
own — any login recreates ~/.local and ~/.ssh within seconds, which is
exactly what my own ssh sessions did while the copy was running. set -e
then stopped the script with 1.5 GiB still in staging and no message
that anything was wrong. It now merges: originals win, and the stub that
was in the way is kept aside rather than deleted.

Also recorded: fscrypt lock refuses when more than one user has added
the key and wants --all-users, which matters for the duress path.
Three faults, all now fixed in the migration script: the move back
stopping 1.5 GiB short in silence, metadata the user could not read so
the phone could not open its own home, and my own earlier "fix" that
removed a space check on the grounds a same-filesystem move is free —
true moving out, false moving back into an encrypted directory.

The note that flagged the permissions risk was right and still would not
have caught it, because it was written as something to watch rather than
something to check. That is the lesson worth keeping.
WirePlumber started, found the sound card, and produced no devices,
sinks or sources at all. Its log said "Failed to get the verb HiFi" and
"Failed to get the verb Voice Call" — note it got further on this card
than on the dummy ones, which say "No UCM verb is valid", so the UCM was
found and simply could not be activated.

Not the environment variable, which an earlier fix here already got
right and which is correctly set in the running process. The tree it
points at is the problem: mobile-nixos ships a minimal legacy UCM —
ucm.conf plus a PinePhone directory, nothing else. Upstream
alsa-ucm-conf ships the same two verbs and, crucially,
conf.d/simple-card/PinePhone.conf, the file that matches a card to its
configuration. That is the mechanism WirePlumber uses and the minimal
tree has no equivalent of.

Checked on the device before changing anything: pointing the user
services at the upstream tree turns an empty device list into an
Internal speaker sink and an Internal Microphone source.

Upstream is the better configuration besides. Its FixedBootSequence sets
the modem path explicitly — AIF2 as the DAC source, the mono mix that
matches this phone single earpiece, and the earpiece and line-out
sources — which is exactly the routing that had to be reverse-engineered
by hand when the calls app was doing this itself.

This unblocks two things: bluetooth audio, which is only reachable
through PipeWire and impossible while it has no devices, and moving call
routing to WirePlumber rather than fighting it over the same mixer.
Three things all failed the same way once home was encrypted, and none
of them said so on screen.

mmsd-tng is a user service wanted by default.target, so it starts at
login while home is still locked, fails with "Cannot read and write in
/home/daniel/.mms", and restarts every five seconds — 315 times in one
boot on the device, heading for the start limit, after which MMS stays
dead for the session. It waits on home-unlocked.target now, and stops
when that target does.

The SMS bridge is the same case: store, Matrix session and crypto keys
are all in the encrypted home.

And the carrier settings were written by a *system activation script*
into $HOME/.mms — activation runs at boot, home is locked, mkdir fails,
so the file was never written at all. That would have read as "MMS is
broken" rather than "MMS was never configured". A user oneshot writes
them after unlock instead, and only when absent, so anything edited by
hand or by a future settings page survives.

The target is raised by the shell when fscrypt reports the directory
open, which is the only moment anything actually knows.
On the first encrypted boot mesa logged

    Failed to create /home/daniel/.cache for shader cache
    (Required key not available)---disabling.

and turned the cache off for the whole session, so lima recompiled every
shader on every launch. On a Mali-400 that is not a rounding error, and
this project grades itself on latency.

The cause is the same one that broke mmsd and the carrier settings: the
shell starts before the home directory can be unlocked, so anything it
touches at startup has to live outside it. MESA_SHADER_CACHE_DIR now
points at /var/cache/pinephone-shell.

A shader cache is not private in the way messages are — it holds
compiled shaders from the shell own baked assets — so putting it
outside the encrypted home costs nothing that matters.

Counted while looking: 1088 mmsd-tng failures in a single boot, worse
than the 315 measured earlier, and two nixos-activation failures from
the same root cause. Both are fixed by the home-unlocked target; this
was the third thing hiding behind it.
Setting MESA_SHADER_CACHE_DIR on the shell fixed the shell and left the
compositor still logging

    phoc: Failed to create /home/daniel/.cache for shader cache
    (Required key not available)---disabling.

on every boot. phoc starts from the session service, not from the
transient unit the shell runs in, so it never saw the variable — and it
is the one that matters most, since every frame goes through it.

Measured across the reboot: the message went from 9 occurrences to 3,
all three phoc. This should take it to none.
Replaces phoc with niri. The shell, its bars, the greeter, the on-screen
keyboard and the decrypt prompt all run unmodified as niri clients -- verified
on the device from a cold boot.

The ordering problem this solves: niri normally reads
~/.config/niri/config.kdl, which lives inside the fscrypt-encrypted home. The
greeter that unlocks that home is drawn by niri, so the compositor cannot read
its own config at the moment it most needs to run. niri therefore boots against
a bootstrap config seeded to /run/niri/config.kdl, and the shell hands over the
user's own config once there is a home to read it from. niri watches the
containing directory and picks up the change within a second, with no restart.

Scale is pinned to 2. Left alone niri picks 1.5 for this panel, and the shell's
font atlas is baked at 2 -- a mismatch is what made text soft before. The output
is matched by connector name because the panel reports no EDID at all, so
make/model matching does not work here.

Three traps, all recorded in comments because each one presents as something
else entirely:

- The bootstrap config must be mode 0644 in a traversable directory. niri runs
  as the user; a root-only config is one it cannot read, and it responds by
  warning once to its own stderr and silently running the defaults. That is
  indistinguishable from "niri ignores its config".
- NIRI_SOCKET must be named explicitly in the session's systemd-run
  environment. It is how `niri msg` reaches the compositor, and so the only way
  the shell can blank the panel -- niri has no zwlr_output_power_manager_v1.
  Without it the screen never goes off. Confirmed on the device both ways.
- A `#` comment cannot sit inside a `\`-continued command, and the backtick
  form that can is unusable when the text contains backticks: the inner pair
  closes the substitution and corrupts the arguments. That shipped once and
  left the shell unspawned on a phone that otherwise looked fine.
niri's default column width is half the screen, which is right for a widescreen
monitor and wrong for a 360-point phone -- it would put two half-width windows
side by side on a display narrow enough that one of them is already cramped.

default-column-width and preset-column-widths are both pinned to a full
proportion. The presets matter as much as the default: left alone, the
column-width binding cycles through a third and a half of the screen, so a width
that is unusable here stays reachable by accident.

Verified on the device with a real window: niri reports 360x460 against a
360-point logical output. The height is the shell's layer-shell bars reserving
their exclusive zones, which is what should happen.

Validated with the phone's own niri binary before deploying, not just the one on
the dev machine -- an invalid config is not an error niri surfaces anywhere the
user will see, it just runs the defaults.
A button would still be a launcher, only smaller. Keeping a few applications
running deletes the concept instead: the overview *is* the app list, picking an
app and switching to it become the same gesture, and nothing has to cold-start
on a device where cold-starting is the slow part.

`lyte.pinephone.residentApps` maps a name to a command; each becomes a user
service that waits for home-unlocked.target and restarts if it exits. Defaults
to a terminal, chat and calls. Restart=always is deliberate -- these are meant
to be permanently present, so the way to remove one is the list, not closing its
window.

Affordable only because these are small: our own applications are around 9 MB
resident. The same list filled with heavyweight toolkit apps would be a
different proposition, and vicinae -- Qt6 QML -- stays for the long tail rather
than joining this.

ExecStart runs through `bash -c 'exec ...'`. It has to be an absolute path,
these commands are bare names resolved against the unit's PATH, and the option
is documented as taking a command *line* -- systemd's own parsing gives
arguments and quoting none of the meaning someone writing `foot -a notes hx
~/notes.md` would expect. `exec` so no shell lingers per application.

The generated units are merged into systemd.user.services with mkMerge: an
attrset literal cannot both assign a key and assign into it, and the module
already defines several services by name.

Note: a resident `chat` runs alongside `chat --daemon` from the SMS bridge, so
two chat processes exist. That is the shape that once cost a quarter of the
battery; the flock sync claim added at the time means only one of them syncs.
Keeping applications running so the overview always listed them made residency
the price of being easy to reach, which is backwards. A program should be
resident because it has work to do while nobody is looking at it -- the SMS
bridge is a daemon for exactly that reason -- not because launching it is
awkward. The launching is now fixed directly: a row of buttons along the bottom
of the shade.

The mechanism stays, because there are real uses for it: a terminal holding a
long-running session, a notes buffer you keep returning to. It is just no longer
how the phone's app list works, and defaults to nothing.
Nearly every bug in this shell is in touch handling, and touch handling is the
one thing that cannot be checked from a desk. Iterating on the phone costs a
cross-compile, a closure copy over wifi and a reboot -- five to forty minutes an
attempt, and the link is unreliable enough that deploys sometimes do not land at
all. babyflip is x86_64 with an ELAN touchscreen and gigabit ethernet, so the
same loop is a 23 second native `cargo build` and a file copy.

A second wayland session in the greeter, running niri plus the shell the way the
phone does. The normal desktop session is untouched and still the default.

It has to be a *session* rather than something started over SSH: a compositor
needs DRM master, which needs a seat, and an SSH connection has no seat.
Everything launched from one fails with DeviceMissing and every layer surface
closing for want of an output, no matter what else is stopped. Logging in at the
greeter is what creates a seat0 session and there is no way to conjure one
remotely.

Registered through sessionPackages rather than by dropping files into /run at
runtime. That was tried first and left the machine with a login manager that
would not start -- twice. This either builds or it does not.

The session reads its binary from /var/lib/pinephone-dev/shell, which is
deliberately outside the store: replacing it is the whole loop. When it is
missing the session opens a terminal saying so and giving the two commands to
fix it, rather than exiting to a black screen that reads as a broken session.

EGL is set up the way the packaged build does it -- /run/opengl-driver first for
the vendor library, plus libglvnd for libEGL.so.1, which is dlopened by soname
and so never appears in `ldd` output. That failure mode is a bare "cannot open
shared object file" with nothing else to go on.

Starts unlocked: this is a test seat on a machine that is not the phone, and a
lock screen asking for a passphrase that was never set is a dead end.
Four fixes found on babyflip's touchscreen, none of which the phone has yet:
the shade's dismiss rule swallowing the button row, the surface shrinking out
from under a finger, sliders claiming drags that began elsewhere, and the
sliders being laid out on top of the row.

Built and waiting: the phone has been off the network since before any of them
landed.
chore(pinephone): pick up today's shade and slider work
Some checks failed
/ check-format (push) Failing after 10s
/ build (push) Successful in 6m16s
c520d844ad
Everything found on babyflip's touchscreen: the dismiss rule that swallowed the
button row, the surface shrinking out from under a finger, sliders claiming
drags that began elsewhere, sliders laid out on top of the row, tap-inside no
longer dismissing, and the shade split into two panels chosen by which side you
pull from.

Built and waiting. The phone has been off the network since before any of it
landed.
feat(pinephone): size vicinae for the phone screen
Some checks failed
/ check-format (push) Failing after 12s
/ build (push) Successful in 6m7s
a7f0cf644b
Its default launcher window is 770x480. The panel is 360 points wide, so
the launcher opened more than twice as wide as the display and most of it
sat off the edge -- which is what "too large for the viewport" was.

Now 344x560 with compact mode, opaque, and no blur: a full-screen readback
per frame for an effect behind an opaque list is not something Mali-400 has
the cycles for. Filesystem search off, since indexing eMMC costs CPU on
every keystroke for results a phone does not want.

Passed with --config rather than written into the home directory: that
directory is encrypted and does not exist when the service is defined, and
a file the system puts there is one the user cannot then edit.
CI runs `nix fmt -- --check .` and this file was failing it.

Mostly my vicinae config block, which was added without running the
formatter. nixfmt also rewrapped the `residentUnits` mapAttrs' and the
`path` list concatenation, so the file had drifted before this too --
adding an unformatted block is just what made the check notice.

Whitespace only; no evaluated change.
chore(pinephone): pick up the scrollable notification list
All checks were successful
/ check-format (push) Successful in 12s
/ build (push) Successful in 6m23s
3aedb46ce0
chore(pinephone): pick up the niri shutdown fix, notifications and activate diagnostics
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m29s
17fac4eaf9
Brings in four shell commits:

- the scrollable notification list with a pinned footer
- releasing the input method before disconnecting, so stopping the shell
  no longer aborts niri and takes the session with it
- notifications that answer their senders: NotificationClosed with the
  spec's reasons, ActionInvoked on a tap, and the actions capability
- a warning when a window cannot be activated, which was the one silent
  link in that chain
chore(pinephone): pick up the launcher removal and the dmabuf fix
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m23s
e0364dc19e
Brings in the shell without its home page: vicinae launches and niri manages
windows, so Page::Home, Page::Apps and Page::Entries are gone and Settings
is the root of what remains.

Also the quick settings tile ownership fix, and a missing dmabuf global
becoming a message rather than an abort.
lytedev changed title from PinePhone: niri as the compositor, plus the shell work that follows to wip: PinePhone: niri as the compositor, plus the shell work that follows 2026-08-05 15:52:24 -05:00
fix(pinephone): vicinae could not launch anything it found
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m18s
1bcf9527dd
Reported as "it scrolls with swiping but I can't tap an app to launch" --
which read as vicinae ignoring touch. It was not. The taps arrived and
vicinae acted on every one of them:

    App started with command line "foot"
    Failed to start app: "Child process set up failed: execve: No such file
    or directory"

It runs as a systemd user unit, which inherits almost nothing, and its path
held only vicinae and coreutils. Nothing it could offer to launch was on it.
The failure goes to vicinae's own log and nowhere the user can see, so a tap
that cannot find its binary looks exactly like a tap that never landed.

Fixed with the profiles rather than a list of applications: a launcher
launches whatever is installed, so enumerating packages would mean every
application added anywhere else had to be remembered here too -- and
forgetting would be silent in precisely the same way.
fix(pinephone): size vicinae for a screen with the keyboard on it
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m13s
67ea3d1af3
Reported: it does not respect the vertical space left when the keyboard is
open, and wants a smaller font and less padding.

Sized for the keyboard being open, because on a launcher it always is -- you
open it in order to type. The panel is 360x720 logical (720x1440 at scale 2),
the on-screen keyboard is 208 of that, and the two bars take another ~50.
About 460 is left; it was asking for 560, which put the bottom of the result
list under the keys.

Font down from 10.5 to 9 and the corner rounding from 10 to 6. Both are
vertical wins: a smaller face fits more results in the space the keyboard
leaves, which is the only space that matters here.

Two more from reading the schema rather than the symptom:

- activate_on_single_click, which defaults to *false*. On a touchscreen that
  is a row which lights up and does nothing -- indistinguishable from a dead
  control, and this shell has shipped four of those.
- escape_key_behavior = close_window, so there is a way out that is not the
  compositor. There is no hardware escape here, but the on-screen keyboard
  can send one, and 'back' from the root search already does nothing.

No close *button*: vicinae does not expose one. close_on_focus_loss exists
but its own documentation says it has no effect while the window is a layer
surface with exclusive keyboard interactivity, which is the default and is
what gives it the keyboard the moment it opens. Trading that away to gain
tap-outside-to-close is a real choice and not mine to make quietly.
feat(pinephone): suspend when idle
Some checks failed
/ check-format (push) Successful in 10s
/ build (push) Has been cancelled
e500c5a709
Closes #52.

A phone that never sleeps lasts an afternoon, and this one measurably never
slept: zero `PM: suspend entry` in 74 minutes of uptime, with nothing holding
a wakelock. Suspend was not blocked, it was never asked for.

Safe to ask for now and not before, because the shell finally takes a sleep
inhibitor for the things that must not be interrupted -- a live call above
all. logind honours inhibitors; until this week there were none to honour, so
turning this on would have suspended the phone mid-call.

logind's idle action rather than /sys/power/autosleep: the Android-style model
suspends between wakelocks and assumes every subsystem takes them. Nothing
here does, so it would ignore the inhibitors rather than compose with them.

Two minutes to start with, deliberately generous. The failure mode this can
cause is a missed call -- the modem's ring-indicator wake is still a TODO in
the supervisor firmware, so whether an incoming call wakes the phone is
untested and needs a SIM to find out. Better to learn that over two minutes
than twenty seconds. Tighten once it is proved.
chore(pinephone): pick up the sleep inhibitors
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m14s
8b919f9aeb
revert(pinephone): stop suspending on idle until resume clears the idle state
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m18s
163fa62f98
Reopens #52.

Reported within minutes of it landing: pressing power to wake the screen, and
the screen going black again a second or two later.

logind never learns that the user did anything. HandlePowerKey is "ignore" --
deliberately, the shell reads the power key from evdev so it can decide
between blanking and suspending -- and touch goes to niri. So nothing clears
the session's idle state. On resume it is exactly as idle as it was before
suspending, IdleActionSec is long past, and the phone suspends again
immediately.

A phone that sleeps two seconds after you wake it is worse than one that never
sleeps. That trade was written down in the decision doc for this issue and
then not protected against, because I enabled it without any way to verify a
suspend/resume cycle -- the one stage of the plan that needed the device.

The sleep inhibitors stay. They are useful on their own and fix a real bug on
the manual path: a power press mid-call used to suspend the phone.

To re-enable, the shell has to tell logind about activity -- SetIdleHint(false)
on its session when it turns the display on -- and that has to be watched
through an actual suspend and wake before this line comes back.
chore(pinephone): pick up the modifier row and the launcher keyboard
All checks were successful
/ check-format (push) Successful in 12s
/ build (push) Successful in 6m14s
618e32696a
chore(pinephone): pick up shell-side idle suspend
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m16s
c37966d83f
chore(pinephone): pick up the idle-suspend ordering fix
Some checks failed
/ check-format (push) Successful in 12s
/ build (push) Has been cancelled
6d83b2b1ce
chore(pinephone): pick up the power-key debounce
All checks were successful
/ check-format (push) Successful in 12s
/ build (push) Successful in 6m13s
25c3afc78f
chore(pinephone): pick up the power press duration check
All checks were successful
/ check-format (push) Successful in 12s
/ build (push) Successful in 6m28s
bfd483f438
chore(pinephone): pick up the power/suspend matrix and the settings interaction fix
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m20s
69d259aeed
chore(pinephone): pick up the resume grace period
Some checks failed
/ check-format (push) Successful in 12s
/ build (push) Has been cancelled
7c91c75952
chore(pinephone): pick up the idle-flag reset on resume
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
d68c5f80c6
chore(pinephone): pick up resume detection for every suspend
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m21s
e948476f46
chore(pinephone): settings readable before unlock
Some checks failed
/ check-format (push) Successful in 34s
/ build (push) Has been cancelled
a65b71662f
chore(pinephone): migrate settings forward on first read
All checks were successful
/ check-format (push) Successful in 14s
/ build (push) Successful in 7m2s
6ffb8621a6
chore(pinephone): quick settings tiles respond again
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m31s
f0154b3e31
chore(pinephone): screen-on clears the idle flag
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m28s
0f8d6be51e
chore(pinephone): a locked phone blanks its screen
All checks were successful
/ check-format (push) Successful in 13s
/ build (push) Successful in 6m40s
a9ad74a78f
feat(pinephone): guarantee a wake alarm before every suspend
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m33s
e4bef01a7a
The shell arms the RTC on its way to sleep, but it is not the only
thing that suspends this phone. Something calls Suspend on the system
bus on a power press before the shell has decided (issue #71), and the
shell's resume handler clears the alarm once it has fired -- so between
a resume and the next shell-initiated suspend the register is empty.

A suspend landing in that window has no wake source at all: no message
poll, and a morning alarm that never fires. Measured 2026-08-06, one
such suspend lasted 3h08m against a fifteen minute poll interval, with
wakealarm empty.

Ordered before sleep.target so it runs whatever asked for the sleep. It
fills only an empty register, so a nearer alarm the shell set for a
clock deadline still wins -- a floor, not a policy.
All checks were successful
/ check-format (push) Successful in 11s
Required
Details
/ build (push) Successful in 6m33s
Required
Details
This pull request is marked as a work in progress.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin pinephone-niri:pinephone-niri
git switch pinephone-niri
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lytedev/nix!933
No description provided.