Start work on game installer and launcher actually working.

This commit is contained in:
Storm Dragon
2026-04-21 14:47:09 -04:00
parent b83b995cf0
commit 2a8265a3b6
4 changed files with 386 additions and 2 deletions
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# shellcheck disable=SC2154
download_with_fallback() {
local outputPath="$1"
local sourceUrl="$2"
local errorFile=""
local curlExitCode=0
local retryExitCode=0
local fallbackExitCode=0
local wgetExitCode=0
downloadAttemptExitCodes=""
downloadErrorLog=""
errorFile="$(mktemp)" || return 1
if curl -L4 -C - --retry 10 --output "${outputPath}" "${sourceUrl}" 2> "${errorFile}"; then
rm -f "${errorFile}"
return 0
fi
curlExitCode=$?
rm -f "${outputPath}"
if curl -L4 --retry 10 --output "${outputPath}" "${sourceUrl}" 2>> "${errorFile}"; then
rm -f "${errorFile}"
return 0
fi
retryExitCode=$?
rm -f "${outputPath}"
if curl -L --retry 10 --output "${outputPath}" "${sourceUrl}" 2>> "${errorFile}"; then
rm -f "${errorFile}"
return 0
fi
fallbackExitCode=$?
if command -v wget > /dev/null 2>&1; then
rm -f "${outputPath}"
if wget --tries=10 --output-document="${outputPath}" "${sourceUrl}" 2>> "${errorFile}"; then
rm -f "${errorFile}"
return 0
fi
wgetExitCode=$?
fi
downloadAttemptExitCodes="curl: ${curlExitCode}, ${retryExitCode}, ${fallbackExitCode}"
if [[ "${wgetExitCode}" -ne 0 ]]; then
downloadAttemptExitCodes+="; wget: ${wgetExitCode}"
fi
if cp -f "${errorFile}" "${cache}/curl-error.log" 2> /dev/null; then
downloadErrorLog="${cache}/curl-error.log"
rm -f "${errorFile}"
else
downloadErrorLog="${errorFile}"
fi
return 1
}
validate_downloaded_cache_file() {
local dest="$1"
local downloadError=0
if [[ "${STORMUX_SKIP_DOWNLOAD_VALIDATION:-0}" == "1" ]]; then
return 0
fi
case "${dest##*.}" in
pk3|zip)
unzip -tq "${cache}/${dest}" | ui_progressbox "Game Installer" "Validating ${dest}"
downloadError=${PIPESTATUS[0]}
;;
7z)
7z t "${cache}/${dest}" | ui_progressbox "Game Installer" "Validating ${dest}"
downloadError=${PIPESTATUS[0]}
;;
exe)
if ! hexdump -n 2 -v -e '/1 "%02X"' "${cache}/${dest}" | grep -q "4D5A"; then
downloadError=1
fi
;;
wad)
if [[ "$(file -b --mime-type "${cache}/${dest}")" != "application/octet-stream" ]]; then
downloadError=1
fi
;;
*)
if file -b "${cache}/${dest}" | grep -q "HTML document"; then
downloadError=1
fi
;;
esac
if [[ "${downloadError}" -ne 0 ]]; then
rm -fv "${cache:?}/${dest}"
alert "Game Installer" "Game Installer" "Error downloading ${dest}. Installation cannot continue."
exit 1
fi
}
download() {
local sourceUrls=("$@")
local sourceUrl
local dest
for sourceUrl in "${sourceUrls[@]}"; do
dest="${sourceUrl##*/}"
dest="${dest//%20/ }"
dest="${dest#*\?filename=}"
dest="${dest%\?*}"
[[ -s "${cache}/${dest}" ]] || rm -f "${cache:?}/${dest}" 2> /dev/null
if [[ "${redownload}" == "true" && -e "${cache}/${dest}" ]]; then
rm -v "${cache:?}/${dest}"
fi
if [[ -e "${cache}/${dest}" ]]; then
continue
fi
ui_progressbox "Game Installer" "Downloading ${dest}" < /dev/null
if ! download_with_fallback "${cache}/${dest}" "${sourceUrl}"; then
ui_msgbox "Game Installer" "Game Installer" "Could not download ${sourceUrl}.\n\nDownload exit codes: ${downloadAttemptExitCodes}\nError details: ${downloadErrorLog}"
exit 1
fi
validate_downloaded_cache_file "${dest}"
done
}
download_named() {
local dest="$1"
local sourceUrl="$2"
[[ -n "${dest}" && -n "${sourceUrl}" ]] || return 1
[[ -s "${cache}/${dest}" ]] || rm -f "${cache:?}/${dest}" 2> /dev/null
if [[ "${redownload}" == "true" && -e "${cache}/${dest}" ]]; then
rm -v "${cache:?}/${dest}"
fi
if [[ -e "${cache}/${dest}" ]]; then
return 0
fi
if ! download_with_fallback "${cache}/${dest}" "${sourceUrl}"; then
ui_msgbox "Game Installer" "Game Installer" "Could not download ${dest}.\n\nDownload exit codes: ${downloadAttemptExitCodes}\nError details: ${downloadErrorLog}"
exit 1
fi
validate_downloaded_cache_file "${dest}"
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# shellcheck disable=SC2154
scriptDir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
source "${scriptDir}/download.sh"
speak() {
local message="$1"
printf '%s\n' "${message}"
spd-say "${message}" 2> /dev/null || true
}
ui_msgbox() {
local _title="$1"
local _backTitle="$2"
local message="$3"
speak "${message}"
}
ui_infobox() {
ui_msgbox "$@"
}
ui_yesno() {
local _title="$1"
local _backTitle="$2"
local message="$3"
speak "${message}"
return 1
}
ui_progressbox() {
local _title="$1"
local message="$2"
speak "${message}"
cat
}
alert() {
local title="Game Installer"
local backTitle="Game Installer"
local message="Press enter to continue."
if [[ $# -eq 1 ]]; then
message="$1"
elif [[ $# -eq 2 ]]; then
title="$1"
backTitle="$1"
message="$2"
elif [[ $# -ge 3 ]]; then
title="$1"
backTitle="$2"
shift 2
message="$*"
fi
ui_msgbox "${title}" "${backTitle}" "${message}"
}
check_architecture() {
local architecture
local supportedArch
architecture="$(uname -m)"
for supportedArch in "$@"; do
if [[ "${architecture}" == "${supportedArch}" ]]; then
return 0
fi
done
if [[ "${architecture}" == "aarch64" && -d "${HOME}/.fex-emu/RootFS/ArchLinux" && "$*" == *"x86_64"* ]]; then
export fex="FEXLoader -- "
return 0
fi
ui_infobox "Game Installer" "Game Installer" "This game is not compatible with ${architecture}."
exit 1
}
check_dependencies() {
local missingDependencies=()
local dependency
for dependency in "$@"; do
if [[ "${dependency}" == python-* ]]; then
if ! python3 -c "import ${dependency#*:}" > /dev/null 2>&1; then
missingDependencies+=("${dependency%:*}")
fi
elif ! command -v "${dependency}" > /dev/null 2>&1; then
missingDependencies+=("${dependency}")
fi
done
if [[ "${#missingDependencies[@]}" -eq 0 ]]; then
return 0
fi
ui_msgbox "Game Installer" "Game Installer" "Missing dependencies: ${missingDependencies[*]}"
exit 1
}
open_url() {
GAME="$*" startx
}
get_installer() {
local installerName="$1"
local sourceDir
if [[ -f "${cache}/${installerName}" ]]; then
return 0
fi
for sourceDir in "${HOME}/Downloads" "${HOME}/Desktop"; do
find "${sourceDir}" -type f -name "${installerName}" -exec mv -v {} "${cache}/" \; 2> /dev/null || true
done
if [[ ! -f "${cache}/${installerName}" ]]; then
ui_msgbox "Game Installer" "Game Installer" "Could not find ${installerName}. Manual download support is not implemented yet."
exit 1
fi
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
scriptDir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
installerPath="${1:-}"
if [[ -z "${installerPath}" || ! -f "${installerPath}" ]]; then
printf 'Usage: %s installer.sh\n' "${0##*/}" >&2
exit 2
fi
gameName="${installerPath##*/}"
gameName="${gameName%.sh}"
installPath="${installPath:-${HOME}/.local/games}"
cache="${cache:-${XDG_CACHE_HOME:-${HOME}/.cache}/stormux-game-install}"
launchRoot="${launchRoot:-${HOME}/.local/.launch}"
redownload="${redownload:-false}"
ipfsGateway="${ipfsGateway:-https://gateway.pinata.cloud}"
fex="${fex:-}"
export installPath cache launchRoot redownload ipfsGateway fex gameName
mkdir -p "${installPath}" "${cache}" "${launchRoot}"
# shellcheck disable=SC1091
source "${scriptDir}/game_install_functions.sh"
speak "Installing ${gameName}"
# shellcheck source=/dev/null
source "${installerPath}"
if [[ -f "${launchRoot}/${gameName}.game" ]]; then
ln -sf "${gameName}.game" "${launchRoot}/${gameName}.sh"
fi
speak "${gameName} installed"
+74 -2
View File
@@ -22,6 +22,9 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
INSTALL_GAME_RUNNER = Path("/home/stormux/.local/.functions/install_game.sh")
@dataclass(frozen=True) @dataclass(frozen=True)
class GameEntry: class GameEntry:
section: str section: str
@@ -69,6 +72,28 @@ def build_game_command(entry):
return f"STORMUX_LAUNCH_SCRIPT={launchPath!r} startx" return f"STORMUX_LAUNCH_SCRIPT={launchPath!r} startx"
return shlex.quote(str(entry.installScript)) return shlex.quote(str(entry.installScript))
def build_game_install_command(entry):
return f"{shlex.quote(str(INSTALL_GAME_RUNNER))} {shlex.quote(str(entry.installScript))}"
def build_game_uninstall_command(entry):
if entry.launchScript is None:
return ""
launchPath = shlex.quote(str(entry.launchScript))
gameName = shlex.quote(entry.name)
return f"rm -f {launchPath}; echo {gameName} uninstalled"
def build_game_actions(entry):
actions = []
if entry.isInstalled:
actions.append(("Play", build_game_command(entry)))
actions.append(("Uninstall", build_game_uninstall_command(entry)))
else:
actions.append(("Install", build_game_install_command(entry)))
return actions
class VoicedMenu: class VoicedMenu:
def __init__(self, title="Stormux Game Menu"): def __init__(self, title="Stormux Game Menu"):
self.title = title self.title = title
@@ -113,6 +138,7 @@ class VoicedMenu:
# Load downloadable games registry # Load downloadable games registry
self.downloadable_games = self.load_downloadable_games() self.downloadable_games = self.load_downloadable_games()
self.savedMenuState = None
def load_downloadable_games(self): def load_downloadable_games(self):
"""Load downloadable games registry from JSON file""" """Load downloadable games registry from JSON file"""
@@ -641,6 +667,49 @@ class VoicedMenu:
self.menuSections[sectionName].append((name, command)) self.menuSections[sectionName].append((name, command))
def save_menu_state(self):
"""Save the current menu state so a temporary submenu can restore it."""
self.savedMenuState = {
"sectionNames": self.sectionNames.copy(),
"currentSection": self.currentSection,
"currentItemIndices": self.currentItemIndices.copy(),
"menuSections": {
sectionName: items.copy()
for sectionName, items in self.menuSections.items()
},
}
def restore_saved_menu(self):
"""Restore the menu state saved before entering a temporary submenu."""
if self.savedMenuState is None:
return
self.sectionNames = self.savedMenuState["sectionNames"]
self.currentSection = self.savedMenuState["currentSection"]
self.currentItemIndices = self.savedMenuState["currentItemIndices"]
self.menuSections = self.savedMenuState["menuSections"]
self.savedMenuState = None
self.draw_menu()
self.announce_current_section()
time.sleep(0.5)
self.announce_current_item(interrupt=False)
def show_action_menu(self, sectionName, actions):
"""Replace the current menu with a temporary action submenu."""
self.save_menu_state()
self.sectionNames = [sectionName]
self.currentSection = 0
self.currentItemIndices = {sectionName: 0}
self.menuSections = {sectionName: actions + [("Back", self.restore_saved_menu)]}
self.draw_menu()
self.announce_current_section()
time.sleep(0.5)
self.announce_current_item(interrupt=False)
def show_game_actions(self, gameEntry):
"""Show available actions for a game."""
self.show_action_menu(gameEntry.name, build_game_actions(gameEntry))
def speak(self, text, interrupt=True): def speak(self, text, interrupt=True):
"""Speak the given text with option to interrupt existing speech""" """Speak the given text with option to interrupt existing speech"""
if self.speechClient is None: if self.speechClient is None:
@@ -1103,8 +1172,11 @@ if __name__ == "__main__":
menu = VoicedMenu(title="Stormux Gaming Menu") menu = VoicedMenu(title="Stormux Gaming Menu")
for gameEntry in discover_game_entries(): for gameEntry in discover_game_entries():
label = gameEntry.name if gameEntry.isInstalled else f"{gameEntry.name} (install)" menu.add_item(
menu.add_item(gameEntry.section, label, build_game_command(gameEntry)) gameEntry.section,
gameEntry.name,
lambda entry=gameEntry: menu.show_game_actions(entry),
)
menu.add_section("Emulators") menu.add_section("Emulators")
menu.add_item("Emulators", "Apple 2e", "/home/stormux/.local/bin/apple_2e.py") menu.add_item("Emulators", "Apple 2e", "/home/stormux/.local/bin/apple_2e.py")