diff --git a/home/stormux/.local/.functions/download.sh b/home/stormux/.local/.functions/download.sh new file mode 100755 index 0000000..75dd19a --- /dev/null +++ b/home/stormux/.local/.functions/download.sh @@ -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}" +} diff --git a/home/stormux/.local/.functions/game_install_functions.sh b/home/stormux/.local/.functions/game_install_functions.sh new file mode 100755 index 0000000..974eb85 --- /dev/null +++ b/home/stormux/.local/.functions/game_install_functions.sh @@ -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 +} diff --git a/home/stormux/.local/.functions/install_game.sh b/home/stormux/.local/.functions/install_game.sh new file mode 100755 index 0000000..d83b21f --- /dev/null +++ b/home/stormux/.local/.functions/install_game.sh @@ -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" diff --git a/home/stormux/.local/bin/game_launcher.py b/home/stormux/.local/bin/game_launcher.py index 8e16c98..c606863 100755 --- a/home/stormux/.local/bin/game_launcher.py +++ b/home/stormux/.local/bin/game_launcher.py @@ -22,6 +22,9 @@ from dataclasses import dataclass from pathlib import Path +INSTALL_GAME_RUNNER = Path("/home/stormux/.local/.functions/install_game.sh") + + @dataclass(frozen=True) class GameEntry: section: str @@ -69,6 +72,28 @@ def build_game_command(entry): return f"STORMUX_LAUNCH_SCRIPT={launchPath!r} startx" 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: def __init__(self, title="Stormux Game Menu"): self.title = title @@ -113,6 +138,7 @@ class VoicedMenu: # Load downloadable games registry self.downloadable_games = self.load_downloadable_games() + self.savedMenuState = None def load_downloadable_games(self): """Load downloadable games registry from JSON file""" @@ -641,6 +667,49 @@ class VoicedMenu: 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): """Speak the given text with option to interrupt existing speech""" if self.speechClient is None: @@ -1103,8 +1172,11 @@ if __name__ == "__main__": menu = VoicedMenu(title="Stormux Gaming Menu") for gameEntry in discover_game_entries(): - label = gameEntry.name if gameEntry.isInstalled else f"{gameEntry.name} (install)" - menu.add_item(gameEntry.section, label, build_game_command(gameEntry)) + menu.add_item( + gameEntry.section, + gameEntry.name, + lambda entry=gameEntry: menu.show_game_actions(entry), + ) menu.add_section("Emulators") menu.add_item("Emulators", "Apple 2e", "/home/stormux/.local/bin/apple_2e.py")