From a8d4fc1fc10a949b14bd5d758612f3d05a1b11f7 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 15 Sep 2026 22:52:13 -0400 Subject: [PATCH] Experimental: add speech selector to startup. --- pi4/build/build-stormux.sh | 10 +- pi4/files/etc/profile.d/stormux_first_boot.sh | 2 + .../local/lib/stormux/select-speech-module | 327 ++++++++++++++++++ tests/test-select-speech-module.sh | 184 ++++++++++ .../etc/profile.d/stormux_first_boot.sh | 5 +- x86_64/airootfs/root/customize_airootfs.sh | 14 + x86_64/airootfs/usr/local/bin/install-stormux | 37 +- .../local/lib/stormux/select-speech-module | 327 ++++++++++++++++++ x86_64/packages.x86_64 | 1 + x86_64/profiledef.sh | 1 + 10 files changed, 898 insertions(+), 10 deletions(-) create mode 100755 pi4/files/usr/local/lib/stormux/select-speech-module create mode 100755 tests/test-select-speech-module.sh create mode 100755 x86_64/airootfs/usr/local/lib/stormux/select-speech-module diff --git a/pi4/build/build-stormux.sh b/pi4/build/build-stormux.sh index 891da54..3c07736 100755 --- a/pi4/build/build-stormux.sh +++ b/pi4/build/build-stormux.sh @@ -442,6 +442,7 @@ packages=( man-pages networkmanager nodm-dgw + openevv-git openssh parted pipewire @@ -548,10 +549,13 @@ 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 + speechBackup="\$(mktemp)" + if /usr/local/lib/stormux/select-speech-module \ + --apply-module rhvoice /etc/speech-dispatcher/speechd.conf "\$speechBackup"; then + rm -f "\$speechBackup" else - printf '\nDefaultModule rhvoice\n' >> /etc/speech-dispatcher/speechd.conf + echo "Warning: Could not set RHVoice as the default speech engine." + rm -f "\$speechBackup" fi fi # Enable linger so that hopefully sound will start at login. diff --git a/pi4/files/etc/profile.d/stormux_first_boot.sh b/pi4/files/etc/profile.d/stormux_first_boot.sh index d43b470..4d277d9 100755 --- a/pi4/files/etc/profile.d/stormux_first_boot.sh +++ b/pi4/files/etc/profile.d/stormux_first_boot.sh @@ -60,6 +60,8 @@ if [[ -x /etc/audibleprompt.sh ]]; then export sudoFlags=("-A") fi +/usr/local/lib/stormux/select-speech-module + echo echo "Starting Fenrir..." spd-say "Starting Fenrir." diff --git a/pi4/files/usr/local/lib/stormux/select-speech-module b/pi4/files/usr/local/lib/stormux/select-speech-module new file mode 100755 index 0000000..44e20b8 --- /dev/null +++ b/pi4/files/usr/local/lib/stormux/select-speech-module @@ -0,0 +1,327 @@ +#!/usr/bin/env bash + +set -u + +speechConfig="${STORMUX_SPEECH_CONFIG:-/etc/speech-dispatcher/speechd.conf}" +selectionFile="" +spdSayCommand="${STORMUX_SPD_SAY:-spd-say}" +systemctlCommand="${STORMUX_SYSTEMCTL:-systemctl}" +killallCommand="${STORMUX_KILLALL:-killall}" +scriptPath="$(readlink -f "$0")" +runtimeDir="${STORMUX_RUNTIME_DIR:-/run/stormux}" + +is_safe_module() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] +} + +apply_module() { + local moduleName="$1" + local configPath="$2" + local backupPath="$3" + local tempPath + + is_safe_module "$moduleName" || return 1 + [[ -f "$configPath" ]] || return 1 + + mkdir -p "${backupPath%/*}" || return 1 + cp --preserve=mode,ownership,timestamps "$configPath" "$backupPath" || return 1 + tempPath="$(mktemp "${configPath}.stormux.XXXXXX")" || return 1 + + if ! awk -v moduleName="$moduleName" ' + BEGIN { wroteDefault = 0 } + /^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+/ { + if (!wroteDefault) { + print "DefaultModule " moduleName + wroteDefault = 1 + } + next + } + { print } + END { + if (!wroteDefault) { + print "" + print "DefaultModule " moduleName + } + } + ' "$configPath" > "$tempPath"; then + rm -f "$tempPath" + return 1 + fi + + chown --reference="$configPath" "$tempPath" || { + rm -f "$tempPath" + return 1 + } + chmod --reference="$configPath" "$tempPath" || { + rm -f "$tempPath" + return 1 + } + mv -f "$tempPath" "$configPath" +} + +restore_config() { + local configPath="$1" + local backupPath="$2" + local tempPath + + [[ -f "$backupPath" ]] || return 1 + tempPath="$(mktemp "${configPath}.stormux.XXXXXX")" || return 1 + if ! cp --preserve=mode,ownership,timestamps "$backupPath" "$tempPath"; then + rm -f "$tempPath" + return 1 + fi + mv -f "$tempPath" "$configPath" || return 1 + rm -f "$backupPath" +} + +write_selection() { + local moduleName="$1" + local targetPath="$2" + local tempPath + + is_safe_module "$moduleName" || return 1 + mkdir -p "${targetPath%/*}" || return 1 + tempPath="$(mktemp "${targetPath}.stormux.XXXXXX")" || return 1 + printf '%s\n' "$moduleName" > "$tempPath" || { + rm -f "$tempPath" + return 1 + } + chmod 644 "$tempPath" || { + rm -f "$tempPath" + return 1 + } + mv -f "$tempPath" "$targetPath" +} + +case "${1:-}" in + --apply-module) + [[ $# -eq 4 ]] || exit 2 + apply_module "$2" "$3" "$4" + exit $? + ;; + --restore-config) + [[ $# -eq 3 ]] || exit 2 + restore_config "$2" "$3" + exit $? + ;; + --write-selection) + [[ $# -eq 3 ]] || exit 2 + write_selection "$2" "$3" + exit $? + ;; +esac + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 ]] || exit 2 + speechConfig="$2" + shift 2 + ;; + --selection-file) + [[ $# -ge 2 ]] || exit 2 + selectionFile="$2" + shift 2 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +sudoCommand=(sudo) +if [[ -n "${SUDO_ASKPASS:-}" ]]; then + sudoCommand+=(-A) +fi + +announce_failure() { + local previousModule="$1" + local message="Speech engine selection failed. Your previous speech engine is still selected." + + "$spdSayCommand" -C >/dev/null 2>&1 || true + if is_safe_module "$previousModule"; then + "$spdSayCommand" -o "$previousModule" "$message" >/dev/null 2>&1 || \ + "$spdSayCommand" "$message" >/dev/null 2>&1 || true + else + "$spdSayCommand" "$message" >/dev/null 2>&1 || true + fi + printf '%s\n' "$message" >&2 +} + +restart_speech() { + "${sudoCommand[@]}" "$killallCommand" speech-dispatcher +} + +restart_fenrir_if_active() { + if "$systemctlCommand" is-active --quiet fenrirscreenreader.service 2>/dev/null; then + "${sudoCommand[@]}" "$systemctlCommand" restart fenrirscreenreader.service + fi +} + +discover_modules() { + local output line candidate + local skippedHeading=false + + if ! output="$("$spdSayCommand" -O 2>/dev/null)"; then + return 1 + fi + + modules=() + while IFS= read -r line; do + candidate="${line%$'\r'}" + candidate="${candidate#"${candidate%%[![:space:]]*}"}" + candidate="${candidate%"${candidate##*[![:space:]]}"}" + [[ -n "$candidate" ]] || continue + if [[ "$skippedHeading" == false ]]; then + skippedHeading=true + continue + fi + is_safe_module "$candidate" || continue + modules+=("$candidate") + done <<< "$output" + + [[ ${#modules[@]} -gt 0 ]] +} + +configured_module() { + local configured="" + + if [[ -r "$speechConfig" ]]; then + configured="$(awk '$1 == "DefaultModule" { print $2; exit }' "$speechConfig")" + fi + if ! is_safe_module "$configured"; then + configured="rhvoice" + fi + printf '%s\n' "$configured" +} + +module_index() { + local wanted="$1" + local index + + for index in "${!modules[@]}"; do + if [[ "${modules[$index]}" == "$wanted" ]]; then + printf '%s\n' "$index" + return 0 + fi + done + return 1 +} + +draw_menu() { + local index + + printf '\033[H\033[2J' + printf 'Select the default speech engine. Use Up and Down, Enter to select, or Escape to keep the current engine.\n\n' + for index in "${!modules[@]}"; do + if [[ $index -eq $selectedIndex ]]; then + printf '> %s\n' "${modules[$index]}" + else + printf ' %s\n' "${modules[$index]}" + fi + done +} + +read_menu_key() { + local first rest="" + + IFS= read -rsn1 first || return 1 + if [[ -z "$first" ]]; then + printf 'enter\n' + return 0 + fi + if [[ "$first" == $'\033' ]]; then + IFS= read -rsn2 -t 0.1 rest || true + case "$rest" in + '[A') printf 'up\n' ;; + '[B') printf 'down\n' ;; + '') printf 'escape\n' ;; + *) printf 'other\n' ;; + esac + return 0 + fi + printf 'other\n' +} + +declare -a modules=() +previousModule="$(configured_module)" +if ! discover_modules; then + announce_failure "$previousModule" + exit 0 +fi + +if ! selectedIndex="$(module_index "$previousModule")"; then + if selectedIndex="$(module_index rhvoice)"; then + : + else + selectedIndex=0 + fi +fi + +draw_menu +"$spdSayCommand" "Speech engine selection. ${modules[$selectedIndex]} is highlighted. Use up and down to preview engines, Enter to select, or Escape to keep the current engine." >/dev/null 2>&1 || true + +while true; do + if ! keyName="$(read_menu_key)"; then + announce_failure "$previousModule" + exit 0 + fi + case "$keyName" in + up) + selectedIndex=$(( (selectedIndex - 1 + ${#modules[@]}) % ${#modules[@]} )) + ;; + down) + selectedIndex=$(( (selectedIndex + 1) % ${#modules[@]} )) + ;; + enter) + break + ;; + escape) + "$spdSayCommand" -C >/dev/null 2>&1 || true + "$spdSayCommand" -o "$previousModule" "Keeping ${previousModule} as the default speech engine." >/dev/null 2>&1 || true + exit 0 + ;; + *) + continue + ;; + esac + + draw_menu + moduleName="${modules[$selectedIndex]}" + "$spdSayCommand" -C >/dev/null 2>&1 || true + "$spdSayCommand" -o "$moduleName" "This is ${moduleName}. If selected, I will be your default text-to-speech voice." >/dev/null 2>&1 || true +done + +selectedModule="${modules[$selectedIndex]}" +backupFile="${runtimeDir}/speechd.conf.$$.backup" +"$spdSayCommand" -C >/dev/null 2>&1 || true + +if ! "${sudoCommand[@]}" "$scriptPath" --apply-module "$selectedModule" "$speechConfig" "$backupFile"; then + "${sudoCommand[@]}" "$scriptPath" --restore-config "$speechConfig" "$backupFile" >/dev/null 2>&1 || true + announce_failure "$previousModule" + exit 0 +fi + +if ! restart_speech || ! restart_fenrir_if_active; then + "${sudoCommand[@]}" "$scriptPath" --restore-config "$speechConfig" "$backupFile" >/dev/null 2>&1 || true + restart_speech >/dev/null 2>&1 || true + restart_fenrir_if_active >/dev/null 2>&1 || true + announce_failure "$previousModule" + exit 0 +fi + +if [[ -n "$selectionFile" ]]; then + if ! "${sudoCommand[@]}" "$scriptPath" --write-selection "$selectedModule" "$selectionFile"; then + "${sudoCommand[@]}" "$scriptPath" --restore-config "$speechConfig" "$backupFile" >/dev/null 2>&1 || true + restart_speech >/dev/null 2>&1 || true + restart_fenrir_if_active >/dev/null 2>&1 || true + announce_failure "$previousModule" + exit 0 + fi +fi + +rm -f "$backupFile" 2>/dev/null || "${sudoCommand[@]}" rm -f "$backupFile" 2>/dev/null || true + +"$spdSayCommand" -o "$selectedModule" "${selectedModule} is now the default speech engine." >/dev/null 2>&1 || true +printf 'Default speech engine set to %s.\n' "$selectedModule" diff --git a/tests/test-select-speech-module.sh b/tests/test-select-speech-module.sh new file mode 100755 index 0000000..1b653e3 --- /dev/null +++ b/tests/test-select-speech-module.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repoRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +selector="$repoRoot/pi4/files/usr/local/lib/stormux/select-speech-module" +tempDir="$(mktemp -d)" +mockBin="$tempDir/bin" +testCount=0 + +cleanup() { + rm -rf "$tempDir" +} +trap cleanup EXIT + +mkdir -p "$mockBin" "$tempDir/runtime" + +cat > "$mockBin/sudo" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "-A" ]]; then + shift +fi +if [[ "${MOCK_SUDO_FAIL_WRITE:-no}" == "yes" && "$*" == *"--write-selection"* ]]; then + exit 1 +fi +exec "$@" +EOF + +cat > "$mockBin/spd-say" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$MOCK_LOG" +if [[ "${1:-}" == "-O" ]]; then + cat "$MOCK_MODULES" + exit "${MOCK_DISCOVERY_STATUS:-0}" +fi +exit 0 +EOF + +cat > "$mockBin/killall" <<'EOF' +#!/usr/bin/env bash +printf 'killall %s\n' "$*" >> "$MOCK_LOG" +if [[ "${MOCK_KILLALL_FAIL:-no}" == "yes" ]]; then + exit 1 +fi +EOF + +cat > "$mockBin/systemctl" <<'EOF' +#!/usr/bin/env bash +printf 'systemctl %s\n' "$*" >> "$MOCK_LOG" +if [[ "${1:-}" == "is-active" ]]; then + [[ "${MOCK_FENRIR_ACTIVE:-no}" == "yes" ]] + exit +fi +exit 0 +EOF +chmod 755 "$mockBin"/* + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_equals() { + local expected="$1" + local actual="$2" + local description="$3" + + [[ "$actual" == "$expected" ]] || fail "$description (expected '$expected', got '$actual')" +} + +assert_file_contains() { + local filePath="$1" + local pattern="$2" + local description="$3" + + grep -qE -- "$pattern" "$filePath" || fail "$description" +} + +assert_file_absent() { + local filePath="$1" + local description="$2" + + [[ ! -e "$filePath" ]] || fail "$description" +} + +reset_case() { + caseDir="$tempDir/case-$testCount" + testCount=$((testCount + 1)) + mkdir -p "$caseDir/runtime" + configFile="$caseDir/speechd.conf" + selectionFile="$caseDir/selected" + modulesFile="$caseDir/modules" + mockLog="$caseDir/mock.log" + : > "$mockLog" + printf 'OUTPUT MODULES\nrhvoice\nopenevv\nespeak-ng\n' > "$modulesFile" + printf '# Speech Dispatcher\nDefaultModule rhvoice\n' > "$configFile" +} + +run_selector() { + local input="$1" + + printf '%b' "$input" | env \ + PATH="$mockBin:$PATH" \ + MOCK_LOG="$mockLog" \ + MOCK_MODULES="$modulesFile" \ + MOCK_DISCOVERY_STATUS="${MOCK_DISCOVERY_STATUS:-0}" \ + MOCK_KILLALL_FAIL="${MOCK_KILLALL_FAIL:-no}" \ + MOCK_FENRIR_ACTIVE="${MOCK_FENRIR_ACTIVE:-no}" \ + MOCK_SUDO_FAIL_WRITE="${MOCK_SUDO_FAIL_WRITE:-no}" \ + STORMUX_SPEECH_CONFIG="$configFile" \ + STORMUX_RUNTIME_DIR="$caseDir/runtime" \ + STORMUX_SPD_SAY=spd-say \ + STORMUX_KILLALL=killall \ + STORMUX_SYSTEMCTL=systemctl \ + "$selector" --selection-file "$selectionFile" >/dev/null +} + +# Enter accepts the configured initial module and collapses duplicate directives. +reset_case +printf 'DefaultModule rhvoice\n# DefaultModule espeak-ng\nDefaultModule openevv\n' > "$configFile" +MOCK_FENRIR_ACTIVE=yes run_selector '\n' +assert_equals 1 "$(grep -c '^DefaultModule ' "$configFile")" "initial selection should leave one active default" +assert_file_contains "$configFile" '^DefaultModule rhvoice$' "initial selection should retain rhvoice" +assert_equals rhvoice "$(< "$selectionFile")" "initial selection should be recorded" +assert_file_contains "$mockLog" '^systemctl restart fenrirscreenreader\.service$' "active Fenrir should restart" + +# Down previews and selects the next module. +reset_case +run_selector 'x\033[B\n' +assert_file_contains "$mockLog" '^-o openevv This is openevv\.' "Down should preview OpenEVV" +assert_file_contains "$configFile" '^DefaultModule openevv$' "Down then Enter should select OpenEVV" + +# Up wraps from the first module to the last. +reset_case +run_selector '\033[A\n' +assert_file_contains "$mockLog" '^-o espeak-ng This is espeak-ng\.' "Up should wrap and preview eSpeak NG" +assert_file_contains "$configFile" '^DefaultModule espeak-ng$' "wrapped module should persist" + +# Escape retains the prior file and does not create an installer selection. +reset_case +cp "$configFile" "$caseDir/original.conf" +run_selector '\033' +cmp -s "$configFile" "$caseDir/original.conf" || fail "Escape should retain the configuration" +assert_file_absent "$selectionFile" "Escape should not record an installer selection" + +# Empty discovery fails safely. +reset_case +printf 'OUTPUT MODULES\n' > "$modulesFile" +cp "$configFile" "$caseDir/original.conf" +run_selector '\n' +cmp -s "$configFile" "$caseDir/original.conf" || fail "empty discovery should retain the configuration" +assert_file_absent "$selectionFile" "empty discovery should not record a selection" + +# Unsafe names are rejected rather than passed to spd-say or configuration. +reset_case +printf 'OUTPUT MODULES\nbad name\n;command\nopenevv\n' > "$modulesFile" +run_selector '\n' +assert_file_contains "$configFile" '^DefaultModule openevv$' "the remaining safe module should be selected" +if grep -qE 'bad name|;command' "$mockLog"; then + fail "unsafe module names should not reach spd-say" +fi + +# A missing configuration simulates failed persistence and remains nonfatal. +reset_case +rm -f "$configFile" +run_selector '\n' +assert_file_absent "$selectionFile" "failed persistence should not record a selection" +assert_file_contains "$mockLog" 'selection failed' "failed persistence should be announced" + +# A restart failure restores the byte-for-byte original configuration. +reset_case +cp "$configFile" "$caseDir/original.conf" +MOCK_KILLALL_FAIL=yes run_selector '\033[B\n' +cmp -s "$configFile" "$caseDir/original.conf" || fail "restart failure should roll back the configuration" +assert_file_absent "$selectionFile" "restart failure should not record a selection" + +# Failure to record the live choice also rolls the system configuration back. +reset_case +cp "$configFile" "$caseDir/original.conf" +MOCK_SUDO_FAIL_WRITE=yes run_selector '\033[B\n' +cmp -s "$configFile" "$caseDir/original.conf" || fail "selection-file failure should roll back the configuration" +assert_file_absent "$selectionFile" "selection-file failure should leave no selection" + +printf 'PASS: %s speech selector cases\n' "$testCount" diff --git a/x86_64/airootfs/etc/profile.d/stormux_first_boot.sh b/x86_64/airootfs/etc/profile.d/stormux_first_boot.sh index cf506a2..b7eba60 100755 --- a/x86_64/airootfs/etc/profile.d/stormux_first_boot.sh +++ b/x86_64/airootfs/etc/profile.d/stormux_first_boot.sh @@ -32,7 +32,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,6 +47,9 @@ if [[ -x /etc/audibleprompt.sh ]]; then export sudoFlags=("-A") fi +/usr/local/lib/stormux/select-speech-module \ + --selection-file /run/stormux/selected-speech-module + clear cat << "EOF" Hello, and welcome to Stormux! diff --git a/x86_64/airootfs/root/customize_airootfs.sh b/x86_64/airootfs/root/customize_airootfs.sh index d9af284..c4c1175 100644 --- a/x86_64/airootfs/root/customize_airootfs.sh +++ b/x86_64/airootfs/root/customize_airootfs.sh @@ -94,6 +94,20 @@ locale-gen install_sas install_rhvoice_english_fixes +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 + speechBackup="$(mktemp)" + if /usr/local/lib/stormux/select-speech-module \ + --apply-module rhvoice /etc/speech-dispatcher/speechd.conf "$speechBackup"; then + rm -f "$speechBackup" + else + echo "Warning: Could not set RHVoice as the default speech engine." + rm -f "$speechBackup" + fi +fi + # Enable system services systemctl enable NetworkManager.service systemctl enable fenrirscreenreader.service diff --git a/x86_64/airootfs/usr/local/bin/install-stormux b/x86_64/airootfs/usr/local/bin/install-stormux index 90edcf5..27bc055 100755 --- a/x86_64/airootfs/usr/local/bin/install-stormux +++ b/x86_64/airootfs/usr/local/bin/install-stormux @@ -31,6 +31,14 @@ errorCount=0 warningCount=0 currentStep="initialization" cleanupOnError=false +selectedSpeechModule="rhvoice" + +if [[ -r /run/stormux/selected-speech-module ]]; then + mapfile -t selectedSpeechLines < /run/stormux/selected-speech-module + if [[ ${#selectedSpeechLines[@]} -eq 1 && "${selectedSpeechLines[0]}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + selectedSpeechModule="${selectedSpeechLines[0]}" + fi +fi # @@ -1412,7 +1420,7 @@ install_base_system() { # Stormux-specific packages (installed later in chroot after keyring setup) # Note: NOT local so it's available in configure_system() stormuxPackages=( - yay w3m-git + openevv-git yay w3m-git ) # Combine all packages @@ -1902,11 +1910,28 @@ if command -v spd-conf > /dev/null 2>&1; then 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 + speechConfig=/etc/speech-dispatcher/speechd.conf + speechConfigTemp="\$(mktemp "\${speechConfig}.stormux.XXXXXX")" + awk -v moduleName="${selectedSpeechModule}" ' + BEGIN { wroteDefault = 0 } + /^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+/ { + if (!wroteDefault) { + print "DefaultModule " moduleName + wroteDefault = 1 + } + next + } + { print } + END { + if (!wroteDefault) { + print "" + print "DefaultModule " moduleName + } + } + ' "\$speechConfig" > "\$speechConfigTemp" + chown --reference="\$speechConfig" "\$speechConfigTemp" + chmod --reference="\$speechConfig" "\$speechConfigTemp" + mv -f "\$speechConfigTemp" "\$speechConfig" fi # Create system-wide accessibility configuration diff --git a/x86_64/airootfs/usr/local/lib/stormux/select-speech-module b/x86_64/airootfs/usr/local/lib/stormux/select-speech-module new file mode 100755 index 0000000..44e20b8 --- /dev/null +++ b/x86_64/airootfs/usr/local/lib/stormux/select-speech-module @@ -0,0 +1,327 @@ +#!/usr/bin/env bash + +set -u + +speechConfig="${STORMUX_SPEECH_CONFIG:-/etc/speech-dispatcher/speechd.conf}" +selectionFile="" +spdSayCommand="${STORMUX_SPD_SAY:-spd-say}" +systemctlCommand="${STORMUX_SYSTEMCTL:-systemctl}" +killallCommand="${STORMUX_KILLALL:-killall}" +scriptPath="$(readlink -f "$0")" +runtimeDir="${STORMUX_RUNTIME_DIR:-/run/stormux}" + +is_safe_module() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] +} + +apply_module() { + local moduleName="$1" + local configPath="$2" + local backupPath="$3" + local tempPath + + is_safe_module "$moduleName" || return 1 + [[ -f "$configPath" ]] || return 1 + + mkdir -p "${backupPath%/*}" || return 1 + cp --preserve=mode,ownership,timestamps "$configPath" "$backupPath" || return 1 + tempPath="$(mktemp "${configPath}.stormux.XXXXXX")" || return 1 + + if ! awk -v moduleName="$moduleName" ' + BEGIN { wroteDefault = 0 } + /^[[:space:]]*#?[[:space:]]*DefaultModule[[:space:]]+/ { + if (!wroteDefault) { + print "DefaultModule " moduleName + wroteDefault = 1 + } + next + } + { print } + END { + if (!wroteDefault) { + print "" + print "DefaultModule " moduleName + } + } + ' "$configPath" > "$tempPath"; then + rm -f "$tempPath" + return 1 + fi + + chown --reference="$configPath" "$tempPath" || { + rm -f "$tempPath" + return 1 + } + chmod --reference="$configPath" "$tempPath" || { + rm -f "$tempPath" + return 1 + } + mv -f "$tempPath" "$configPath" +} + +restore_config() { + local configPath="$1" + local backupPath="$2" + local tempPath + + [[ -f "$backupPath" ]] || return 1 + tempPath="$(mktemp "${configPath}.stormux.XXXXXX")" || return 1 + if ! cp --preserve=mode,ownership,timestamps "$backupPath" "$tempPath"; then + rm -f "$tempPath" + return 1 + fi + mv -f "$tempPath" "$configPath" || return 1 + rm -f "$backupPath" +} + +write_selection() { + local moduleName="$1" + local targetPath="$2" + local tempPath + + is_safe_module "$moduleName" || return 1 + mkdir -p "${targetPath%/*}" || return 1 + tempPath="$(mktemp "${targetPath}.stormux.XXXXXX")" || return 1 + printf '%s\n' "$moduleName" > "$tempPath" || { + rm -f "$tempPath" + return 1 + } + chmod 644 "$tempPath" || { + rm -f "$tempPath" + return 1 + } + mv -f "$tempPath" "$targetPath" +} + +case "${1:-}" in + --apply-module) + [[ $# -eq 4 ]] || exit 2 + apply_module "$2" "$3" "$4" + exit $? + ;; + --restore-config) + [[ $# -eq 3 ]] || exit 2 + restore_config "$2" "$3" + exit $? + ;; + --write-selection) + [[ $# -eq 3 ]] || exit 2 + write_selection "$2" "$3" + exit $? + ;; +esac + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 ]] || exit 2 + speechConfig="$2" + shift 2 + ;; + --selection-file) + [[ $# -ge 2 ]] || exit 2 + selectionFile="$2" + shift 2 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +sudoCommand=(sudo) +if [[ -n "${SUDO_ASKPASS:-}" ]]; then + sudoCommand+=(-A) +fi + +announce_failure() { + local previousModule="$1" + local message="Speech engine selection failed. Your previous speech engine is still selected." + + "$spdSayCommand" -C >/dev/null 2>&1 || true + if is_safe_module "$previousModule"; then + "$spdSayCommand" -o "$previousModule" "$message" >/dev/null 2>&1 || \ + "$spdSayCommand" "$message" >/dev/null 2>&1 || true + else + "$spdSayCommand" "$message" >/dev/null 2>&1 || true + fi + printf '%s\n' "$message" >&2 +} + +restart_speech() { + "${sudoCommand[@]}" "$killallCommand" speech-dispatcher +} + +restart_fenrir_if_active() { + if "$systemctlCommand" is-active --quiet fenrirscreenreader.service 2>/dev/null; then + "${sudoCommand[@]}" "$systemctlCommand" restart fenrirscreenreader.service + fi +} + +discover_modules() { + local output line candidate + local skippedHeading=false + + if ! output="$("$spdSayCommand" -O 2>/dev/null)"; then + return 1 + fi + + modules=() + while IFS= read -r line; do + candidate="${line%$'\r'}" + candidate="${candidate#"${candidate%%[![:space:]]*}"}" + candidate="${candidate%"${candidate##*[![:space:]]}"}" + [[ -n "$candidate" ]] || continue + if [[ "$skippedHeading" == false ]]; then + skippedHeading=true + continue + fi + is_safe_module "$candidate" || continue + modules+=("$candidate") + done <<< "$output" + + [[ ${#modules[@]} -gt 0 ]] +} + +configured_module() { + local configured="" + + if [[ -r "$speechConfig" ]]; then + configured="$(awk '$1 == "DefaultModule" { print $2; exit }' "$speechConfig")" + fi + if ! is_safe_module "$configured"; then + configured="rhvoice" + fi + printf '%s\n' "$configured" +} + +module_index() { + local wanted="$1" + local index + + for index in "${!modules[@]}"; do + if [[ "${modules[$index]}" == "$wanted" ]]; then + printf '%s\n' "$index" + return 0 + fi + done + return 1 +} + +draw_menu() { + local index + + printf '\033[H\033[2J' + printf 'Select the default speech engine. Use Up and Down, Enter to select, or Escape to keep the current engine.\n\n' + for index in "${!modules[@]}"; do + if [[ $index -eq $selectedIndex ]]; then + printf '> %s\n' "${modules[$index]}" + else + printf ' %s\n' "${modules[$index]}" + fi + done +} + +read_menu_key() { + local first rest="" + + IFS= read -rsn1 first || return 1 + if [[ -z "$first" ]]; then + printf 'enter\n' + return 0 + fi + if [[ "$first" == $'\033' ]]; then + IFS= read -rsn2 -t 0.1 rest || true + case "$rest" in + '[A') printf 'up\n' ;; + '[B') printf 'down\n' ;; + '') printf 'escape\n' ;; + *) printf 'other\n' ;; + esac + return 0 + fi + printf 'other\n' +} + +declare -a modules=() +previousModule="$(configured_module)" +if ! discover_modules; then + announce_failure "$previousModule" + exit 0 +fi + +if ! selectedIndex="$(module_index "$previousModule")"; then + if selectedIndex="$(module_index rhvoice)"; then + : + else + selectedIndex=0 + fi +fi + +draw_menu +"$spdSayCommand" "Speech engine selection. ${modules[$selectedIndex]} is highlighted. Use up and down to preview engines, Enter to select, or Escape to keep the current engine." >/dev/null 2>&1 || true + +while true; do + if ! keyName="$(read_menu_key)"; then + announce_failure "$previousModule" + exit 0 + fi + case "$keyName" in + up) + selectedIndex=$(( (selectedIndex - 1 + ${#modules[@]}) % ${#modules[@]} )) + ;; + down) + selectedIndex=$(( (selectedIndex + 1) % ${#modules[@]} )) + ;; + enter) + break + ;; + escape) + "$spdSayCommand" -C >/dev/null 2>&1 || true + "$spdSayCommand" -o "$previousModule" "Keeping ${previousModule} as the default speech engine." >/dev/null 2>&1 || true + exit 0 + ;; + *) + continue + ;; + esac + + draw_menu + moduleName="${modules[$selectedIndex]}" + "$spdSayCommand" -C >/dev/null 2>&1 || true + "$spdSayCommand" -o "$moduleName" "This is ${moduleName}. If selected, I will be your default text-to-speech voice." >/dev/null 2>&1 || true +done + +selectedModule="${modules[$selectedIndex]}" +backupFile="${runtimeDir}/speechd.conf.$$.backup" +"$spdSayCommand" -C >/dev/null 2>&1 || true + +if ! "${sudoCommand[@]}" "$scriptPath" --apply-module "$selectedModule" "$speechConfig" "$backupFile"; then + "${sudoCommand[@]}" "$scriptPath" --restore-config "$speechConfig" "$backupFile" >/dev/null 2>&1 || true + announce_failure "$previousModule" + exit 0 +fi + +if ! restart_speech || ! restart_fenrir_if_active; then + "${sudoCommand[@]}" "$scriptPath" --restore-config "$speechConfig" "$backupFile" >/dev/null 2>&1 || true + restart_speech >/dev/null 2>&1 || true + restart_fenrir_if_active >/dev/null 2>&1 || true + announce_failure "$previousModule" + exit 0 +fi + +if [[ -n "$selectionFile" ]]; then + if ! "${sudoCommand[@]}" "$scriptPath" --write-selection "$selectedModule" "$selectionFile"; then + "${sudoCommand[@]}" "$scriptPath" --restore-config "$speechConfig" "$backupFile" >/dev/null 2>&1 || true + restart_speech >/dev/null 2>&1 || true + restart_fenrir_if_active >/dev/null 2>&1 || true + announce_failure "$previousModule" + exit 0 + fi +fi + +rm -f "$backupFile" 2>/dev/null || "${sudoCommand[@]}" rm -f "$backupFile" 2>/dev/null || true + +"$spdSayCommand" -o "$selectedModule" "${selectedModule} is now the default speech engine." >/dev/null 2>&1 || true +printf 'Default speech engine set to %s.\n' "$selectedModule" diff --git a/x86_64/packages.x86_64 b/x86_64/packages.x86_64 index 4456c83..ba87fe0 100644 --- a/x86_64/packages.x86_64 +++ b/x86_64/packages.x86_64 @@ -90,6 +90,7 @@ nvme-cli open-iscsi open-vm-tools openconnect +openevv-git openpgp-card-tools openssh openvpn diff --git a/x86_64/profiledef.sh b/x86_64/profiledef.sh index a7959eb..22ba53d 100644 --- a/x86_64/profiledef.sh +++ b/x86_64/profiledef.sh @@ -27,6 +27,7 @@ file_permissions=( ["/usr/local/bin/livecd-sound"]="0:0:755" ["/usr/local/bin/configure-stormux"]="0:0:755" ["/usr/local/bin/install-stormux"]="0:0:755" + ["/usr/local/lib/stormux/select-speech-module"]="0:0:755" ["/usr/local/bin/stormux-setup.sh"]="0:0:755" ["/usr/local/bin/init-pipewire-sound.sh"]="0:0:755" ["/usr/share/fenrirscreenreader/scripts/ssh-login-monitor.sh"]="0:0:755"