Build and install the native front-end on musicdolphin

slint-frontend/ in the app repo is a native front-end for the same backend, written
because the React UI is sluggish on this device. This installs it beside the web UI,
never instead of it: the systemd unit is installed but not enabled, so the device still
boots into the kiosk browser and the two can be compared on the same screen.

  systemctl start musikdelfin     # takes over the display
  systemctl stop  musikdelfin     # back to the browser underneath

Unlike pi_musicmouse, which builds the web front-end on the control machine and rsyncs
dist/, this builds on the device. A Rust binary is not portable, and the reason it has
to be built here rather than cross-compiled is worth writing down: these Pis run 32-bit
armhf Raspbian on a 64-bit kernel. `uname -m` says aarch64 and it is misleading;
`dpkg --print-architecture` says armhf and that is the one that matters. Cross-compiling
to armv7-unknown-linux-gnueabihf needs a sysroot, the devices carry no -dev packages to
build one from, and reconstructing one from the Debian archive means owning a glibc skew
risk indefinitely. Spending the device's own CPU once is the smaller liability. The
build is stamped with the commit it came from, so it only runs again when that changes.

The same fact rules out the Skia renderer that the app's own kiosk profile prefers:
rust-skia publishes no prebuilt binaries for armhf. This builds --features desktop -
winit, FemtoVG and the software renderer - and runs as an X client in the existing
session. Worth noting after the fact: the FemtoVG bug that makes Skia the upstream
recommendation (slint-ui/slint#11785, album covers drawn as solid black) was reported on
a Pi 5 with a 64-bit userland and does not reproduce here - covers render correctly.

Three things had to be found by running it rather than reading anything:

The X session belongs to `kiosk`, not root, so the launcher needs that user's cookie and
the unit runs as them. Without it winit fails with "could not initialize backend", which
says nothing about authorization.

winit and glutin dlopen every windowing and GL library they use, so none of them appear
in `ldd` and the binary links happily without them - then aborts on first run with
"Library libxkbcommon-x11.so could not be loaded". The runtime packages are listed
explicitly for that reason.

Raspbian ships no font containing the emoji the design uses as iconography, so the shelf
badges rendered as empty boxes until fonts-noto-color-emoji went in.

First build took 63 minutes: 570 crates, two jobs, nice'd, which bounds peak memory on
musicdolphin's 1.8 GB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 00:44:54 +02:00
parent 8c225bbe06
commit 2fb290b146
9 changed files with 308 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
---
- name: Packages needed to build the front-end
ansible.builtin.apt:
name:
- build-essential
- pkg-config
# The only C libraries the binary links: everything else - X11, Wayland, EGL,
# GL - is dlopen'd at runtime by winit and glutin, so it is not needed to build.
- libfontconfig1-dev
- libfreetype-dev
- curl
# Runtime, not build: winit and glutin dlopen all of these, so they never show
# up in `ldd` and the binary links happily without them - it then aborts on
# first run with "Library libxkbcommon-x11.so could not be loaded".
- libxkbcommon0
- libxkbcommon-x11-0
- libx11-xcb1
- libxcb-xkb1
- libxcursor1
- libxi6
- libxrandr2
- libgl1
- libegl1
- libgles2
# The design uses emoji as iconography - the shelf badges, the figurine marker,
# the scene sparkle. Raspbian ships no font that has them, so without these they
# render as empty boxes. Symbola is the monochrome fallback for the glyphs Noto
# does not cover.
- fonts-noto-color-emoji
- fonts-symbola
cache_valid_time: 7200
- name: Check for a rust toolchain
ansible.builtin.stat:
path: /root/.cargo/bin/cargo
register: pi_slint_frontend_cargo
- name: Fetch the rustup installer
ansible.builtin.get_url:
url: https://sh.rustup.rs
dest: /tmp/rustup-init.sh
mode: "0755"
when: not pi_slint_frontend_cargo.stat.exists
- name: Install the rust toolchain
# Debian 12 ships rustc 1.63, which is far too old for Slint, so this comes from
# rustup rather than apt.
ansible.builtin.command:
cmd: "/tmp/rustup-init.sh -y --profile minimal --default-toolchain {{ pi_slint_frontend_toolchain }}"
creates: /root/.cargo/bin/cargo
when: not pi_slint_frontend_cargo.stat.exists
- name: Check the checkout has a slint-frontend
ansible.builtin.stat:
path: "{{ pi_slint_frontend_root }}/Cargo.toml"
register: pi_slint_frontend_manifest
when: not ansible_check_mode
- name: Fail on a checkout without the front-end
ansible.builtin.fail:
msg: >-
{{ musicmouse_version }} has no slint-frontend/, so it predates the native
front-end. Point musicmouse_version at a branch that has it, or set
mediapi_install_slint_frontend to false for this host.
when: not ansible_check_mode and not pi_slint_frontend_manifest.stat.exists
- name: Fail when the checkout's commit is unknown
# This role builds what pi_musicmouse checked out, and reuses its record of which
# commit that was. With mediapi_install_kidsmusic false that role never ran, so
# there is no checkout to build and no commit to stamp - and the stamp comparison
# below would compare against an empty string and rebuild on every run.
ansible.builtin.fail:
msg: >-
pi_slint_frontend needs pi_musicmouse to have run first - it builds the
slint-frontend/ inside the checkout that role owns. Set
mediapi_install_kidsmusic to true for this host, or
mediapi_install_slint_frontend to false.
when: not ansible_check_mode and pi_musicmouse_commit | length == 0
- name: Read which commit the binary was built from
# The same guard pi_musicmouse uses for the venv and the web dist, for the same
# reason: hanging a 40-minute build off "the checkout changed" strands the device
# when a run fails after the checkout, because the next run finds it current and
# rebuilds nothing.
ansible.builtin.command:
cmd: "cat {{ pi_slint_frontend_root }}/.built-commit"
register: pi_slint_frontend_stamp
changed_when: false
failed_when: false
- name: Build the front-end
# First build takes roughly 40 minutes on a Pi 4 - 570 crates, two jobs. Later ones
# are incremental. `async` keeps the SSH connection from idling out underneath it.
ansible.builtin.command:
cmd: >-
nice -n 10 /root/.cargo/bin/cargo build --release
--no-default-features --features {{ pi_slint_frontend_features }}
-j {{ pi_slint_frontend_jobs }}
chdir: "{{ pi_slint_frontend_root }}"
environment:
# The committed release profile is tuned for a workstation. Thin LTO with
# codegen-units=1 is a memory risk on 1.8 GB and buys nothing that matters here.
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
PATH: "/root/.cargo/bin:{{ ansible_env.PATH }}"
async: 7200
poll: 30
changed_when: true
when: >-
not ansible_check_mode
and pi_slint_frontend_stamp.stdout | trim != pi_musicmouse_commit
- name: Record which commit the binary was built from
ansible.builtin.copy:
content: "{{ pi_musicmouse_commit }}\n"
dest: "{{ pi_slint_frontend_root }}/.built-commit"
mode: "0644"
when: not ansible_check_mode
- name: Find the X session user's home directory
ansible.builtin.getent:
database: passwd
key: "{{ pi_slint_frontend_x_user }}"
- name: Note where the X cookie lives
ansible.builtin.set_fact:
pi_slint_frontend_x_home: "{{ ansible_facts.getent_passwd[pi_slint_frontend_x_user][4] }}"
- name: Install the launcher
ansible.builtin.template:
src: musikdelfin.sh.j2
dest: /usr/local/bin/musikdelfin
mode: "0755"
- name: Install the systemd unit
# Installed but deliberately not enabled: this is the thing under test, and the
# kiosk browser is still what the device shows on boot. Start it by hand -
# `systemctl start musikdelfin` - or run /usr/local/bin/musikdelfin over SSH.
ansible.builtin.template:
src: musikdelfin.service.j2
dest: /etc/systemd/system/musikdelfin.service
mode: "0644"
notify: Reload systemd