Ship I38 in Pi images by default

Add the I38 desktop stack to the Raspberry Pi image build, including Cthulhu, XLibre, nodm, xorg-xinit, and I38 runtime Python dependencies.

Generate the default stormux user's I38 configuration during image creation with the non-interactive I38 mode, configure nodm for that generated xinitrc, and set Speech Dispatcher to use RHVoice by default.

Harden the Pi build for larger GUI images by raising the default image size to 8 GB, skipping mkinitcpio autodetect for the target image initramfs, and validating the root filesystem before compression.

Rework first boot so volume calibration runs before Fenrir starts, temporary tty1 autologin and passwordless sudo are removed during setup, I38/nodm startup is selected explicitly, and physical versus headless X configuration is written before nodm starts.

Document the Pi GUI rollout in gui.md and refresh README size and desktop references.
This commit is contained in:
Storm Dragon
2026-06-29 15:59:33 -04:00
parent 5ec1d7727d
commit d67ca0f3e0
6 changed files with 237 additions and 34 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
# Stormux Pi Image Builder
Stormux provides an Arch-based Raspberry Pi image that already includes Fenrir, Orca, and other screen-reader friendly defaults. This repository hosts the scripts maintained by the Stormux community (originally crafted by Storm) so anyone can rebuild the image, customize the overlay, and share accessible Pi spins.
Stormux provides an Arch-based Raspberry Pi image that already includes Fenrir, Cthulhu, I38, and other screen-reader friendly defaults. This repository hosts the scripts maintained by the Stormux community (originally crafted by Storm) so anyone can rebuild the image, customize the overlay, and share accessible Pi spins.
## Purpose & Scope
This repo captures the complete build pipeline for the Stormux Raspberry Pi images: bootstrapping Arch Linux ARM, layering the accessible defaults, installing Fenrir and helper tools, and producing a compressed ready-to-flash image. Running the script yourself lets you reproduce the same system the project ships for Raspberry Pi 4/5-class systems, tweak it, and publish new spins without reverse-engineering the original release.
@@ -9,7 +9,7 @@ This repo captures the complete build pipeline for the Stormux Raspberry Pi imag
- A 64-bit Arch Linux host (bare metal or VM) with at least 20GB free disk space.
- Root access on that host. The builder partitions loopback devices and must run as root.
- Required packages: `arch-install-scripts`, `dosfstools`, `parted`, `qemu-user-static`, `qemu-user-static-binfmt`, plus standard developer tools that Arch already ships.
- A Raspberry Pi 4, Raspberry Pi 400, Raspberry Pi 5, or compatible board, plus a microSD card (8 GB or larger recommended).
- A Raspberry Pi 4, Raspberry Pi 400, Raspberry Pi 5, or compatible board, plus a microSD card (16 GB or larger recommended).
Install dependencies on the build host with:
```bash
@@ -31,13 +31,13 @@ The script is designed for an x86_64 Arch host so it can emulate ARM binaries wi
1. Become root (`sudo -i`) so the script can create loop devices and mount them.
2. From `/path/to/stormux`, run:
```bash
sudo ./pi4/build/build-stormux.sh -l en_US -s 6
sudo ./pi4/build/build-stormux.sh -l en_US -s 8
```
- The builder creates an aarch64 image for Raspberry Pi 4/5-class systems.
- `-l en_US` selects the locale (use `es_ES`, `fr_FR`, etc.).
- `-s 6` sets the image size in gigabytes.
- `-s 8` sets the image size in gigabytes.
- Add `-n my-stormux.img` to override the default filename.
3. Grab coffee. The script bootstraps Arch Linux ARM, provisions packages (Fenrir, Orca, NetworkManager, PipeWire, etc.), copies the overlay from `pi4/files/`, installs the latest `sas`, cleans up, compresses the image, and writes a SHA-1 checksum.
3. Grab coffee. The script bootstraps Arch Linux ARM, provisions packages (Fenrir, Cthulhu, I38, XLibre, NetworkManager, PipeWire, etc.), copies the overlay from `pi4/files/`, installs the latest `sas`, cleans up, compresses the image, and writes a SHA-1 checksum.
4. When the `build-stormux.sh` command returns, you should have an `.img.xz` file and matching `.sha1sum` in your working directory (for example `stormux-rpi4-5-aarch64-YYYY-MM-DD.img.xz`).
If the build is interrupted, run `sudo umount -R /mnt && sudo losetup -D` to be sure nothing is still mounted.
+65 -7
View File
@@ -68,6 +68,14 @@ cleanup_image() {
return "$cleanupStatus"
}
# shellcheck disable=SC2329 # validate_image_filesystems is invoked from finish_build
validate_image_filesystems() {
local rootPartition="${loopdev}p2"
echo "Checking root filesystem..."
e2fsck -f -n "$rootPartition"
}
# shellcheck disable=SC2329 # cleanup is invoked via trap EXIT
cleanup() {
local status=$? # capture original exit status so failures propagate
@@ -86,6 +94,19 @@ cleanup() {
}
finish_build() {
if [[ $mounted -eq 0 ]]; then
if ! umount -R /mnt; then
echo "Image build commands completed, but unmounting failed."
exit 1
fi
mounted=1
fi
if ! validate_image_filesystems; then
echo "Image filesystem validation failed."
exit 1
fi
trap - EXIT
if ! cleanup_image; then
@@ -148,7 +169,7 @@ declare -A command=(
[h]="This help screen."
[l:]="Language default is en_US."
[n:]="Image name, default is stormux-rpi4-5-aarch64-<yyyy-mm-dd>.img"
[s:]="image size in GB, default is 6."
[s:]="image size in GB, default is 8."
)
# Convert the keys of the associative array to a format usable by getopts
@@ -178,7 +199,7 @@ while getopts "${args}" i ; do
done
# make sure variables are set, or use defaults.
export imageSize="${imageSize:-6G}"
export imageSize="${imageSize:-8G}"
imageName="${imageName:-stormux-rpi4-5-aarch64-$(date '+%Y-%m-%d').img}"
export imageName
export imageLanguage="${imageLanguage:-en_US.UTF-8}"
@@ -362,6 +383,7 @@ packages=(
gstreamer
gst-plugins-base
gst-plugins-good
i38
ii
# Keep Pi onboard firmware plus common USB/network chipset firmware without
# pulling in unrelated desktop/server GPU firmware.
@@ -373,6 +395,7 @@ packages=(
man
man-pages
networkmanager
nodm-dgw
openssh
parted
pipewire
@@ -382,10 +405,13 @@ packages=(
poppler
python-dbus
python-gobject
python-msgpack
python-pyenchant
python-pyte
python-pyperclip
python-tornado
raspberrypi-utils
cthulhu
socat
realtime-privileges
rhvoice-voice-bdl
@@ -401,6 +427,11 @@ packages=(
vi
xdg-user-dirs
xdg-utils
xlibre-input-libinput
xlibre-video-dummy-with-vt
xlibre-video-fbdev
xlibre-xserver
xorg-xinit
yay
)
@@ -413,7 +444,7 @@ install_sas
# See: https://archlinuxarm.org/forum/viewtopic.php?t=16672
if [[ -f /etc/mkinitcpio.d/linux-rpi.preset ]]; then
echo "Configuring mkinitcpio preset to skip kms hook..."
sed -i "s/^default_options=.*/default_options=\"--skiphook kms\"/" /etc/mkinitcpio.d/linux-rpi.preset
sed -i "s/^default_options=.*/default_options=\"-S autodetect --skiphook kms\"/" /etc/mkinitcpio.d/linux-rpi.preset
sed -i "s/^fallback_options=.*/fallback_options=\"-S autodetect --skiphook kms\"/" /etc/mkinitcpio.d/linux-rpi.preset
fi
@@ -435,8 +466,10 @@ echo 'Stormux \r (\l)' > /etc/issue
echo >> /etc/issue
# Create the stormux user
useradd -m -g users -G wheel,realtime,audio,video,network,brlapi -s /bin/bash stormux
# Grant sudo privileges to the stormux user for package installation.
echo 'stormux ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers.d/wheel
# Grant normal sudo privileges plus temporary first-boot passwordless sudo.
echo '%wheel ALL=(ALL) ALL' > /etc/sudoers.d/wheel
echo 'stormux ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/zz-stormux-first-boot
chmod 440 /etc/sudoers.d/wheel /etc/sudoers.d/zz-stormux-first-boot
# Set the password for the root user
echo -e "root\nroot" | passwd "root"
# Set the password for the stormux user
@@ -449,13 +482,39 @@ git config --global init.defaultBranch master
# Create desktop, downloads, music, and other directories.
xdg-user-dirs-update
exit
mkdir -p /home/stormux/git
chown stormux:users /home/stormux/git
if ! sudo -u stormux git clone --depth 1 https://git.stormux.org/storm/I38 /home/stormux/git/I38; then
echo "Failed to clone I38."
exit 1
fi
cd /home/stormux/git/I38
sudo -u stormux ./i38.sh -D
cd /
if [[ -f /etc/nodm.conf ]]; then
sed -i \
-e "s/{user}/stormux/g" \
-e "s#^NODM_USER=.*#NODM_USER='stormux'#" \
-e "s#^NODM_XSESSION=.*#NODM_XSESSION='/home/stormux/.xinitrc'#" \
/etc/nodm.conf
fi
if command -v spd-conf > /dev/null 2>&1; then
spd-conf -n -C || echo "Warning: Speech Dispatcher system configuration failed."
fi
if [[ -f /etc/speech-dispatcher/speechd.conf ]]; then
if grep -qE '^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+' /etc/speech-dispatcher/speechd.conf; then
sed -i -E 's/^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+.*/DefaultModule rhvoice/' /etc/speech-dispatcher/speechd.conf
else
printf '\nDefaultModule rhvoice\n' >> /etc/speech-dispatcher/speechd.conf
fi
fi
# Enable linger so that hopefully sound will start at login.
mkdir -p /var/lib/systemd/linger
touch /var/lib/systemd/linger/stormux
systemctl --global enable pipewire.service pipewire-pulse.service
/usr/share/fenrirscreenreader/tools/configure_pipewire.sh
sudo -u stormux /usr/share/fenrirscreenreader/tools/configure_pipewire.sh
# Configure sudo for group wheel, remove nopasswd for the stormux user
# Ensure normal sudo remains configured after first-boot cleanup removes NOPASSWD.
echo '%wheel ALL=(ALL) ALL' > /etc/sudoers.d/wheel
# Set the hostname
echo stormux > /etc/hostname
@@ -466,7 +525,6 @@ services=(
brltty.path
cronie.service
fake-hwclock.service
fenrirscreenreader.service
log-to-ram-setup.service
log-to-ram-sync.timer
log-to-ram-shutdown.service
+19 -7
View File
@@ -12,6 +12,19 @@ if ! [[ -x /usr/local/bin/configure-stormux ]]; then
return
fi
firstBootSudoersFile="/etc/sudoers.d/zz-stormux-first-boot"
firstBootAutologinFile="/etc/systemd/system/getty@tty1.service.d/stormux-first-boot-autologin.conf"
firstBootAutologinDir="${firstBootAutologinFile%/*}"
cleanup_first_boot_access() {
trap - RETURN HUP INT TERM
if [[ -e "$firstBootSudoersFile" || -e "$firstBootAutologinFile" ]]; then
sudo -n /bin/sh -c "rm -f '$firstBootSudoersFile' '$firstBootAutologinFile'; rmdir --ignore-fail-on-non-empty '$firstBootAutologinDir'; systemctl daemon-reload" 2>/dev/null || true
fi
}
trap cleanup_first_boot_access RETURN HUP INT TERM
# Volume calibration FIRST
echo "Setting up audio volume..."
volume=50
@@ -32,7 +45,7 @@ while [[ $volume -le 130 ]]; do
clear
pactl set-sink-volume @DEFAULT_SINK@ "${volume}%" 2>/dev/null
spd-say "If this is loud enough, press enter."
if read -t4 ; then
if read -r -t4 ; then
echo "Volume set to ${volume}%"
break
else
@@ -47,16 +60,15 @@ if [[ -x /etc/audibleprompt.sh ]]; then
export sudoFlags=("-A")
fi
echo
echo "Starting Fenrir..."
spd-say "Starting Fenrir."
sudo "${sudoFlags[@]}" systemctl --quiet enable --now fenrirscreenreader.service
clear
cat << "EOF"
Hello, and welcome to Stormux!
Let's get you set up. After you press enter, you will be prompted for the sudo password.
When that happens, type the word stormux and press enter.
You will not receive any speech feedback for this process.
That is completely normal, and speech will return after you have typed the password.
Once again, the password is stormux in all lower case letters.
Please press enter to continue.
EOF
read -r
@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=-/usr/bin/agetty --autologin stormux --noclear %I $TERM
+119 -15
View File
@@ -28,6 +28,81 @@ fi
# Volume calibration is now handled in stormux_first_boot.sh
export DIALOGOPTS='--insecure --no-lines --visit-items'
i38StartAtBoot=0
firstBootSudoersFile="/etc/sudoers.d/zz-stormux-first-boot"
firstBootAutologinFile="/etc/systemd/system/getty@tty1.service.d/stormux-first-boot-autologin.conf"
firstBootAutologinDir="${firstBootAutologinFile%/*}"
read_yes_no() {
local answer
while true; do
echo "$1"
echo "Press y for yes or n for no followed by enter."
read -r answer
answer="${answer::1}"
case "${answer,,}" in
y)
return 0
;;
n)
return 1
;;
esac
echo "Please answer y or n."
done
}
cleanup_first_boot_access() {
rm -f "$firstBootSudoersFile" "$firstBootAutologinFile"
rmdir --ignore-fail-on-non-empty "$firstBootAutologinDir"
systemctl daemon-reload
}
configure_physical_screen() {
sudo "${sudoFlags[@]}" mkdir -p /etc/X11/xorg.conf.d
sudo "${sudoFlags[@]}" tee /etc/X11/xorg.conf.d/10-screendriver.conf > /dev/null << 'EOF'
Section "OutputClass"
Identifier "vc4"
MatchDriver "vc4"
Driver "modesetting"
Option "PrimaryGPU" "true"
EndSection
EOF
}
configure_headless_screen() {
sudo "${sudoFlags[@]}" mkdir -p /etc/X11/xorg.conf.d
sudo "${sudoFlags[@]}" tee /etc/X11/xorg.conf.d/10-screendriver.conf > /dev/null << 'EOF'
Section "Monitor"
Identifier "dummy_monitor"
HorizSync 28.0-80.0
VertRefresh 48.0-75.0
Modeline "1920x1080" 172.80 1920 2040 2248 2576 1080 1081 1084 1118
EndSection
Section "Device"
Identifier "dummy_card"
Driver "dummy"
VideoRam 256000
Option "UseFBDev" "false"
EndSection
Section "Screen"
Identifier "dummy_screen"
Device "dummy_card"
Monitor "dummy_monitor"
SubSection "Display"
Depth 24
Modes "1920x1080"
EndSubSection
EndSection
Section "ServerLayout"
Identifier "dummy_layout"
Screen 0 "dummy_screen"
EndSection
EOF
}
set_timezone() {
# Get the list of timezones
@@ -58,26 +133,40 @@ set_timezone() {
timedatectl set-ntp true
}
# Offer to switch fenrir layout.
echo "Would you like to switch Fenrir to laptop layout?"
echo "Press y for yes or n for no followed by enter."
read -r continue
continue="${continue::1}"
if [[ "${continue,}" == "y" ]];then
if read_yes_no "Would you like to switch Fenrir to laptop layout?"; then
sed -i 's/=desktop/=laptop/' /etc/fenrirscreenreader/settings/settings.conf
clear
systemctl restart fenrirscreenreader.service
fi
if command -v i3 > /dev/null 2>&1 && systemctl list-unit-files nodm.service > /dev/null 2>&1; then
if read_yes_no "Will this Pi be connected to a physical screen?"; then
configure_physical_screen
else
configure_headless_screen
fi
if read_yes_no "Should I38 start at boot?"; then
sudo "${sudoFlags[@]}" systemctl --quiet enable nodm.service
i38StartAtBoot=1
if read_yes_no "Should Fenrir run in the console at boot?"; then
sudo "${sudoFlags[@]}" systemctl --quiet enable fenrirscreenreader.service
else
sudo "${sudoFlags[@]}" systemctl --quiet disable fenrirscreenreader.service
fi
else
sudo "${sudoFlags[@]}" systemctl --quiet disable nodm.service
sudo "${sudoFlags[@]}" systemctl --quiet enable fenrirscreenreader.service
fi
fi
# Check for possible resize
diskSource="$(df --output='source' / | tail -1)"
diskSize="$(df -h --output='size' / | tail -1 | tr -cd '[:digit:].')"
diskSize=${diskSize%.*}
if [[ $diskSize -le 7 ]]; then
echo "$diskSource is only $diskSize gigs, which means it probably needs to be resized. Would you like to do this now?"
echo "Press y for yes or n for no followed by enter."
read -r continue
continue="${continue::1}"
if [[ "${continue,}" == "y" ]];then
resizeThreshold=9
if [[ $diskSize -le $resizeThreshold ]]; then
if read_yes_no "$diskSource is only $diskSize gigs, which means it probably needs to be resized. Would you like to do this now?"; then
resizeLog="/tmp/stormux-resize.log"
# Extract base device name (handles mmcblk0p2, nvme0n1p2, sda2, etc.)
if [[ "$diskSource" =~ (.*[0-9]+)p[0-9]+$ ]]; then
# Handle mmcblk0p2, nvme0n1p2 style
@@ -87,14 +176,19 @@ if [[ $diskSize -le 7 ]]; then
# shellcheck disable=SC2001
diskDevice="$(echo "$diskSource" | sed 's/[0-9]*$//')"
fi
echo "Yes" | sudo "${sudoFlags[@]}" parted ---pretend-input-tty "$diskDevice" resizepart 2 100%
sudo "${sudoFlags[@]}" resize2fs -f "$diskSource"
echo "Resizing the filesystem. This may take a few minutes."
if { echo "Yes" | sudo "${sudoFlags[@]}" parted ---pretend-input-tty "$diskDevice" resizepart 2 100%; } > "$resizeLog" 2>&1 \
&& { sudo "${sudoFlags[@]}" resize2fs -f "$diskSource"; } >> "$resizeLog" 2>&1; then
echo "Resize complete."
else
echo "Resize failed. Details are in $resizeLog."
fi
fi
fi
if ! ping -c1 stormux.org &> /dev/null ; then
echo "No internet connection detected. Press enter to open NetworkManager."
read -r continue
read -r _
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
nmtui-connect
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
@@ -112,7 +206,7 @@ if ping -qc1 -W 1 stormux.org &> /dev/null; then
read -rp "Would you like the time to sync as soon as the Pi connects to the internet? " answer
answer="${answer:0:1}"
if [[ "${answer,,}" == "y" ]]; then
systemctl enable time_sync_at_boot.service
systemctl --quiet enable time_sync_at_boot.service
else
echo "Time sync at boot skipped."
echo "If you change your mind later, simply type:"
@@ -141,5 +235,15 @@ echo "or you may configure your system manually."
echo
echo "Thank you for choosing Stormux."
echo
read -rp "Press enter to continue."
cleanup_first_boot_access
if [[ $i38StartAtBoot -eq 1 ]]; then
echo
echo "Starting I38..."
sudo "${sudoFlags[@]}" systemctl --quiet start nodm.service
fi
exit 0
+26
View File
@@ -0,0 +1,26 @@
# Ship I38 By Default On Pi Stormux
## Planned
- [ ] Build and boot-test a Pi image with the new GUI defaults.
- [ ] Port the proven Pi behavior to the x86_64 installer/profile after the Pi image is verified.
## In Progress
- No active implementation items.
## Done
- [x] Bump the default Pi image size from 6 GB to 8 GB.
- [x] Raise the first-boot resize prompt threshold to 9 GB.
- [x] Configure Speech Dispatcher during image creation when `spd-conf` is available.
- [x] Add I38, Cthulhu, XLibre, nodm, Speech Dispatcher, and startx support to the Pi image.
- [x] Generate the default user's I38 config during image creation with `i38.sh -D`.
- [x] Ask during first boot whether I38 should start at boot, and whether Fenrir should stay enabled for the console.
- [x] Convert the external `configure-stormux` I38 action into a removal/purge action.
## Deferred x86_64 Follow-up
- [ ] Replace interactive I38 setup in the x86_64 installer with the same non-interactive `i38.sh -D` path where appropriate.
- [ ] Review x86_64 package lists against the Pi package set after the Pi build is proven.
- [ ] Re-check nodm and Fenrir service policy for installed x86_64 systems.