Compare commits

18 Commits

Author SHA1 Message Date
Storm Dragon 5ec1d7727d Steam now offered in x86_64 installer. Fix up the rhvoice dictionary copy. A few minor cleanups. Rename pi images to more clearly show they support both Raspberry Pi 4 and 5. 2026-06-23 19:40:06 -04:00
Storm Dragon 426069f36a Xlibre build script updated. 2026-06-21 16:21:37 -04:00
Storm Dragon 4aeff109f7 Latest fixes for x86_64 installer. Updated README for Pi images. 2026-06-21 12:56:38 -04:00
Storm Dragon 46e3f0d084 Make sure GUI DISPLAY is set. 2026-06-06 14:22:07 -04:00
Storm Dragon 13e3ce64fd Fix bug in select loop so multiple options do not appear on the same line. 2026-06-05 13:16:48 -04:00
Storm Dragon f6e2e2f4c8 added sas to both images. A few minor updates to image generation for pi images. 2026-06-04 23:52:44 -04:00
Storm Dragon c94a71e0d6 Missed some deamon to daemon updates. This shoulf make Fenrir work with the installer automatically again, switching modes, etc. 2026-06-03 00:34:19 -04:00
Storm Dragon f1dfe1737b qemu-boot.sh now checks for required packages. 2026-06-01 19:37:53 -04:00
Storm Dragon eb36d6d976 Fixed typo, or loss of current thought or something lol. 2026-05-24 22:19:14 -04:00
Storm Dragon 040eecca09 Build script compresses the image and generates the sha1sum now. 2026-05-24 21:27:07 -04:00
Storm Dragon 973b2573c8 Couple improvements to the install-stormux script. make sure hostname doesn't contain spaces. Try to make sure current information for mirrors even if current iso is a bit out of date. 2026-05-23 06:44:20 -04:00
Storm Dragon 9a0a6fef00 Update install-stormux script. 2026-05-11 21:34:43 -04:00
Storm Dragon 9496559875 A couple of management scripts added. 2026-04-03 07:27:38 -04:00
Storm Dragon ddab0d827f Update xlibre dummy with vt srcinfo 2026-04-02 19:28:40 -04:00
Storm Dragon aacdf8eb4a Rebase xlibre dummy with vt packaging 2026-04-02 19:28:36 -04:00
Storm Dragon a6c65ca973 Fix XLibre updater package order 2026-04-02 19:20:12 -04:00
Storm Dragon 19fd4b1ed4 Add XLibre updater design spec 2026-04-02 18:21:21 -04:00
Storm Dragon 185d098bdd First pass at custom mount options. Everything should be mounted on /mnt. 2026-02-26 14:35:13 -05:00
27 changed files with 2113 additions and 1438 deletions
+1
View File
@@ -5,3 +5,4 @@ CLAUDE.md
*.sha1sum
*.xz
*.zst
scripts/xlibre-video-dummy-with-vt/
+17 -16
View File
@@ -3,17 +3,17 @@
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.
## Purpose & Scope
This repo captures the complete build pipeline for the Stormux Raspberry Pi images: downloading Arch Linux ARM, layering the accessible defaults, compiling Fenrir and helper tools, and producing a ready-to-flash `.img`. Running the script yourself lets you reproduce the same system the project ships for the Pi 4/400, tweak it, and publish new spins without reverse-engineering the original release.
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.
## What You Need
- 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`, `wget`, `qemu-user-static`, `qemu-user-static-binfmt`, plus standard developer tools that Arch already ships.
- A Raspberry Pi 4/400 or similar board (a Pi 3 works with the `-v 32` option) and a microSD card (8GB or larger recommended).
- 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).
Install dependencies on the build host with:
```bash
sudo pacman -S arch-install-scripts dosfstools parted wget qemu-user-static qemu-user-static-binfmt
sudo pacman -S arch-install-scripts dosfstools parted qemu-user-static qemu-user-static-binfmt
```
## Getting the Source
@@ -31,21 +31,21 @@ 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 -v 64 -l en_US -s 6
sudo ./pi4/build/build-stormux.sh -l en_US -s 6
```
- `-v 64` builds the aarch64 image (use `-v 32` for the Pi 3/armv7h).
- 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.
- Add `-n my-stormux.img` to override the default filename.
3. Grab coffee. The script downloads the latest Arch Linux ARM tarball, provisions packages (Fenrir, Orca, NetworkManager, PipeWire, etc.), copies the overlay from `pi4/files/`, and cleans up.
4. When the `build-stormux.sh` command returns, you should have an `.img` file in your working directory (for example `stormux-pi4-aarch64-YYYY-MM-DD.img`).
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.
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.
## Customizing the Image
- **Overlay files**: Add or edit files in `pi4/files/` using the same paths they should have inside the Pi. Example: to add a custom MOTD, edit `pi4/files/etc/motd`.
- **Packages**: Edit the package list inside `pi4/build/build-stormux.sh` (search for `pacman -Su --needed`). Add or remove entries as needed, then rebuild.
- **AUR components**: The script already compiles Fenrir (`fenrir-git`), `growpartfs`, `log2ram`, and `yay`. To add more AUR packages, append them to the `aurPackages` array near the middle of the script.
- **Package sources**: The build does not fetch or compile packages from the AUR. Packages are installed from the official Arch Linux ARM repositories or the Stormux repository. The finished image includes `yay`, but it is installed as a prebuilt package from the Stormux repository and is not used during the build.
- **First-boot behavior**: Modify `pi4/files/usr/local/bin/configure-stormux` to change the guided setup that runs when the image boots the first time.
Remember to keep permissions sensible (`chmod 755` for scripts, `644` for configs) so rsync copies them correctly.
@@ -61,20 +61,22 @@ Rebuild after pulling so your custom image inherits any upstream fixes (new pack
## Flashing and Booting
1. Verify the image:
```bash
ls -lh stormux-pi4-*.img
ls -lh stormux-rpi4-5-*.img.xz stormux-rpi4-5-*.img.xz.sha1sum
sha1sum -c stormux-rpi4-5-*.img.xz.sha1sum
```
2. Write it to an SD card (replace `/dev/sdX` with your card, not a partition):
```bash
sudo dd if=stormux-pi4-aarch64-*.img of=/dev/sdX bs=4M status=progress conv=fsync
xzcat stormux-rpi4-5-aarch64-*.img.xz | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
```
3. Insert the card into your Pi 4/400 and power it on. You should hear Fenrir start speaking once the user session loads.
3. Insert the card into your Raspberry Pi and power it on. You should hear Fenrir start speaking once the user session loads.
4. Log in using the default credentials (username `stormux`, password `stormux`). Root uses `root`/`root` until you change it.
5. Run `sudo configure-stormux` (or follow the automatic prompt) to finish the guided setup: configure networking via `nmtui`, update the clock, optionally resize the SD card with `growpartfs`, and toggle Fenrirs layout.
## Testing Without Hardware
You can boot the image in a container to verify services before flashing:
```bash
sudo systemd-nspawn -i stormux-pi4-aarch64-*.img --boot
xz -dk stormux-rpi4-5-aarch64-*.img.xz
sudo systemd-nspawn -i stormux-rpi4-5-aarch64-*.img --boot
```
Use `machinectl` to connect to the console and ensure critical services start. For unit files you edit, run `systemd-analyze verify path/to/unit` inside the container or the Pi.
@@ -86,9 +88,8 @@ Use `machinectl` to connect to the console and ensure critical services start. F
## Sharing Your Image
When you are happy with your customizations:
1. Rebuild so you have a clean `.img` file named clearly (for example, `stormux-pi4-a11y-v1.img`).
2. Compress it (`xz -T0 -z stormux-pi4-a11y-v1.img`).
3. Publish the image along with the exact commit you built from and any extra scripts you used. The README and `AGENTS.md` in this repo explain the layout so others can reproduce your work.
1. Rebuild so you have a clean `.img.xz` file named clearly.
2. Publish the compressed image, its `.sha1sum`, the exact commit you built from, and any extra scripts you used. The README and `AGENTS.md` in this repo explain the layout so others can reproduce your work.
## Huge Thanks to Storm
Storm did the heavy lifting—curating packages, wiring up Fenrir/Orca defaults, scripting the first-boot assistant, and sharing the whole build process so the community can remix it. This repo exists because accessibility was treated as a first-class feature from day one, and the documentation lets anyone extend that work. If the image improves your Pi experience, consider contributing improvements or telling Storm thanks.
+123 -11
View File
@@ -24,18 +24,40 @@ mounted=1
set -e # Don't want to destroy stuff if this goes majorly wrong.
trap cleanup EXIT # make sure the script cleans up after itself before closing.
# shellcheck disable=SC2329 # compress_image is invoked from finish_build
compress_image() {
local compressedImage="${imageName}.xz"
# shellcheck disable=SC2329 # cleanup is invoked via trap EXIT
cleanup() {
status=$? # capture original exit status so failures propagate
if [[ ! -s "${imageName}" ]]; then
echo "Image file ${imageName} was not created or is empty."
return 1
fi
echo "Compressing ${imageName} to ${compressedImage}..."
xz -T0 -9 "${imageName}"
echo "Creating SHA-1 checksum for ${compressedImage}..."
sha1sum "${compressedImage}" > "${compressedImage}.sha1sum"
echo "Image build complete: ${compressedImage}"
echo "Checksum: ${compressedImage}.sha1sum"
}
# shellcheck disable=SC2329 # cleanup_image is invoked from cleanup and finish_build
cleanup_image() {
local cleanupStatus=0
if [[ $mounted -eq 0 ]]; then
umount -R /mnt || true
if ! umount -R /mnt; then
cleanupStatus=1
fi
fi
if [[ -n "${loopdev:-}" ]]; then
partx -d "${loopdev}" || true
losetup --detach "${loopdev}" || true
if ! partx -d "${loopdev}"; then
cleanupStatus=1
fi
if ! losetup --detach "${loopdev}"; then
cleanupStatus=1
fi
fi
# Clean up temporary pacman config directory
@@ -43,9 +65,75 @@ cleanup() {
rm -rf "${tmpDir}"
fi
return "$cleanupStatus"
}
# shellcheck disable=SC2329 # cleanup is invoked via trap EXIT
cleanup() {
local status=$? # capture original exit status so failures propagate
local cleanupStatus=0
if ! cleanup_image; then
cleanupStatus=1
fi
if [[ $status -eq 0 && $cleanupStatus -ne 0 ]]; then
echo "Image build commands completed, but cleanup failed."
status=1
fi
exit "$status"
}
finish_build() {
trap - EXIT
if ! cleanup_image; then
echo "Image build commands completed, but cleanup failed."
exit 1
fi
if ! compress_image; then
exit 1
fi
exit 0
}
copy_rhvoice_english_fixes() {
local sourceDir="../files/etc/RHVoice/dicts/English"
local targetDir="/mnt/etc/RHVoice/dicts/English"
if [[ -d "$sourceDir" ]]; then
mkdir -p "$targetDir"
cp -a "$sourceDir/." "$targetDir/"
fi
}
# shellcheck disable=SC2329 # install_sas is emitted into the chroot heredoc with declare -f
install_sas() {
local sasRepo="https://git.stormux.org/storm/sas"
local sasPath="/usr/local/bin/sas"
local tempDir
local installStatus=0
tempDir="$(mktemp -d)"
echo "Installing latest sas..."
if ! git clone --depth 1 "$sasRepo" "$tempDir"; then
rm -rf "$tempDir"
return 1
fi
rm -f "$sasPath"
if ! install -m 755 "$tempDir/sas.py" "$sasPath"; then
installStatus=1
fi
rm -rf "$tempDir"
return "$installStatus"
}
help() {
echo -e "Usage:\n"
echo "With no arguments, build with default parameters."
@@ -59,7 +147,7 @@ help() {
declare -A command=(
[h]="This help screen."
[l:]="Language default is en_US."
[n:]="Image name, default is stormux-pi4-aarch64-<yyyy-mm-dd>.img"
[n:]="Image name, default is stormux-rpi4-5-aarch64-<yyyy-mm-dd>.img"
[s:]="image size in GB, default is 6."
)
@@ -91,7 +179,7 @@ done
# make sure variables are set, or use defaults.
export imageSize="${imageSize:-6G}"
imageName="${imageName:-stormux-pi4-aarch64-$(date '+%Y-%m-%d').img}"
imageName="${imageName:-stormux-rpi4-5-aarch64-$(date '+%Y-%m-%d').img}"
export imageName
export imageLanguage="${imageLanguage:-en_US.UTF-8}"
@@ -100,6 +188,12 @@ if [[ -e "$imageName" ]]; then
echo "${imageName} exists, please remove or move it for this script to continue."
exit 1
fi
for outputFile in "${imageName}.xz" "${imageName}.xz.sha1sum"; do
if [[ -e "$outputFile" ]]; then
echo "${outputFile} exists, please remove or move it for this script to continue."
exit 1
fi
done
# Make sure this script is ran as root.
if [ "$(whoami)" != "root" ] ; then
@@ -124,6 +218,12 @@ for i in arch-install-scripts dosfstools parted ; do
exit 1
fi
done
for i in sha1sum xz ; do
if ! command -v "$i" &> /dev/null ; then
echo "Please install ${i} before continuing."
exit 1
fi
done
fallocate -l "$imageSize" "$imageName"
@@ -252,17 +352,23 @@ packages=(
bluez-utils
brltty
cronie
curl
dialog
espeak-ng
fake-hwclock
fenrir
firmware-raspberrypi
linux-firmware
git
gstreamer
gst-plugins-base
gst-plugins-good
ii
# Keep Pi onboard firmware plus common USB/network chipset firmware without
# pulling in unrelated desktop/server GPU firmware.
linux-firmware-atheros
linux-firmware-broadcom
linux-firmware-mediatek
linux-firmware-realtek
magic-wormhole
man
man-pages
@@ -287,6 +393,7 @@ packages=(
rsync
screen
sox
speech-dispatcher
w3m-git
wget
wireless-regdb
@@ -299,6 +406,9 @@ packages=(
pacman -Su --needed --noconfirm "\${packages[@]}"
$(declare -f install_sas)
install_sas
# Fix mkinitcpio preset for linux-rpi - kms hook fails on aarch64
# See: https://archlinuxarm.org/forum/viewtopic.php?t=16672
if [[ -f /etc/mkinitcpio.d/linux-rpi.preset ]]; then
@@ -381,11 +491,13 @@ sed -i '/^DisableSandbox/d' /etc/pacman.conf
EOF
copy_rhvoice_english_fixes
# Copy skel files to stormux user home (after user rename in chroot)
find ../files/etc/skel/ -mindepth 1 -exec cp -rv "{}" /mnt/home/stormux/ \;
# Copy boot files again to ensure custom config overrides any package changes
cp -rv ../files/boot/* /mnt/boot
# Exiting calls the cleanup function to unmount.
exit 0
# Clean up, verify, compress, and create the checksum.
finish_build
+12 -8
View File
@@ -7,19 +7,20 @@ if [[ -x /etc/audibleprompt.sh ]]; then
export sudoFlags=("-A")
fi
trap cleanup EXIT
# shellcheck disable=SC2329
cleanup() {
popd &> /dev/null
popd &> /dev/null || true
if ! [[ -x /opt/configure-stormux/configure-stormux.sh ]]; then
echo "Initial setup is not complete."
echo "To continue setup, please run:"
echo "sudo configure-stormux"
fi
}
trap cleanup EXIT
if [[ -x /opt/configure-stormux/configure-stormux.sh ]]; then
pushd /opt/configure-stormux
pushd /opt/configure-stormux || exit 1
./configure-stormux.sh
exit 0
fi
@@ -33,25 +34,27 @@ set_timezone() {
mapfile -t regions < <(timedatectl --no-pager list-timezones | cut -d '/' -f1 | sort -u)
# Use the same text twice here and just hide the tag field.
# shellcheck disable=SC2046
region=$(dialog --backtitle "Please select your Region" \
--no-tags \
--menu "Use up and down arrows or page-up and page-down to navigate the list, and press 'Enter' to make your selection." 0 0 0 \
$(for i in ${regions[@]} ; do echo "$i";echo "$i";done) --stdout)
$(for i in "${regions[@]}" ; do echo "$i";echo "$i";done) --stdout)
mapfile -t cities < <(timedatectl --no-pager list-timezones | grep "$region" | cut -d '/' -f2 | sort -u)
# Use the same text twice here and just hide the tag field.
# shellcheck disable=SC2046
city=$(dialog --backtitle "Please select a city near you" \
--no-tags \
--menu "Use up and down arrow or page-up and page-down to navigate the list." 0 0 10 \
$(for i in ${cities[@]} ; do echo "$i";echo "$i";done) --stdout)
$(for i in "${cities[@]}" ; do echo "$i";echo "$i";done) --stdout)
# Set the timezone
if [[ -f /etc/localtime ]]; then
rm /etc/localtime
fi
ln -sf /usr/share/zoneinfo/${region}/${city} /etc/localtime
ln -sf /usr/share/zoneinfo/"${region}"/"${city}" /etc/localtime
timedatectl set-ntp true
}
# Offer to switch fenrir layout.
@@ -81,6 +84,7 @@ if [[ $diskSize -le 7 ]]; then
diskDevice="${BASH_REMATCH[1]}"
else
# Handle sda2, sdb3 style
# shellcheck disable=SC2001
diskDevice="$(echo "$diskSource" | sed 's/[0-9]*$//')"
fi
echo "Yes" | sudo "${sudoFlags[@]}" parted ---pretend-input-tty "$diskDevice" resizepart 2 100%
@@ -91,9 +95,9 @@ fi
if ! ping -c1 stormux.org &> /dev/null ; then
echo "No internet connection detected. Press enter to open NetworkManager."
read -r continue
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
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-deamon.sock
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
fi
# Check for internet connectivity
if ping -qc1 -W 1 stormux.org &> /dev/null; then
-582
View File
@@ -1,582 +0,0 @@
#!/usr/bin/env python3
import os
import re
import secrets
import signal
import string
import subprocess
import sys
import tempfile
import time
import pwd
import threading
stormuxAdmin = ("storm",)
ircServer = "irc.stormux.org"
ircPort = 6667
ircChannel = "#stormux"
remoteHost = "billysballoons.com"
sasUser = "sas"
pingIntervalSeconds = 180
pingCount = 5
maxWormholeFailures = 3
sudoKeepaliveThread = None
sudoKeepaliveStop = threading.Event()
def speak_message(message):
try:
subprocess.run(["spd-say", message], check=False)
except FileNotFoundError:
print(message, flush=True)
def say_or_print(message, useSpeech):
if useSpeech:
speak_message(message)
else:
print(message, flush=True)
def run_command(command, inputText=None, check=False, env=None):
return subprocess.run(
command,
input=inputText,
text=True,
capture_output=True,
check=check,
env=env,
)
def ensure_sudo(useSpeech):
if os.geteuid() == 0:
return True
if useSpeech:
speak_message("Sudo password required. Please enter your password now.")
result = run_command(["sudo", "-v"])
if result.returncode == 0:
start_sudo_keepalive()
return True
return False
def start_sudo_keepalive():
global sudoKeepaliveThread
if sudoKeepaliveThread and sudoKeepaliveThread.is_alive():
return
def keepalive_loop():
while not sudoKeepaliveStop.wait(240):
run_command(["sudo", "-n", "-v"])
sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True)
sudoKeepaliveThread.start()
def run_privileged(command, useSpeech, inputText=None, check=True):
if os.geteuid() == 0:
fullCommand = command
else:
if not ensure_sudo(useSpeech):
raise RuntimeError("sudo authentication failed")
fullCommand = ["sudo"] + command
return run_command(fullCommand, inputText=inputText, check=check)
def run_as_user(userName, command, useSpeech, check=True):
if os.geteuid() == 0:
fullCommand = ["sudo", "-u", userName, "-H"] + command
else:
if not ensure_sudo(useSpeech):
raise RuntimeError("sudo authentication failed")
fullCommand = ["sudo", "-u", userName, "-H"] + command
return run_command(fullCommand, check=check)
def user_exists(userName):
result = run_command(["getent", "passwd", userName])
return result.returncode == 0
def ensure_wheel(userName, useSpeech):
result = run_command(["id", "-nG", userName])
groups = result.stdout.strip().split()
if "wheel" not in groups:
run_privileged(["usermod", "-a", "-G", "wheel", userName], useSpeech)
def generate_password():
allowedChars = string.ascii_letters + string.digits
length = secrets.randbelow(5) + 6
return "".join(secrets.choice(allowedChars) for _ in range(length))
def get_user_home(userName):
return pwd.getpwnam(userName).pw_dir
def set_password(userName, password, useSpeech):
run_privileged(["chpasswd"], useSpeech, inputText=f"{userName}:{password}\n")
def generate_ssh_key(userName, useSpeech):
userHome = get_user_home(userName)
sshDir = os.path.join(userHome, ".ssh")
privateKeyPath = os.path.join(sshDir, "id_ed25519")
publicKeyPath = f"{privateKeyPath}.pub"
run_privileged(["mkdir", "-p", sshDir], useSpeech)
run_privileged(["chown", f"{userName}:{userName}", sshDir], useSpeech)
run_privileged(["chmod", "700", sshDir], useSpeech)
for entry in list_subdirs(sshDir):
if os.path.isfile(entry) or os.path.islink(entry):
run_privileged(["rm", "-f", entry], useSpeech, check=False)
run_as_user(
userName,
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", privateKeyPath],
useSpeech,
)
run_privileged(["chmod", "600", privateKeyPath], useSpeech)
run_privileged(["chmod", "644", publicKeyPath], useSpeech)
run_privileged(["chown", f"{userName}:{userName}", privateKeyPath, publicKeyPath], useSpeech)
return privateKeyPath, publicKeyPath
def path_exists_for_user(path, userName, useSpeech):
result = run_as_user(userName, ["stat", path], useSpeech, check=False)
return result.returncode == 0
def extract_message_text(line):
if "> " in line:
return line.split("> ", 1)[1].strip()
if ": " in line:
return line.split(": ", 1)[1].strip()
return line.strip()
def parse_sender(line):
match = re.search(r"<([^>]+)>", line)
if match:
return match.group(1)
return None
def find_wormhole_code(message):
lowered = message.strip().lower()
if lowered in ("yes", "accept"):
return None
match = re.search(r"\b\d+-[A-Za-z0-9-]+\b", message)
if match:
return match.group(0)
return None
class IrcSession:
def __init__(self, server, port, nick, channel, baseDir):
self.server = server
self.port = port
self.nick = nick
self.channel = channel
self.baseDir = baseDir
self.serverDir = None
self.serverInPath = None
self.channelInPath = None
self.iiProcess = None
self.pmOffsets = {}
def start(self):
if not shutil_which("ii"):
raise RuntimeError("ii is not installed")
supportsI = ii_supports_i()
processEnv = os.environ.copy()
iiCommand = ["ii", "-s", self.server, "-p", str(self.port), "-n", self.nick]
if supportsI:
iiCommand += ["-i", self.baseDir]
else:
processEnv["HOME"] = self.baseDir
self.iiProcess = subprocess.Popen(
iiCommand,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=processEnv,
)
self.serverDir = self.wait_for_server_dir()
self.serverInPath = os.path.join(self.serverDir, "in")
def stop(self):
if self.channelInPath and os.path.exists(self.channelInPath):
try:
self.write_line(self.channelInPath, f"/part {self.channel}")
except OSError:
pass
if self.serverInPath and os.path.exists(self.serverInPath):
try:
self.write_line(self.serverInPath, "/quit")
except OSError:
pass
if self.iiProcess and self.iiProcess.poll() is None:
self.iiProcess.terminate()
try:
self.iiProcess.wait(timeout=5)
except subprocess.TimeoutExpired:
self.iiProcess.kill()
def join_channel(self):
joinMessage = f"/join {self.channel}"
channelDir = os.path.join(self.serverDir, self.channel)
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
startTime = time.monotonic()
nextJoinTime = startTime
while time.monotonic() - startTime < 60:
if time.monotonic() >= nextJoinTime:
self.write_line(self.serverInPath, joinMessage)
nextJoinTime = time.monotonic() + 5
for candidate in (channelDir, channelAltDir):
inPath = os.path.join(candidate, "in")
if os.path.exists(inPath):
self.channelInPath = inPath
return
time.sleep(0.5)
self.channelInPath = None
def send_channel_message(self, message):
if not self.channelInPath:
self.refresh_channel_in_path()
if self.channelInPath and os.path.exists(self.channelInPath):
self.write_line(self.channelInPath, message)
else:
self.write_line(self.serverInPath, f"/msg {self.channel} {message}")
def send_private_message(self, nick, message):
nickDir = os.path.join(self.serverDir, nick)
inPath = os.path.join(nickDir, "in")
if os.path.exists(inPath):
self.write_line(inPath, message)
else:
self.write_line(self.serverInPath, f"/msg {nick} {message}")
def get_private_messages(self, allowedUsers):
messages = []
for nick in allowedUsers:
nickDir = os.path.join(self.serverDir, nick)
outPath = os.path.join(nickDir, "out")
if not os.path.exists(outPath):
continue
lastPos = self.pmOffsets.get(outPath, 0)
with open(outPath, "r", encoding="utf-8", errors="ignore") as fileHandle:
fileHandle.seek(lastPos)
for line in fileHandle:
sender = parse_sender(line)
if sender and sender == self.nick:
continue
if sender and sender != nick:
continue
messageText = extract_message_text(line)
if messageText:
messages.append((nick, messageText))
self.pmOffsets[outPath] = fileHandle.tell()
return messages
def refresh_channel_in_path(self):
channelDir = os.path.join(self.serverDir, self.channel)
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
for candidate in (channelDir, channelAltDir):
inPath = os.path.join(candidate, "in")
if os.path.exists(inPath):
self.channelInPath = inPath
return
def wait_for_server_dir(self):
for _ in range(120):
for rootDir in [self.baseDir] + list_subdirs(self.baseDir):
if not os.path.isdir(rootDir):
continue
for entry in os.listdir(rootDir):
path = os.path.join(rootDir, entry)
if os.path.isdir(path) and self.server in entry:
inPath = os.path.join(path, "in")
if os.path.exists(inPath):
return path
time.sleep(0.5)
raise RuntimeError("ii server directory not found")
@staticmethod
def write_line(path, message):
with open(path, "w", encoding="utf-8", errors="ignore") as fileHandle:
fileHandle.write(message + "\n")
fileHandle.flush()
def ii_supports_i():
result = run_command(["ii", "-h"])
output = (result.stdout or "") + (result.stderr or "")
return "-i" in output
def list_subdirs(path):
try:
return [os.path.join(path, entry) for entry in os.listdir(path)]
except OSError:
return []
def shutil_which(command):
for path in os.environ.get("PATH", "").split(os.pathsep):
candidate = os.path.join(path, command)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def build_nick():
baseUser = os.environ.get("SUDO_USER") or os.environ.get("USER") or "sas"
return f"{baseUser}-{int(time.time())}"
def main():
say_or_print("Checking accessibility. Is your screen reader working? (y/n)", True)
answer = input().strip().lower()
useSpeech = answer in ("n", "no")
shouldRemoveUser = False
cleanupDone = False
tempDir = tempfile.mkdtemp(prefix="sas-ii-")
ircSession = None
sshProcess = None
def cleanup(exitMessage=None):
nonlocal cleanupDone
if cleanupDone:
return
cleanupDone = True
nonlocal sshProcess
if exitMessage:
say_or_print(exitMessage, useSpeech)
if sshProcess and sshProcess.poll() is None:
sshProcess.terminate()
try:
sshProcess.wait(timeout=5)
except subprocess.TimeoutExpired:
sshProcess.kill()
if ircSession:
ircSession.stop()
if shouldRemoveUser:
try:
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
time.sleep(1)
result = run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
if result.returncode != 0 and user_exists(sasUser):
say_or_print(
"Cleanup warning: failed to remove sas user. Please remove it manually.",
useSpeech,
)
except Exception:
pass
sudoKeepaliveStop.set()
if sudoKeepaliveThread:
sudoKeepaliveThread.join(timeout=2)
try:
remove_tree(tempDir)
except Exception:
pass
def handle_signal(signum, frame):
cleanup("Interrupted. Cleaning up.")
sys.exit(1)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
try:
if not user_exists(sasUser):
run_privileged(
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
useSpeech,
)
shouldRemoveUser = True
else:
say_or_print(
"User 'sas' exists. Remove and recreate it? This will delete /home/sas. (y/n)",
useSpeech,
)
response = input().strip().lower()
if response not in ("y", "yes"):
cleanup("The sas user is unavailable. Remove it manually and try again.")
return 1
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
run_privileged(
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
useSpeech,
)
shouldRemoveUser = True
ensure_wheel(sasUser, useSpeech)
password = generate_password()
set_password(sasUser, password, useSpeech)
privateKeyPath, publicKeyPath = generate_ssh_key(sasUser, useSpeech)
sasHome = get_user_home(sasUser)
knownHostsPath = os.path.join(sasHome, ".ssh", "known_hosts_sas")
run_privileged(["touch", knownHostsPath], useSpeech)
run_privileged(["chmod", "600", knownHostsPath], useSpeech)
run_privileged(["chown", f"{sasUser}:{sasUser}", knownHostsPath], useSpeech)
nick = build_nick()
ircSession = IrcSession(ircServer, ircPort, nick, ircChannel, tempDir)
ircSession.start()
ircSession.join_channel()
say_or_print("Waiting for assistance on IRC.", useSpeech)
startTime = time.monotonic()
nextPingTime = startTime
pingsSent = 0
confirmedAdmin = None
while time.monotonic() - startTime < pingIntervalSeconds * pingCount:
now = time.monotonic()
if pingsSent < pingCount and now >= nextPingTime:
ircSession.send_channel_message(f"{nick} is requesting assistance.")
pingsSent += 1
nextPingTime = startTime + (pingsSent * pingIntervalSeconds)
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
if messageText.strip().lower() in ("yes", "accept"):
confirmedAdmin = adminNick
break
if confirmedAdmin:
break
time.sleep(1)
if not confirmedAdmin:
cleanup("No one was available to help, please try again later.")
return 1
ircSession.send_private_message(
confirmedAdmin,
f'password: "{password}" please send wormhole ssh invite code',
)
failures = 0
while failures < maxWormholeFailures:
inviteCode = None
while inviteCode is None:
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
inviteCode = find_wormhole_code(messageText)
if inviteCode:
break
if inviteCode:
break
time.sleep(1)
if not path_exists_for_user(publicKeyPath, sasUser, useSpeech):
raise RuntimeError(f"Public key missing: {publicKeyPath}")
wormholeCommand = [
"wormhole",
"ssh",
"accept",
"--yes",
inviteCode,
]
result = run_as_user(sasUser, wormholeCommand, useSpeech, check=False)
if result.returncode == 0:
say_or_print("Wormhole key transfer succeeded.", useSpeech)
break
failures += 1
errorTextFull = (result.stderr or result.stdout or "").strip()
if errorTextFull and not useSpeech:
print("Wormhole ssh accept error:", flush=True)
print(errorTextFull, flush=True)
errorText = errorTextFull
if errorText:
errorText = " ".join(errorText.split())
if len(errorText) > 400:
errorText = errorText[:400] + "..."
ircSession.send_private_message(
confirmedAdmin,
f"Wormhole ssh accept failed: {errorText}",
)
ircSession.send_private_message(
confirmedAdmin,
"Wormhole ssh accept failed. Please send a new invite code.",
)
if failures >= maxWormholeFailures:
cleanup("Wormhole failed too many times. Exiting.")
return 1
say_or_print("Starting reverse SSH tunnel. Press Ctrl+C to stop.", useSpeech)
sshCommand = [
"ssh",
"-N",
"-R",
"localhost:2232:localhost:22",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"BatchMode=yes",
"-o",
"ServerAliveInterval=30",
"-o",
"ServerAliveCountMax=3",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
f"UserKnownHostsFile={knownHostsPath}",
"-i",
privateKeyPath,
f"{sasUser}@{remoteHost}",
]
sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand
sshProcess = subprocess.Popen(sshCommand)
sshProcess.wait()
except Exception as exc:
cleanup(f"Error: {exc}")
return 1
finally:
cleanup()
return 0
def remove_tree(path):
if not os.path.exists(path):
return
for rootDir, dirNames, fileNames in os.walk(path, topdown=False):
for fileName in fileNames:
try:
os.unlink(os.path.join(rootDir, fileName))
except OSError:
pass
for dirName in dirNames:
try:
os.rmdir(os.path.join(rootDir, dirName))
except OSError:
pass
try:
os.rmdir(path)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())
@@ -4,7 +4,7 @@
# Monitors SSH logins and announces them via Fenrir's speech system
# Configuration
fenrirSocket="/tmp/fenrirscreenreader-deamon.sock"
fenrirSocket="/tmp/fenrirscreenreader-daemon.sock"
logFile="/var/log/auth.log"
stateFile="/tmp/fenrir-ssh-monitor.state"
checkInterval=2 # seconds between checks
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -euo pipefail
readonly repoName="stormux"
readonly repoKeyUrl="https://packages.stormux.org/stormux_repo.pub"
readonly repoKeyId="52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82"
readonly pacmanConf="${STORMUX_PACMAN_CONF:-/etc/pacman.conf}"
readonly osRelease="${STORMUX_OS_RELEASE:-/etc/os-release}"
readonly effectiveUid="${STORMUX_TEST_EUID:-${EUID}}"
keyFile=""
cleanup() {
if [[ -n "$keyFile" && -f "$keyFile" ]]; then
rm -f "$keyFile"
fi
}
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
require_root() {
[[ "$effectiveUid" == "0" ]] || die "This script must be run as root."
}
require_command() {
local commandName="$1"
command -v "$commandName" >/dev/null 2>&1 || die "Required command not found: ${commandName}"
}
is_arch_based_system() {
local osId=""
local osIdLike=""
[[ -f "$osRelease" ]] || return 1
while IFS='=' read -r keyName keyValue; do
keyValue="${keyValue%\"}"
keyValue="${keyValue#\"}"
case "$keyName" in
ID)
osId="$keyValue"
;;
ID_LIKE)
osIdLike="$keyValue"
;;
esac
done < "$osRelease"
[[ "$osId" == "arch" || "$osId" == "archarm" || " ${osIdLike} " == *" arch "* ]]
}
check_prerequisites() {
require_root
require_command curl
require_command pacman
require_command pacman-key
is_arch_based_system || die "This script supports Arch-based pacman systems only."
[[ -f "$pacmanConf" ]] || die "pacman.conf not found: ${pacmanConf}"
}
download_and_trust_key() {
keyFile="$(mktemp)"
trap cleanup EXIT
printf 'Downloading Stormux repository key...\n'
curl -fsSL "$repoKeyUrl" > "$keyFile"
printf 'Adding Stormux repository key to pacman keyring...\n'
pacman-key --add "$keyFile"
printf 'Locally signing Stormux repository key...\n'
pacman-key --lsign-key "$repoKeyId"
}
update_pacman_conf() {
local tempConf
tempConf="$(mktemp)"
awk -v repoName="$repoName" '
function print_repo_block() {
print "[" repoName "]"
print "SigLevel = Required DatabaseOptional"
print "Server = https://packages.stormux.org/$arch"
}
/^\[stormux\][[:space:]]*$/ {
inStormux = 1
next
}
inStormux && /^\[[^]]+\][[:space:]]*$/ {
inStormux = 0
}
inStormux {
next
}
!inserted && /^\[core\][[:space:]]*$/ {
print_repo_block()
print ""
inserted = 1
}
{
print
}
END {
if (!inserted) {
print ""
print_repo_block()
}
}
' "$pacmanConf" > "$tempConf"
cat "$tempConf" > "$pacmanConf"
rm -f "$tempConf"
}
refresh_databases() {
printf 'Refreshing package databases...\n'
pacman -Sy
}
main() {
check_prerequisites
download_and_trust_key
update_pacman_conf
refresh_databases
printf 'Stormux repository is configured.\n'
}
main "$@"
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env bash
set -euo pipefail
repoUrl="https://github.com/X11Libre/pkgbuilds-arch-based.git"
rootfsUrl="http://os.archlinuxarm.org/os/ArchLinuxARM-aarch64-latest.tar.gz"
aarch64Packages=(
xlibre-xserver
xlibre-input-libinput
xlibre-video-fbdev
xlibre-video-amdgpu
xlibre-video-ati
xlibre-video-nouveau
)
outputDir=$(pwd -P)
workDir=""
chrootDir=""
binfmtMounted=false
binfmtRegistered=false
log() {
printf '%s %s\n' "$*" "$(date '+%Y-%m-%d %H:%M:%S')"
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
cleanup() {
local exitStatus=$?
local cleanupFailed=false
local mountTarget
local chrootMounts=()
trap - EXIT INT TERM
if [[ -n "$workDir" && -d "$workDir" ]]; then
log "Removing temporary chroot"
if [[ -n "$chrootDir" ]]; then
mapfile -t chrootMounts < <(
findmnt -rn -o TARGET |
awk -v root="$chrootDir" '$0 == root || index($0, root "/") == 1' |
sort -r
)
fi
for mountTarget in "${chrootMounts[@]}"; do
if ! mountpoint -q "$mountTarget"; then
continue
fi
if ! umount --recursive "$mountTarget"; then
printf 'Warning: normal chroot unmount failed; detaching it lazily\n' >&2
umount --recursive --lazy "$mountTarget" || cleanupFailed=true
fi
done
rm -rf --one-file-system "$workDir" || cleanupFailed=true
if [[ -e "$workDir" ]]; then
printf 'Error: failed to remove temporary chroot: %s\n' "$workDir" >&2
cleanupFailed=true
fi
fi
if [[ "$binfmtRegistered" == true && -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ]]; then
printf '%s' -1 > /proc/sys/fs/binfmt_misc/qemu-aarch64 || cleanupFailed=true
fi
if [[ "$binfmtMounted" == true ]]; then
umount /proc/sys/fs/binfmt_misc || cleanupFailed=true
fi
if [[ "$cleanupFailed" == true && "$exitStatus" -eq 0 ]]; then
exitStatus=1
fi
exit "$exitStatus"
}
require_commands() {
local commandName
local missingCommands=()
for commandName in arch-chroot awk bsdtar chown cp curl date findmnt grep install mkdir mktemp mount mountpoint rm sort tr umount; do
if ! command -v "$commandName" >/dev/null 2>&1; then
missingCommands+=("$commandName")
fi
done
if ((${#missingCommands[@]})); then
die "Missing required commands: ${missingCommands[*]}"
fi
[[ -x /usr/bin/qemu-aarch64-static ]] || die "/usr/bin/qemu-aarch64-static is required"
[[ -r /usr/lib/binfmt.d/qemu-aarch64-static.conf ]] || die "The qemu-user-static-binfmt package is required"
}
configure_binfmt() {
if ! mountpoint -q /proc/sys/fs/binfmt_misc; then
mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc
binfmtMounted=true
fi
if [[ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ]]; then
tr -d '\n' < /usr/lib/binfmt.d/qemu-aarch64-static.conf > /proc/sys/fs/binfmt_misc/register
binfmtRegistered=true
fi
grep -qx 'enabled' /proc/sys/fs/binfmt_misc/qemu-aarch64 || die "aarch64 binfmt registration is not enabled"
}
create_build_script() {
local buildScript=$1
install -m 0755 /dev/stdin "$buildScript" <<'CHROOT_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
repoUrl=$1
shift
packages=("$@")
repoDir=/build/pkgbuilds
log() {
printf '%s %s\n' "$*" "$(date '+%Y-%m-%d %H:%M:%S')"
}
install_dependencies() {
local packageDir=$1
local dependency
local outputPackage
local srcInfo
local dependencies=()
local filteredDependencies=()
local missingDependencies=()
local outputPackages=()
local -A internalPackages=()
# $1 is expanded by the nested builder shell, not this root shell.
# shellcheck disable=SC2016
srcInfo=$(runuser -u builder -- bash -c \
'cd "$1" && makepkg --printsrcinfo' _ "$packageDir")
mapfile -t outputPackages < <(
printf '%s\n' "$srcInfo" |
awk -F ' = ' '/^[[:space:]]*pkgname = / { print $2 }'
)
for outputPackage in "${outputPackages[@]}"; do
internalPackages["$outputPackage"]=1
done
mapfile -t dependencies < <(
printf '%s\n' "$srcInfo" |
awk -F ' = ' '
/^[[:space:]]*(depends|makedepends|checkdepends)(_aarch64)? = / {
dependency=$2
sub(/[<>=].*/, "", dependency)
print dependency
}
' |
sort -u
)
for dependency in "${dependencies[@]}"; do
if [[ -z ${internalPackages[$dependency]+present} ]]; then
filteredDependencies+=("$dependency")
fi
done
((${#filteredDependencies[@]})) || return 0
mapfile -t missingDependencies < <(pacman -T "${filteredDependencies[@]}" || true)
((${#missingDependencies[@]})) || return 0
log "Installing dependencies for ${packageDir##*/}"
pacman -S --asdeps --needed --noconfirm "${missingDependencies[@]}"
}
remove_installed_packages() {
local packageName
local installedPackages=()
for packageName in "$@"; do
if pacman -Q "$packageName" >/dev/null 2>&1; then
installedPackages+=("$packageName")
fi
done
((${#installedPackages[@]})) || return 0
log "Removing conflicting packages: ${installedPackages[*]}"
pacman -Rdd --noconfirm "${installedPackages[@]}"
}
build_package() {
local packageName=$1
local installAfterBuild=${2:-false}
local packageDir="$repoDir/$packageName"
local builtPackages=()
[[ -f "$packageDir/PKGBUILD" ]] || {
printf 'Missing PKGBUILD for %s\n' "$packageName" >&2
return 1
}
log "Building $packageName"
install_dependencies "$packageDir"
# $1 is expanded by the nested builder shell, not this root shell.
# shellcheck disable=SC2016
runuser -u builder -- bash -c \
'cd "$1" && makepkg --noconfirm --clean --cleanbuild' _ "$packageDir"
mapfile -d '' builtPackages < <(
find "$packageDir" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print0
)
((${#builtPackages[@]})) || {
printf 'No package artifacts were produced for %s\n' "$packageName" >&2
return 1
}
if [[ "$installAfterBuild" == true ]]; then
case "$packageName" in
xlibre-input-libinput)
remove_installed_packages xf86-input-libinput
;;
xlibre-xserver)
remove_installed_packages \
xorg-server \
xorg-server-common \
xorg-server-devel \
xorg-server-xephyr \
xorg-server-xnest \
xorg-server-xvfb \
glamor-egl \
xf86-video-modesetting
;;
esac
log "Installing $packageName for subsequent builds"
pacman -U --noconfirm "${builtPackages[@]}"
fi
}
log "Initializing Arch Linux ARM"
# Pacman's Landlock sandbox is unavailable through qemu-user emulation.
sed -i \
-e '/^[[:space:]]*DisableSandbox[[:space:]]*$/d' \
-e '/^\[options\][[:space:]]*$/a DisableSandbox' \
/etc/pacman.conf
pacman-key --init
pacman-key --populate archlinuxarm
pacman -Syu --noconfirm
pacman -S --needed --noconfirm base-devel git
useradd --create-home --shell /bin/bash builder
install -d -o builder -g builder /build
log "Cloning XLibre PKGBUILDs"
runuser -u builder -- git clone --depth 1 "$repoUrl" "$repoDir"
# The input driver must first be built against the stock xorg-server-devel.
build_package xlibre-input-libinput true
# Installing all server split packages replaces the stock Xorg server/devel
# packages and provides the ABI dependencies needed by the video drivers.
build_package xlibre-xserver true
for packageName in "${packages[@]}"; do
case "$packageName" in
xlibre-input-libinput|xlibre-xserver)
continue
;;
esac
build_package "$packageName"
done
install -d /output
find "$repoDir" -type f -name '*.pkg.tar.*' ! -name '*.sig' -exec cp -t /output -- {} +
log "All requested aarch64 packages built"
CHROOT_SCRIPT
}
main() {
local rootfsArchive
local packageFile
local copiedPackageCount=0
((EUID == 0)) || die "Run this script as root: sudo $0"
require_commands
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
configure_binfmt
workDir=$(mktemp -d /tmp/xlibre-aarch64.XXXXXX)
chrootDir="$workDir/root"
rootfsArchive="$workDir/ArchLinuxARM-aarch64-latest.tar.gz"
mkdir -p "$chrootDir"
log "Downloading a fresh Arch Linux ARM aarch64 root filesystem"
curl --fail --location --retry 3 --output "$rootfsArchive" "$rootfsUrl"
log "Creating aarch64 chroot"
bsdtar -xpf "$rootfsArchive" -C "$chrootDir"
install -m 0755 /usr/bin/qemu-aarch64-static "$chrootDir/usr/bin/qemu-aarch64-static"
cp --remove-destination /etc/resolv.conf "$chrootDir/etc/resolv.conf"
create_build_script "$chrootDir/root/build-xlibre"
# arch-chroot expects the chroot root to be a mountpoint.
mount --bind "$chrootDir" "$chrootDir"
log "Starting aarch64 package build"
arch-chroot "$chrootDir" /root/build-xlibre "$repoUrl" "${aarch64Packages[@]}"
shopt -s nullglob
for packageFile in "$chrootDir"/output/*.pkg.tar.*; do
[[ ${packageFile##*/} == *-aarch64.pkg.tar.* ]] || die "Unexpected non-aarch64 artifact: ${packageFile##*/}"
install -m 0644 "$packageFile" "$outputDir/"
((copiedPackageCount += 1))
if [[ -n ${SUDO_UID:-} && -n ${SUDO_GID:-} ]]; then
chown "$SUDO_UID:$SUDO_GID" "$outputDir/${packageFile##*/}"
fi
done
shopt -u nullglob
((copiedPackageCount > 0)) || die "No package artifacts were copied to $outputDir"
log "Copied $copiedPackageCount completed packages to $outputDir"
}
main "$@"
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
repoDir="/var/www/packages.stormux.org"
aurRpcUrl="https://aur.archlinux.org/rpc/v5/info"
exclude=("gzdoom")
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
printf 'Required command not found: %s\n' "$cmd" >&2
exit 1
fi
}
is_excluded() {
local packageName="$1"
local excludedPackage
for excludedPackage in "${exclude[@]}"; do
if [[ "$excludedPackage" == "$packageName" ]]; then
return 0
fi
done
return 1
}
record_local_package_version() {
local packageName="$1"
local packageVersion="$2"
local packageMapName="$3"
local -n packageMapRef="$packageMapName"
local existingVersion="${packageMapRef[$packageName]:-}"
if [[ -z "$existingVersion" ]] || (( $(vercmp "$packageVersion" "$existingVersion") > 0 )); then
packageMapRef["$packageName"]="$packageVersion"
fi
}
read_package_metadata() {
local packageFile="$1"
pacman -Qip "$packageFile" | parse_pacman_info
}
parse_pacman_info() {
awk '
/^Name[[:space:]]*:/ {
packageName=$0
sub(/^Name[[:space:]]*:[[:space:]]*/, "", packageName)
}
/^Version[[:space:]]*:/ {
packageVersion=$0
sub(/^Version[[:space:]]*:[[:space:]]*/, "", packageVersion)
}
END {
if (packageName == "" || packageVersion == "") {
exit 1
}
printf "%s\t%s\n", packageName, packageVersion
}
'
}
collect_local_packages() {
local packageMapName="$1"
# shellcheck disable=SC2178
local -n packageMapRef="$packageMapName"
local archDir packageFile metadata packageName packageVersion
local -a archDirs=("$repoDir/x86_64" "$repoDir/aarch64")
for archDir in "${archDirs[@]}"; do
if [[ ! -d "$archDir" ]]; then
continue
fi
for packageFile in "$archDir"/*.pkg.tar.zst "$archDir"/*.pkg.tar.xz; do
metadata="$(read_package_metadata "$packageFile")" || {
printf 'Unable to read package metadata: %s\n' "$packageFile" >&2
exit 1
}
packageName="${metadata%%$'\t'*}"
packageVersion="${metadata#*$'\t'}"
record_local_package_version "$packageName" "$packageVersion" "$packageMapName"
done
done
}
extract_aur_version_from_json() {
local packageName="$1"
jq -r --arg packageName "$packageName" '
.results[]
| select(.Name == $packageName)
| .Version
' | head -n1
}
fetch_aur_version() {
local packageName="$1"
curl -fsS --get \
--data-urlencode "arg[]=${packageName}" \
"$aurRpcUrl" |
extract_aur_version_from_json "$packageName"
}
print_outdated_packages() {
local packageMapName="$1"
# shellcheck disable=SC2178
local -n packageMapRef="$packageMapName"
local packageName localVersion aurVersion
local -a packageNames=()
mapfile -t packageNames < <(printf '%s\n' "${!packageMapRef[@]}" | sort)
for packageName in "${packageNames[@]}"; do
if is_excluded "$packageName"; then
continue
fi
localVersion="${packageMapRef[$packageName]}"
aurVersion="$(fetch_aur_version "$packageName" || true)"
if [[ -z "$aurVersion" ]]; then
continue
fi
if (( $(vercmp "$localVersion" "$aurVersion") < 0 )); then
printf '%s %s\n' "$packageName" "$aurVersion"
fi
done
}
assert_equals() {
local expected="$1"
local actual="$2"
local message="$3"
if [[ "$expected" != "$actual" ]]; then
printf 'FAIL: %s\nExpected: %s\nActual: %s\n' "$message" "$expected" "$actual" >&2
exit 1
fi
}
assert_success() {
local message="$1"
shift
if ! "$@"; then
printf 'FAIL: %s\n' "$message" >&2
exit 1
fi
}
assert_failure() {
local message="$1"
shift
if "$@"; then
printf 'FAIL: %s\n' "$message" >&2
exit 1
fi
}
main() {
if [[ "${1:-}" == "--self-test" ]]; then
run_self_tests
return
fi
require_cmd "curl"
require_cmd "jq"
require_cmd "pacman"
require_cmd "vercmp"
if [[ ! -d "$repoDir" ]]; then
printf 'Repo dir does not exist: %s\n' "$repoDir" >&2
exit 1
fi
# shellcheck disable=SC2034
declare -A localPackages=()
collect_local_packages localPackages
print_outdated_packages localPackages
}
run_self_tests() {
declare -A packageMap=()
local exactMatchJson noMatchJson
local extractedVersion=""
local pacmanInfo=""
assert_success "excluded package should match" is_excluded "gzdoom"
assert_failure "non-excluded package should not match" is_excluded "fenrir"
record_local_package_version "fenrir" "1:2026.01.20-1" packageMap
record_local_package_version "fenrir" "1:2026.01.28-1" packageMap
record_local_package_version "fenrir" "1:2026.01.10-1" packageMap
assert_equals "1:2026.01.28-1" "${packageMap[fenrir]}" "newest local version should win"
exactMatchJson='{"results":[{"Name":"fenrir-git","Version":"1:r3322.4672592d-1"},{"Name":"fenrir","Version":"1:2026.01.28-1"}]}'
extractedVersion="$(printf '%s\n' "$exactMatchJson" | extract_aur_version_from_json "fenrir")"
assert_equals "1:2026.01.28-1" "$extractedVersion" "exact package name should be selected from AUR JSON"
noMatchJson='{"results":[{"Name":"fenrir-git","Version":"1:r3322.4672592d-1"}]}'
extractedVersion="$(printf '%s\n' "$noMatchJson" | extract_aur_version_from_json "fenrir")"
assert_equals "" "$extractedVersion" "missing exact AUR match should stay empty"
pacmanInfo='Name : fenrir
Version : 1:2026.01.28-1
Description : A user space console screen reader written in python3'
extractedVersion="$(printf '%s\n' "$pacmanInfo" | parse_pacman_info)"
assert_equals $'fenrir\t1:2026.01.28-1' "$extractedVersion" "pacman metadata parsing should return name and version"
if (( $(vercmp "1:2026.01.20-1" "1:2026.01.28-1") >= 0 )); then
printf 'FAIL: older local version should compare lower than AUR version\n' >&2
exit 1
fi
printf 'Self-test passed\n'
}
main "$@"
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
# ---- Configurable Variables ----
repoDir="/var/www/packages.stormux.org"
keyId="52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82"
repoName="stormux"
dbName="${repoName}.db.tar.gz"
filesName="${repoName}.files.tar.gz"
rebuildDb="${REBUILD_DB:-false}"
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "❌ Required command not found: $cmd"
exit 1
fi
}
# ---- Safety Checks ----
if [[ ! -d "$repoDir" ]]; then
echo "❌ Repo dir does not exist: $repoDir"
exit 1
fi
require_cmd "gpg"
require_cmd "repo-add"
require_cmd "repo-remove"
# ---- Create Architecture Directories ----
mkdir -p "$repoDir/x86_64" "$repoDir/aarch64"
# ---- Process Each Architecture ----
process_arch() {
local arch="$1"
local archDir="$repoDir/$arch"
local pkgFiles=()
local selectedPkgFiles=()
local repoAddArgs=()
local -A currentPkgNames=()
local -A newestPkgByName=()
local repoPkgNames=()
local dbFile="$dbName"
local filesFile="$filesName"
local dbSig="${dbFile}.sig"
local filesSig="${filesFile}.sig"
local dbLink="${repoName}.db"
local filesLink="${repoName}.files"
echo "🏗️ Processing $arch packages..."
# Enter arch directory (packages should already be sorted by update.sh)
cd "$archDir" || return 1
# Select only the newest archive for each package name. repo-add cannot
# safely consume multiple versions of the same package in a single run.
pkgFiles=( *.pkg.tar.zst *.pkg.tar.xz )
for pkg in "${pkgFiles[@]}"; do
local pkgName pkgVersion existingPkg existingVersion
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
pkgVersion="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p' | head -n1)-$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgrel = //p' | head -n1)"
if [[ -z "$pkgName" || -z "$pkgVersion" ]]; then
echo "❌ Unable to determine package metadata for $pkg"
cd "$repoDir" || exit 1
return 1
fi
existingPkg="${currentPkgNames[$pkgName]:-}"
if [[ -z "$existingPkg" ]]; then
currentPkgNames["$pkgName"]="$pkg"
else
existingVersion="$(bsdtar -xOqf "$existingPkg" .PKGINFO | sed -n 's/^pkgver = //p' | head -n1)-$(bsdtar -xOqf "$existingPkg" .PKGINFO | sed -n 's/^pkgrel = //p' | head -n1)"
if (( $(vercmp "$pkgVersion" "$existingVersion") > 0 )); then
currentPkgNames["$pkgName"]="$pkg"
fi
fi
done
for pkgName in "${!currentPkgNames[@]}"; do
newestPkgByName["$pkgName"]="${currentPkgNames[$pkgName]}"
selectedPkgFiles+=( "${currentPkgNames[$pkgName]}" )
done
pkgFiles=( "${selectedPkgFiles[@]}" )
# Sign all unsigned selected packages
echo "🔏 Signing $arch packages..."
for pkg in "${pkgFiles[@]}"; do
if [[ ! -f "$pkg.sig" ]]; then
echo " 📝 Signing $pkg"
gpg --default-key "$keyId" --detach-sign "$pkg"
else
echo "$pkg already signed"
fi
done
# Track which package names should remain in the repo after this run.
for pkg in "${pkgFiles[@]}"; do
local pkgName
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
if [[ -n "$pkgName" ]]; then
currentPkgNames["$pkgName"]=1
else
echo "❌ Unable to determine package name for $pkg"
cd "$repoDir" || exit 1
return 1
fi
done
# Rebuild database for this architecture
if [[ "$rebuildDb" == "true" ]]; then
echo "🗃️ Rebuilding $arch repo database..."
rm -f "$dbFile" "$dbSig" "$filesFile" "$filesSig" "$dbLink" "$filesLink"
elif [[ -f "$dbFile" ]]; then
echo "🗃️ Updating $arch repo database..."
else
echo "🆕 Creating new $arch repo database..."
fi
# Remove packages that still exist in the repo database but are no longer
# present in the current directory. repo-remove with --remove also deletes
# the matching package archive and detached signature from disk.
if [[ -f "$dbFile" ]]; then
mapfile -t repoPkgNames < <(
bsdtar -tf "$dbFile" |
awk -F/ 'NF == 2 && $2 == "desc" {print $1}' |
while read -r entry; do
bsdtar -xOf "$dbFile" "${entry}/desc" |
awk 'found {print; exit} /^%NAME%$/ {found=1}'
done |
sort -u
)
for pkgName in "${repoPkgNames[@]}"; do
if [[ -z "${currentPkgNames[$pkgName]:-}" ]]; then
echo "🧹 Removing stale repo package $pkgName"
repo-remove --sign --key "$keyId" --remove "$dbFile" "$pkgName"
fi
done
fi
# Only run repo-add if there are packages
if ((${#pkgFiles[@]} > 0)); then
repoAddArgs=(--sign --key "$keyId" --remove)
if [[ "$rebuildDb" != "true" && -f "$dbFile" ]]; then
repoAddArgs+=(--verify)
fi
repo-add "${repoAddArgs[@]}" "$dbFile" "${pkgFiles[@]}"
if [[ ! -f "$dbFile" ]]; then
echo "❌ repo-add did not create $dbFile"
cd "$repoDir" || exit 1
return 1
fi
if [[ ! -e "$dbLink" ]]; then
ln -s "$dbFile" "$dbLink"
fi
if [[ -f "$filesFile" && ! -e "$filesLink" ]]; then
ln -s "$filesFile" "$filesLink"
fi
echo "$arch repo updated successfully"
else
echo "️ No $arch packages found"
fi
# Remove orphaned package archives and detached signatures that are not part
# of the current package set. This keeps the on-disk repo contents aligned
# with the package database after rebuilds and package removals.
for pkg in *.pkg.tar.zst *.pkg.tar.xz; do
local pkgName
pkgName="$(bsdtar -xOqf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p' | head -n1)"
if [[ -z "${newestPkgByName[$pkgName]:-}" || "${newestPkgByName[$pkgName]}" != "$pkg" ]]; then
echo "🧹 Removing orphaned package file $pkg"
rm -f "$pkg" "$pkg.sig"
fi
done
cd "$repoDir" || exit
}
# ---- Process Both Architectures ----
process_arch "x86_64"
process_arch "aarch64"
# ---- Done ----
echo "✅ All repositories updated and signed successfully."
@@ -0,0 +1,23 @@
pkgbase = xlibre-video-dummy-with-vt
pkgdesc = XLibre dummy video driver with an allocated vt
pkgver = 25.0.0
pkgrel = 5
url = https://github.com/X11Libre/xf86-video-dummy
arch = x86_64
arch = aarch64
groups = xlibre-drivers
license = MIT
license = X11
makedepends = xlibre-xserver-devel>=25.0
makedepends = xorgproto
depends = xlibre-xserver>=25.0
depends = glibc
provides = xf86-video-dummy
provides = x11win-video-dummy
conflicts = xf86-video-dummy
source = https://github.com/X11Libre/xf86-video-dummy/archive/refs/tags/xlibre-xf86-video-dummy-25.0.0.tar.gz
source = dummy_driver.patch
sha256sums = b56e610705cd3d4d86422a11c6b0d93357e4d4749a05178a85fd250301d357b9
sha256sums = 68cdcf21e9b54a7fdb8e968292e1ef9ad154ddb1361b141a0a635c2a13c92bfa
pkgname = xlibre-video-dummy-with-vt
@@ -0,0 +1,5 @@
*
!PKGBUILD
!.SRCINFO
!.gitignore
!.nvchecker.toml
@@ -0,0 +1,5 @@
[xlibre-video-dummy]
source = "git"
git = "https://github.com/x11libre/xf86-video-dummy.git"
include_regex = "xlibre-xf86-video-dummy-.*"
prefix = "xlibre-xf86-video-dummy-"
@@ -0,0 +1,59 @@
# Maintainer: Storm Dragon <storm_dragon@linux-a11y.org>
pkgname=xlibre-video-dummy-with-vt
pkgver=25.0.0
pkgrel=5
pkgdesc="XLibre dummy video driver with an allocated vt"
arch=(x86_64 aarch64)
_pkgname=xf86-video-dummy
url="https://github.com/X11Libre/${_pkgname}"
license=('MIT' 'X11')
depends=("xlibre-xserver>=${pkgver%.*}" 'glibc')
makedepends=("xlibre-xserver-devel>=${pkgver%.*}" 'xorgproto')
conflicts=("${_pkgname}")
provides=("${_pkgname}" 'x11win-video-dummy')
source=("${url}/archive/refs/tags/xlibre-${_pkgname}-${pkgver}.tar.gz"
"dummy_driver.patch")
groups=('xlibre-drivers')
sha256sums=('b56e610705cd3d4d86422a11c6b0d93357e4d4749a05178a85fd250301d357b9'
'68cdcf21e9b54a7fdb8e968292e1ef9ad154ddb1361b141a0a635c2a13c92bfa')
prepare() {
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}/src"
patch -i "${srcdir}/dummy_driver.patch"
}
build() {
case "$CARCH" in
"x86_64")
CFLAGS=" -march=x86-64"
;;
"aarch64")
CFLAGS=" -march=armv8-a"
;;
*)
CFLAGS=" -march=native"
;;
esac
CFLAGS+=" -mtune=generic -O2 -pipe -fexceptions -Wp,-D_FORTIFY_SOURCE=3 -Wformat -Werror=format-security"
CFLAGS+=" -fstack-clash-protection -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer"
LDFLAGS=" -Wl,-O1 -Wl,--sort-common -Wl,--as-needed -Wl,-z,lazy -Wl,-z,relro -Wl,-z,pack-relative-relocs"
if [[ $CARCH != 'aarch64' ]]; then
CFLAGS+=" -fcf-protection"
fi
CXXFLAGS="${CFLAGS} -Wp,-D_GLIBCXX_ASSERTIONS"
export CFLAGS="${CFLAGS}"
export CXXFLAGS="${CXXFLAGS}"
export LDFLAGS="${LDFLAGS}"
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}"
./autogen.sh
./configure --prefix=/usr
make
}
package() {
cd "${srcdir}/${_pkgname}-xlibre-${_pkgname}-${pkgver}"
make DESTDIR="${pkgdir}" install
install -Dm644 COPYING "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -0,0 +1,17 @@
--- a/src/dummy_driver.c
+++ b/src/dummy_driver.c
@@ -1016,10 +1016,10 @@ dummyDriverFunc(ScrnInfoPtr pScrn, xorgDriverFuncOp op, pointer ptr)
CARD32 *flag;
switch (op) {
- case GET_REQUIRED_HW_INTERFACES:
- flag = (CARD32*)ptr;
- (*flag) = HW_SKIP_CONSOLE;
- return TRUE;
+ case GET_REQUIRED_HW_INTERFACES:
+ flag = (CARD32*)ptr;
+ /* Allow the driver to allocate a VT instead of skipping the console. */
+ return TRUE;
default:
return FALSE;
}
+201
View File
@@ -0,0 +1,201 @@
# Stormux x86_64 ISO Build
This directory contains the archiso profile for building a Stormux x86_64 live/install ISO image.
## Overview
The x86_64 build uses archiso (Arch Linux ISO build system) and is configured for accessibility with the Fenrir screen reader. The `airootfs/` tree is maintained as the x86_64 live-system overlay; shared behavior may mirror the Pi image, but files are not copied from `pi4/files/` during the build.
## Directory Structure
```
x86_64/
├── build.sh # Main build script
├── profiledef.sh # Archiso profile definition
├── pacman.conf # Pacman config with Stormux repository
├── packages.x86_64 # Package list for installation
├── grub/ # UEFI boot configuration
│ ├── grub.cfg
│ └── loopback.cfg
├── syslinux/ # BIOS boot configuration
│ ├── archiso_head.cfg
│ ├── archiso_pxe.cfg
│ ├── archiso_pxe-linux.cfg
│ ├── archiso_sys.cfg
│ ├── archiso_sys-linux.cfg
│ ├── archiso_tail.cfg
│ ├── splash.png
│ └── syslinux.cfg
└── airootfs/ # Live system overlay files
├── etc/ # Live-system configuration
├── root/ # x86_64-specific scripts
├── usr/ # Live-system utilities and installer
└── var/ # Live-system variable data
```
## Overlay Maintenance
Edit files directly under `airootfs/` using the final filesystem path they should have in the live ISO. For example, live-system scripts belong under `airootfs/usr/local/bin/`, and systemd units belong under `airootfs/etc/systemd/system/`.
Some files intentionally match the Pi overlay, such as shell defaults, Fenrir support scripts, and shared Stormux helper behavior. Keep both trees in sync when a change is meant to affect both image families.
Pi hardware files remain Pi-only and should not be added to the x86_64 profile unless there is a specific PC use for them:
- `boot/cmdline.txt` - Pi boot command line
- `boot/config.txt` - Pi hardware configuration
- `etc/modprobe.d/brcmfmac.conf` - Pi wireless driver config
## Prerequisites
Building requires an Arch Linux host system with:
- `archiso` package installed
- Root privileges
- Internet connection for package downloads and build-time helper installs
## Building the ISO
```bash
cd x86_64
sudo ./build.sh
```
### Build Options
- `-o <dir>` - Output directory (default: `./out`)
- `-w <dir>` - Work directory (default: `./work`)
- `-h` - Show help
### Build Process
1. Uses the maintained `airootfs/` overlay as-is
2. Adds Stormux repository GPG key to the build host's keyring
- Uses the included `stormux_repo.pub` file
- Key fingerprint: 52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82
3. Runs `mkarchiso` to build the ISO
- Packages from Stormux repo can be installed during build
- The latest `sas` helper is installed into the live environment during image creation
4. Renames the ISO to `stormux-x86_64-YYYY-MM-DD.iso` when possible and writes a matching `.sha1sum`
The Stormux repository key is also embedded in the ISO at `/usr/share/stormux/stormux_repo.pub` and automatically imported on first boot via the `stormux-repo-init.service`, ensuring the live environment can install additional packages from the Stormux repository.
## Key Features
### Accessibility
- **Fenrir screen reader** starts automatically (not speakup)
- **Pipewire audio** properly initialized before Fenrir starts
- **Speech-dispatcher** integration for speech synthesis
- GRUB plays an audible tune on boot for accessibility
- Boot menu defaults to accessible entry
- Service startup order ensures audio is ready before screen reader
- First-login live-environment setup calibrates volume, checks networking and time, then offers to run the installer
### Package Management
- Stormux repository configured with priority over Arch repos
- Custom packages from Stormux repo: fenrir, w3m-git, yay, etc.
### Default Configuration
- Default user: `stormux` / Password: `stormux`
- Root password: `root`
- NetworkManager for network configuration
- Braille terminal support (brltty)
- Multiple speech synthesizers (espeak-ng, rhvoice)
## Audio and Speech Initialization
The live environment uses a carefully orchestrated startup sequence to ensure Fenrir has working audio:
1. **stormux-audio-setup.service** - Runs after sound hardware is detected
- Enables systemd user linger for the stormux user
- Starts pipewire user services
- Unmutes audio and sets volume to 70%
2. **stormux-speech.service** - Runs after audio setup
- Waits 2 seconds for pipewire to fully initialize
- Starts fenrirscreenreader.service
3. **fenrirscreenreader.service** - Screen reader with dependencies
- Configured to wait for pipewire, speech-dispatcher, and sound.target
- Uses speech-dispatcher for TTS output
This ensures Fenrir never starts without working audio, preventing system freezes or silent boot.
## Live Setup and Installer Flow
On first login to tty1, the live environment runs a short setup script. It calibrates speech volume, checks for network access, updates the live environment clock, copies the detected timezone into the installer defaults, and then asks whether to run `install-stormux`.
The installer gives one last chance to accept or change the timezone before installation. The live setup changes are for the temporary live environment only; the installed system is configured by `install-stormux`.
## Differences from Pi4 Build
1. **No ARM-specific packages** - Uses x86_64 standard Linux kernel
2. **Fenrir instead of speakup** - More feature-rich screen reader
3. **UEFI and BIOS support** - Boots on both modern and legacy systems
4. **ISO format** - Live/install medium instead of disk image
5. **No Pi hardware configs** - Standard x86_64 PC configuration
6. **Different audio startup** - Pipewire user services instead of system-wide
## Testing
After building, test the ISO with:
- QEMU/KVM virtual machine
- VirtualBox
- Physical hardware (USB/CD)
For VM testing with audio:
```bash
./qemu-boot.sh
```
After installing to the test disk, boot the installed system with:
```bash
./qemu-boot.sh -i
```
## Customization
### Adding Packages
Edit `packages.x86_64` and add package names (one per line).
### Modifying Boot Configuration
- BIOS: Edit `syslinux/archiso_sys-linux.cfg`
- UEFI: Edit `grub/grub.cfg`
### Adding Overlay Files
Place files in `airootfs/` following the target filesystem structure.
The x86_64 build uses this overlay directly.
## Troubleshooting
### Build fails with GPG errors
The build script should automatically add the Stormux repository key. If it fails:
1. Check that `x86_64/stormux_repo.pub` exists
2. Manually add the key to your build host:
```bash
sudo pacman-key --add x86_64/stormux_repo.pub
sudo pacman-key --lsign-key 52ADA49000F1FF0456F8AEEFB4CDE1CD56EF8E82
```
### Missing packages
Ensure the Stormux repository is accessible:
```bash
curl -I https://packages.stormux.org/x86_64/
```
### Disk space issues
The build requires significant space:
- Work directory: ~3-4 GB
- Output ISO: ~1-2 GB
Ensure adequate free space in work and output directories.
@@ -5,6 +5,29 @@
set -e -u
install_sas() {
local sasRepo="https://git.stormux.org/storm/sas"
local sasPath="/usr/local/bin/sas"
local tempDir
local installStatus=0
tempDir="$(mktemp -d)"
echo "Installing latest sas..."
if ! git clone --depth 1 "$sasRepo" "$tempDir"; then
rm -rf "$tempDir"
return 1
fi
rm -f "$sasPath"
if ! install -m 755 "$tempDir/sas.py" "$sasPath"; then
installStatus=1
fi
rm -rf "$tempDir"
return "$installStatus"
}
# Initialize pacman keyring
echo "Initializing pacman keyring..."
pacman-key --init
@@ -25,6 +48,8 @@ fi
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
locale-gen
install_sas
# Enable system services
systemctl enable NetworkManager.service
systemctl enable fenrirscreenreader.service
@@ -14,6 +14,7 @@ set_timezone() {
mapfile -t regions < <(timedatectl --no-pager list-timezones | cut -d '/' -f1 | sort -u)
# Use the same text twice here and just hide the tag field.
# shellcheck disable=SC2046
region=$(dialog --backtitle "Please select your Region" \
--no-tags \
--menu "Use up and down arrows or page-up and page-down to navigate the list, and press 'Enter' to make your selection." 0 0 0 \
@@ -23,6 +24,7 @@ set_timezone() {
mapfile -t cities < <(timedatectl --no-pager list-timezones | grep "$region" | cut -d '/' -f2 | sort -u)
# Use the same text twice here and just hide the tag field.
# shellcheck disable=SC2046
city=$(dialog --backtitle "Please select a city near you" \
--no-tags \
--menu "Use up and down arrow or page-up and page-down to navigate the list." 0 0 10 \
@@ -49,9 +51,9 @@ fi
if ! ping -c1 stormux.org &> /dev/null ; then
echo "No internet connection detected. Press enter to open NetworkManager."
read -r continue
echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock
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-deamon.sock
echo "setting set focus#highlight=False" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
fi
# Check for internet connectivity
if ping -qc1 -W 1 stormux.org &> /dev/null; then
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
sas.sh
-582
View File
@@ -1,582 +0,0 @@
#!/usr/bin/env python3
import os
import re
import secrets
import signal
import string
import subprocess
import sys
import tempfile
import time
import pwd
import threading
stormuxAdmin = ("storm",)
ircServer = "irc.stormux.org"
ircPort = 6667
ircChannel = "#stormux"
remoteHost = "billysballoons.com"
sasUser = "sas"
pingIntervalSeconds = 180
pingCount = 5
maxWormholeFailures = 3
sudoKeepaliveThread = None
sudoKeepaliveStop = threading.Event()
def speak_message(message):
try:
subprocess.run(["spd-say", message], check=False)
except FileNotFoundError:
print(message, flush=True)
def say_or_print(message, useSpeech):
if useSpeech:
speak_message(message)
else:
print(message, flush=True)
def run_command(command, inputText=None, check=False, env=None):
return subprocess.run(
command,
input=inputText,
text=True,
capture_output=True,
check=check,
env=env,
)
def ensure_sudo(useSpeech):
if os.geteuid() == 0:
return True
if useSpeech:
speak_message("Sudo password required. Please enter your password now.")
result = run_command(["sudo", "-v"])
if result.returncode == 0:
start_sudo_keepalive()
return True
return False
def start_sudo_keepalive():
global sudoKeepaliveThread
if sudoKeepaliveThread and sudoKeepaliveThread.is_alive():
return
def keepalive_loop():
while not sudoKeepaliveStop.wait(240):
run_command(["sudo", "-n", "-v"])
sudoKeepaliveThread = threading.Thread(target=keepalive_loop, daemon=True)
sudoKeepaliveThread.start()
def run_privileged(command, useSpeech, inputText=None, check=True):
if os.geteuid() == 0:
fullCommand = command
else:
if not ensure_sudo(useSpeech):
raise RuntimeError("sudo authentication failed")
fullCommand = ["sudo"] + command
return run_command(fullCommand, inputText=inputText, check=check)
def run_as_user(userName, command, useSpeech, check=True):
if os.geteuid() == 0:
fullCommand = ["sudo", "-u", userName, "-H"] + command
else:
if not ensure_sudo(useSpeech):
raise RuntimeError("sudo authentication failed")
fullCommand = ["sudo", "-u", userName, "-H"] + command
return run_command(fullCommand, check=check)
def user_exists(userName):
result = run_command(["getent", "passwd", userName])
return result.returncode == 0
def ensure_wheel(userName, useSpeech):
result = run_command(["id", "-nG", userName])
groups = result.stdout.strip().split()
if "wheel" not in groups:
run_privileged(["usermod", "-a", "-G", "wheel", userName], useSpeech)
def generate_password():
allowedChars = string.ascii_letters + string.digits
length = secrets.randbelow(5) + 6
return "".join(secrets.choice(allowedChars) for _ in range(length))
def get_user_home(userName):
return pwd.getpwnam(userName).pw_dir
def set_password(userName, password, useSpeech):
run_privileged(["chpasswd"], useSpeech, inputText=f"{userName}:{password}\n")
def generate_ssh_key(userName, useSpeech):
userHome = get_user_home(userName)
sshDir = os.path.join(userHome, ".ssh")
privateKeyPath = os.path.join(sshDir, "id_ed25519")
publicKeyPath = f"{privateKeyPath}.pub"
run_privileged(["mkdir", "-p", sshDir], useSpeech)
run_privileged(["chown", f"{userName}:{userName}", sshDir], useSpeech)
run_privileged(["chmod", "700", sshDir], useSpeech)
for entry in list_subdirs(sshDir):
if os.path.isfile(entry) or os.path.islink(entry):
run_privileged(["rm", "-f", entry], useSpeech, check=False)
run_as_user(
userName,
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", privateKeyPath],
useSpeech,
)
run_privileged(["chmod", "600", privateKeyPath], useSpeech)
run_privileged(["chmod", "644", publicKeyPath], useSpeech)
run_privileged(["chown", f"{userName}:{userName}", privateKeyPath, publicKeyPath], useSpeech)
return privateKeyPath, publicKeyPath
def path_exists_for_user(path, userName, useSpeech):
result = run_as_user(userName, ["stat", path], useSpeech, check=False)
return result.returncode == 0
def extract_message_text(line):
if "> " in line:
return line.split("> ", 1)[1].strip()
if ": " in line:
return line.split(": ", 1)[1].strip()
return line.strip()
def parse_sender(line):
match = re.search(r"<([^>]+)>", line)
if match:
return match.group(1)
return None
def find_wormhole_code(message):
lowered = message.strip().lower()
if lowered in ("yes", "accept"):
return None
match = re.search(r"\b\d+-[A-Za-z0-9-]+\b", message)
if match:
return match.group(0)
return None
class IrcSession:
def __init__(self, server, port, nick, channel, baseDir):
self.server = server
self.port = port
self.nick = nick
self.channel = channel
self.baseDir = baseDir
self.serverDir = None
self.serverInPath = None
self.channelInPath = None
self.iiProcess = None
self.pmOffsets = {}
def start(self):
if not shutil_which("ii"):
raise RuntimeError("ii is not installed")
supportsI = ii_supports_i()
processEnv = os.environ.copy()
iiCommand = ["ii", "-s", self.server, "-p", str(self.port), "-n", self.nick]
if supportsI:
iiCommand += ["-i", self.baseDir]
else:
processEnv["HOME"] = self.baseDir
self.iiProcess = subprocess.Popen(
iiCommand,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=processEnv,
)
self.serverDir = self.wait_for_server_dir()
self.serverInPath = os.path.join(self.serverDir, "in")
def stop(self):
if self.channelInPath and os.path.exists(self.channelInPath):
try:
self.write_line(self.channelInPath, f"/part {self.channel}")
except OSError:
pass
if self.serverInPath and os.path.exists(self.serverInPath):
try:
self.write_line(self.serverInPath, "/quit")
except OSError:
pass
if self.iiProcess and self.iiProcess.poll() is None:
self.iiProcess.terminate()
try:
self.iiProcess.wait(timeout=5)
except subprocess.TimeoutExpired:
self.iiProcess.kill()
def join_channel(self):
joinMessage = f"/join {self.channel}"
channelDir = os.path.join(self.serverDir, self.channel)
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
startTime = time.monotonic()
nextJoinTime = startTime
while time.monotonic() - startTime < 60:
if time.monotonic() >= nextJoinTime:
self.write_line(self.serverInPath, joinMessage)
nextJoinTime = time.monotonic() + 5
for candidate in (channelDir, channelAltDir):
inPath = os.path.join(candidate, "in")
if os.path.exists(inPath):
self.channelInPath = inPath
return
time.sleep(0.5)
self.channelInPath = None
def send_channel_message(self, message):
if not self.channelInPath:
self.refresh_channel_in_path()
if self.channelInPath and os.path.exists(self.channelInPath):
self.write_line(self.channelInPath, message)
else:
self.write_line(self.serverInPath, f"/msg {self.channel} {message}")
def send_private_message(self, nick, message):
nickDir = os.path.join(self.serverDir, nick)
inPath = os.path.join(nickDir, "in")
if os.path.exists(inPath):
self.write_line(inPath, message)
else:
self.write_line(self.serverInPath, f"/msg {nick} {message}")
def get_private_messages(self, allowedUsers):
messages = []
for nick in allowedUsers:
nickDir = os.path.join(self.serverDir, nick)
outPath = os.path.join(nickDir, "out")
if not os.path.exists(outPath):
continue
lastPos = self.pmOffsets.get(outPath, 0)
with open(outPath, "r", encoding="utf-8", errors="ignore") as fileHandle:
fileHandle.seek(lastPos)
for line in fileHandle:
sender = parse_sender(line)
if sender and sender == self.nick:
continue
if sender and sender != nick:
continue
messageText = extract_message_text(line)
if messageText:
messages.append((nick, messageText))
self.pmOffsets[outPath] = fileHandle.tell()
return messages
def refresh_channel_in_path(self):
channelDir = os.path.join(self.serverDir, self.channel)
channelAltDir = os.path.join(self.serverDir, self.channel.lstrip("#"))
for candidate in (channelDir, channelAltDir):
inPath = os.path.join(candidate, "in")
if os.path.exists(inPath):
self.channelInPath = inPath
return
def wait_for_server_dir(self):
for _ in range(120):
for rootDir in [self.baseDir] + list_subdirs(self.baseDir):
if not os.path.isdir(rootDir):
continue
for entry in os.listdir(rootDir):
path = os.path.join(rootDir, entry)
if os.path.isdir(path) and self.server in entry:
inPath = os.path.join(path, "in")
if os.path.exists(inPath):
return path
time.sleep(0.5)
raise RuntimeError("ii server directory not found")
@staticmethod
def write_line(path, message):
with open(path, "w", encoding="utf-8", errors="ignore") as fileHandle:
fileHandle.write(message + "\n")
fileHandle.flush()
def ii_supports_i():
result = run_command(["ii", "-h"])
output = (result.stdout or "") + (result.stderr or "")
return "-i" in output
def list_subdirs(path):
try:
return [os.path.join(path, entry) for entry in os.listdir(path)]
except OSError:
return []
def shutil_which(command):
for path in os.environ.get("PATH", "").split(os.pathsep):
candidate = os.path.join(path, command)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def build_nick():
baseUser = os.environ.get("SUDO_USER") or os.environ.get("USER") or "sas"
return f"{baseUser}-{int(time.time())}"
def main():
say_or_print("Checking accessibility. Is your screen reader working? (y/n)", True)
answer = input().strip().lower()
useSpeech = answer in ("n", "no")
shouldRemoveUser = False
cleanupDone = False
tempDir = tempfile.mkdtemp(prefix="sas-ii-")
ircSession = None
sshProcess = None
def cleanup(exitMessage=None):
nonlocal cleanupDone
if cleanupDone:
return
cleanupDone = True
nonlocal sshProcess
if exitMessage:
say_or_print(exitMessage, useSpeech)
if sshProcess and sshProcess.poll() is None:
sshProcess.terminate()
try:
sshProcess.wait(timeout=5)
except subprocess.TimeoutExpired:
sshProcess.kill()
if ircSession:
ircSession.stop()
if shouldRemoveUser:
try:
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
time.sleep(1)
result = run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
if result.returncode != 0 and user_exists(sasUser):
say_or_print(
"Cleanup warning: failed to remove sas user. Please remove it manually.",
useSpeech,
)
except Exception:
pass
sudoKeepaliveStop.set()
if sudoKeepaliveThread:
sudoKeepaliveThread.join(timeout=2)
try:
remove_tree(tempDir)
except Exception:
pass
def handle_signal(signum, frame):
cleanup("Interrupted. Cleaning up.")
sys.exit(1)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
try:
if not user_exists(sasUser):
run_privileged(
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
useSpeech,
)
shouldRemoveUser = True
else:
say_or_print(
"User 'sas' exists. Remove and recreate it? This will delete /home/sas. (y/n)",
useSpeech,
)
response = input().strip().lower()
if response not in ("y", "yes"):
cleanup("The sas user is unavailable. Remove it manually and try again.")
return 1
run_privileged(["pkill", "-u", sasUser], useSpeech, check=False)
run_privileged(["userdel", "-r", sasUser], useSpeech, check=False)
run_privileged(["rm", "-rf", f"/home/{sasUser}"], useSpeech, check=False)
run_privileged(
["useradd", "-m", "-d", f"/home/{sasUser}", "-s", "/bin/bash", "-G", "wheel", sasUser],
useSpeech,
)
shouldRemoveUser = True
ensure_wheel(sasUser, useSpeech)
password = generate_password()
set_password(sasUser, password, useSpeech)
privateKeyPath, publicKeyPath = generate_ssh_key(sasUser, useSpeech)
sasHome = get_user_home(sasUser)
knownHostsPath = os.path.join(sasHome, ".ssh", "known_hosts_sas")
run_privileged(["touch", knownHostsPath], useSpeech)
run_privileged(["chmod", "600", knownHostsPath], useSpeech)
run_privileged(["chown", f"{sasUser}:{sasUser}", knownHostsPath], useSpeech)
nick = build_nick()
ircSession = IrcSession(ircServer, ircPort, nick, ircChannel, tempDir)
ircSession.start()
ircSession.join_channel()
say_or_print("Waiting for assistance on IRC.", useSpeech)
startTime = time.monotonic()
nextPingTime = startTime
pingsSent = 0
confirmedAdmin = None
while time.monotonic() - startTime < pingIntervalSeconds * pingCount:
now = time.monotonic()
if pingsSent < pingCount and now >= nextPingTime:
ircSession.send_channel_message(f"{nick} is requesting assistance.")
pingsSent += 1
nextPingTime = startTime + (pingsSent * pingIntervalSeconds)
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
if messageText.strip().lower() in ("yes", "accept"):
confirmedAdmin = adminNick
break
if confirmedAdmin:
break
time.sleep(1)
if not confirmedAdmin:
cleanup("No one was available to help, please try again later.")
return 1
ircSession.send_private_message(
confirmedAdmin,
f'password: "{password}" please send wormhole ssh invite code',
)
failures = 0
while failures < maxWormholeFailures:
inviteCode = None
while inviteCode is None:
for adminNick, messageText in ircSession.get_private_messages(stormuxAdmin):
inviteCode = find_wormhole_code(messageText)
if inviteCode:
break
if inviteCode:
break
time.sleep(1)
if not path_exists_for_user(publicKeyPath, sasUser, useSpeech):
raise RuntimeError(f"Public key missing: {publicKeyPath}")
wormholeCommand = [
"wormhole",
"ssh",
"accept",
"--yes",
inviteCode,
]
result = run_as_user(sasUser, wormholeCommand, useSpeech, check=False)
if result.returncode == 0:
say_or_print("Wormhole key transfer succeeded.", useSpeech)
break
failures += 1
errorTextFull = (result.stderr or result.stdout or "").strip()
if errorTextFull and not useSpeech:
print("Wormhole ssh accept error:", flush=True)
print(errorTextFull, flush=True)
errorText = errorTextFull
if errorText:
errorText = " ".join(errorText.split())
if len(errorText) > 400:
errorText = errorText[:400] + "..."
ircSession.send_private_message(
confirmedAdmin,
f"Wormhole ssh accept failed: {errorText}",
)
ircSession.send_private_message(
confirmedAdmin,
"Wormhole ssh accept failed. Please send a new invite code.",
)
if failures >= maxWormholeFailures:
cleanup("Wormhole failed too many times. Exiting.")
return 1
say_or_print("Starting reverse SSH tunnel. Press Ctrl+C to stop.", useSpeech)
sshCommand = [
"ssh",
"-N",
"-R",
"localhost:2232:localhost:22",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"BatchMode=yes",
"-o",
"ServerAliveInterval=30",
"-o",
"ServerAliveCountMax=3",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
f"UserKnownHostsFile={knownHostsPath}",
"-i",
privateKeyPath,
f"{sasUser}@{remoteHost}",
]
sshCommand = ["sudo", "-u", sasUser, "-H"] + sshCommand
sshProcess = subprocess.Popen(sshCommand)
sshProcess.wait()
except Exception as exc:
cleanup(f"Error: {exc}")
return 1
finally:
cleanup()
return 0
def remove_tree(path):
if not os.path.exists(path):
return
for rootDir, dirNames, fileNames in os.walk(path, topdown=False):
for fileName in fileNames:
try:
os.unlink(os.path.join(rootDir, fileName))
except OSError:
pass
for dirName in dirNames:
try:
os.rmdir(os.path.join(rootDir, dirName))
except OSError:
pass
try:
os.rmdir(path)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())
@@ -4,7 +4,7 @@
# Monitors SSH logins and announces them via Fenrir's speech system
# Configuration
fenrirSocket="/tmp/fenrirscreenreader-deamon.sock"
fenrirSocket="/tmp/fenrirscreenreader-daemon.sock"
logFile="/var/log/auth.log"
stateFile="/tmp/fenrir-ssh-monitor.state"
checkInterval=2 # seconds between checks
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Fix live ISO services for x86_64
set -e
cd "$(dirname "$0")"
echo "=== Fixing Live ISO Services for x86_64 ==="
echo
# 1. Remove Pi-specific first boot script
if [ -f airootfs/etc/profile.d/stormux_first_boot.sh ]; then
echo "Removing Pi-specific first boot script..."
sudo rm airootfs/etc/profile.d/stormux_first_boot.sh
fi
# 2. Mask systemd-firstboot (blocks boot with interactive prompts)
echo "Masking systemd-firstboot.service..."
sudo ln -sf /dev/null airootfs/etc/systemd/system/systemd-firstboot.service
# 3. Install Fenrir audio configurations
echo "Installing Fenrir audio configurations..."
sudo ./setup-fenrir-audio-configs.sh
# 4. Check/verify enabled services
echo
echo "=== Enabled Services ==="
ls -la airootfs/etc/systemd/system/multi-user.target.wants/ | grep -E "fenrir|speech|audio" || echo "No speech/audio services in multi-user.target.wants"
ls -la airootfs/etc/systemd/system/sound.target.wants/ | grep -E "audio|sound" || echo "No services in sound.target.wants"
echo
echo "=== Configuration Complete ==="
echo "Next step: sudo ./build.sh -v"
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Mask systemd-firstboot for live ISO (it blocks boot)
cd airootfs/etc/systemd/system
# Create mask symlink
ln -sf /dev/null systemd-firstboot.service
echo "Masked systemd-firstboot.service"
ls -la systemd-firstboot.service
+1
View File
@@ -18,6 +18,7 @@ btrfs-progs
clonezilla
cloud-init
cryptsetup
curl
darkhttpd
ddrescue
dhcpcd
-3
View File
@@ -19,7 +19,6 @@ file_permissions=(
["/etc/shadow"]="0:0:400"
["/root"]="0:0:750"
["/root/.automated_script.sh"]="0:0:755"
["/usr/local/bin/sas"]="0:0:755"
["/root/customize_airootfs.sh"]="0:0:755"
["/root/.gnupg"]="0:0:700"
["/root/.config"]="0:0:700"
@@ -30,8 +29,6 @@ file_permissions=(
["/usr/local/bin/install-stormux"]="0:0:755"
["/usr/local/bin/stormux-setup.sh"]="0:0:755"
["/usr/local/bin/init-pipewire-sound.sh"]="0:0:755"
["/usr/local/bin/sas.sh"]="0:0:755"
["/usr/local/bin/sas"]="0:0:755"
["/usr/share/fenrirscreenreader/scripts/ssh-login-monitor.sh"]="0:0:755"
["/etc/stormux-assist"]="0:0:755"
["/etc/stormux-assist/client.conf"]="0:0:644"
+25
View File
@@ -21,6 +21,29 @@
set -e
checkDependencies() {
local missingCommands=()
local command
for command in qemu-system-x86_64 qemu-img date dirname find sort head cut mkdir; do
if ! command -v "$command" &> /dev/null; then
missingCommands+=("$command")
fi
done
if (( ${#missingCommands[@]} > 0 )); then
echo "Error: Missing required commands:"
printf ' %s\n' "${missingCommands[@]}"
echo
echo "On Arch Linux, install the required packages with:"
echo " sudo pacman -S qemu-system-x86 qemu-img qemu-ui-gtk qemu-audio-pa coreutils findutils"
exit 1
fi
}
# make sure we are in a GUI
DISPLAY="${DISPLAY:-:0}"
# Parse command line arguments
bootInstalled=false
while getopts "ih" opt; do
@@ -44,6 +67,8 @@ while getopts "ih" opt; do
esac
done
checkDependencies
# Get the directory where this script is located
scriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
outDir="${scriptDir}/out"