From 73a41e7252aa383ab487bd2af489feb0c72fccb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 04:41:59 +0000 Subject: [PATCH 1/2] wolf: redesign app selection with numbered menu, ES-DE ROM dirs, and manage.sh apps command - Drive picker: numbered list with UUID display, mounted + unmounted drives, explicit system-path exclusions (no regex bare-/ bug), skip whole disks with mounted partitions - Pre-create ES-DE ROM system directories (~50 systems) so ES-DE detects all platforms on first launch - App selection at install time: numbered menu for Steam, ES-DE, Lutris, RetroArch, Prism Launcher, Kodi, Firefox, Desktop - Python catalog-driven TOML injector shared between install and manage.sh; supports update_mounts() to patch paths on re-run without duplicating entries - manage.sh `apps` command replaces `add-apps` / `update-storage`: shows installed apps, interactive picker, updates game storage path from .env - Game storage written to ~/docker/wolf/.env as GAME_STORAGE_DIR Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs --- services/wolf.sh | 668 +++++++++++++++++++++++++++++++---------------- 1 file changed, 448 insertions(+), 220 deletions(-) diff --git a/services/wolf.sh b/services/wolf.sh index 3026c14..7197380 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -694,10 +694,84 @@ UDEV log_success "Game storage: $GAME_STORAGE_DIR" # Create the storage sub-directories - mkdir -p "$GAME_STORAGE_DIR/roms" "$GAME_STORAGE_DIR/steam" \ - "$GAME_STORAGE_DIR/saves" "$GAME_STORAGE_DIR/media" + mkdir -p "$GAME_STORAGE_DIR/steam" "$GAME_STORAGE_DIR/saves" \ + "$GAME_STORAGE_DIR/media" "$GAME_STORAGE_DIR/lutris" \ + "$GAME_STORAGE_DIR/firefox" "$GAME_STORAGE_DIR/minecraft" \ + "$GAME_STORAGE_DIR/kodi" + + # Pre-create ES-DE ROM directories so the user knows where to drop files + # and ES-DE shows the system in its list immediately on first launch. + local _ESDE_SYSTEMS=( + 3do amstradcpc arcade atari2600 atari5200 atari7800 atari800 + atarijaguar atarilynx atarist c64 cavestory channelf coco colecovision + dreamcast dos famicom fds gamegear gb gba gbc gc genesis + gx4000 intellivision j2me lynx mame megadrive megadrive-japan + msx msx2 n64 naomi nds neogeo neogeocd ngp ngpc nes nintendo3ds + odyssey2 pc88 pc98 pcengine pcenginecd pcfx pokemini ps2 ps3 psp psx + saturn scummvm sega32x segacd sg-1000 snes snesmsu1 supervision + switch tg16 tg-cd vectrex vic20 videopac wii wiiu wonderswan + wonderswancolor x68000 xbox xbox360 zmachine zxspectrum + ) + local _sys + for _sys in "${_ESDE_SYSTEMS[@]}"; do + mkdir -p "$GAME_STORAGE_DIR/roms/$_sys" + done + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$GAME_STORAGE_DIR" 2>/dev/null || true - log_success "Storage layout: $GAME_STORAGE_DIR/{roms,steam,saves,media}" + log_success "Storage layout: $GAME_STORAGE_DIR/{roms//,steam,saves,media,...}" + log_info "ES-DE ROM directories pre-created. Drop ROMs in the matching subfolder." + + # ── App selection ───────────────────────────────────────────────────────── + echo "" + echo "═══════════════════════════════════════════════════════" + echo " WOLF APPS" + echo "═══════════════════════════════════════════════════════" + echo "" + echo " Select apps to add to Wolf (shown in Moonlight)." + echo " Containers are pulled on first launch, not now." + echo "" + echo " 1) Steam - Big Picture + Proton (PC games)" + echo " 2) EmulationStation - ES-DE + RetroArch (retro ROMs) [default]" + echo " 3) Lutris - GOG / Epic / Wine / non-Steam" + echo " 4) RetroArch - standalone emulator frontend" + echo " 5) Prism Launcher - Minecraft (Java + Bedrock)" + echo " 6) Kodi - media center" + echo " 7) Firefox - browser" + echo " 8) Desktop - full XFCE desktop session" + echo "" + echo " Enter numbers separated by spaces, or 'all', or Enter for [1 2]:" + echo "" + + local _APP_PICKS="" _SELECTED_APPS=() + if [ "$UNATTENDED" = true ]; then + _SELECTED_APPS=(1 2) + log_info "Unattended — selecting Steam + EmulationStation" + else + read -r -p " Apps [1 2]: " _APP_PICKS + _APP_PICKS="${_APP_PICKS:-1 2}" + if [[ "${_APP_PICKS,,}" == "all" ]]; then + _SELECTED_APPS=(1 2 3 4 5 6 7 8) + else + read -ra _SELECTED_APPS <<< "$_APP_PICKS" + fi + fi + + # Map numbers to app keys for the Python injector + local _APP_KEYS="" + for _n in "${_SELECTED_APPS[@]}"; do + case "$_n" in + 1) _APP_KEYS="$_APP_KEYS steam" ;; + 2) _APP_KEYS="$_APP_KEYS esde" ;; + 3) _APP_KEYS="$_APP_KEYS lutris" ;; + 4) _APP_KEYS="$_APP_KEYS retroarch" ;; + 5) _APP_KEYS="$_APP_KEYS prismlauncher" ;; + 6) _APP_KEYS="$_APP_KEYS kodi" ;; + 7) _APP_KEYS="$_APP_KEYS firefox" ;; + 8) _APP_KEYS="$_APP_KEYS desktop" ;; + esac + done + _APP_KEYS="${_APP_KEYS# }" # trim leading space + log_info "Will add: ${_APP_KEYS:-none}" # ── docker-compose.yml ──────────────────────────────────────────────────── log_info "Generating docker-compose.yml..." @@ -803,45 +877,208 @@ case "$1" in docker compose pull docker compose up -d ;; - add-apps|update-storage) + apps|add-apps|update-storage) WOLF_CFG=/etc/wolf/cfg/config.toml - GAME_DIR="${2}" - - # update-storage: read path from .env if not given on command line - if [ "$1" = "update-storage" ] && [ -z "$GAME_DIR" ]; then - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - ENV_FILE="$SCRIPT_DIR/.env" - if [ -f "$ENV_FILE" ]; then - GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$ENV_FILE" | cut -d= -f2-) - fi - fi + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + # Resolve game storage dir: explicit arg > .env > prompt + GAME_DIR="${2:-}" if [ -z "$GAME_DIR" ]; then - echo "Usage: ./manage.sh add-apps " - echo " ./manage.sh update-storage []" - echo "" - echo " root of game storage, e.g. /home/user/drives/games" - echo " update-storage with no path reads GAME_STORAGE_DIR from .env" - exit 1 + GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-) fi + if [ -z "$GAME_DIR" ]; then + read -r -p " Game storage path: " GAME_DIR + fi + if [ -z "$GAME_DIR" ]; then echo "No game storage path."; exit 1; fi + + # Persist path back to .env if it changed + if grep -qs '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null; then + sed -i "s|^GAME_STORAGE_DIR=.*|GAME_STORAGE_DIR=${GAME_DIR}|" "$SCRIPT_DIR/.env" + else + echo "GAME_STORAGE_DIR=${GAME_DIR}" >> "$SCRIPT_DIR/.env" + fi + if [ ! -f "$WOLF_CFG" ]; then echo "Wolf config not found at $WOLF_CFG — is Wolf running?" exit 1 fi - # Persist new path in .env when update-storage is called with explicit path - if [ "$1" = "update-storage" ] && [ -n "${2:-}" ]; then - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - sed -i "s|^GAME_STORAGE_DIR=.*|GAME_STORAGE_DIR=${GAME_DIR}|" "$SCRIPT_DIR/.env" 2>/dev/null \ - || echo "GAME_STORAGE_DIR=${GAME_DIR}" >> "$SCRIPT_DIR/.env" - echo "Updated .env: GAME_STORAGE_DIR=${GAME_DIR}" + # Show which apps are already installed and what's available + echo "" + echo " Checking current Wolf config..." + INSTALLED=$(grep "^ name = 'Wolf" "$WOLF_CFG" 2>/dev/null | sed "s/.*name = '//;s/'//" | tr '\n' ' ') + [ -n "$INSTALLED" ] && echo " Already installed: $INSTALLED" || echo " No Wolf apps installed yet." + echo "" + echo " Available apps:" + echo " 1) Steam - Big Picture + Proton (PC games)" + echo " 2) EmulationStation - ES-DE + RetroArch (retro ROMs)" + echo " 3) Lutris - GOG / Epic / Wine / non-Steam" + echo " 4) RetroArch - standalone emulator frontend" + echo " 5) Prism Launcher - Minecraft (Java + Bedrock)" + echo " 6) Kodi - media center" + echo " 7) Firefox - browser" + echo " 8) Desktop - full XFCE desktop session" + echo "" + echo " Enter numbers (space-separated), 'all', or Enter to update existing mounts only:" + read -r -p " Apps [Enter=update mounts only]: " _PICKS + if [[ "${_PICKS,,}" == "all" ]]; then + APP_KEYS="steam esde lutris retroarch prismlauncher kodi firefox desktop" + elif [ -z "$_PICKS" ]; then + # No new apps — just re-run with whatever keys are already installed + APP_KEYS="" + [[ "$INSTALLED" == *WolfSteam* ]] && APP_KEYS="$APP_KEYS steam" + [[ "$INSTALLED" == *WolfES-DE* ]] && APP_KEYS="$APP_KEYS esde" + [[ "$INSTALLED" == *WolfLutris* ]] && APP_KEYS="$APP_KEYS lutris" + [[ "$INSTALLED" == *WolfRetroArch* ]] && APP_KEYS="$APP_KEYS retroarch" + [[ "$INSTALLED" == *WolfPrismLauncher* ]] && APP_KEYS="$APP_KEYS prismlauncher" + [[ "$INSTALLED" == *WolfKodi* ]] && APP_KEYS="$APP_KEYS kodi" + [[ "$INSTALLED" == *WolfFirefox* ]] && APP_KEYS="$APP_KEYS firefox" + [[ "$INSTALLED" == *WolfDesktop* ]] && APP_KEYS="$APP_KEYS desktop" + APP_KEYS="${APP_KEYS# }" + [ -z "$APP_KEYS" ] && APP_KEYS="steam esde" + else + APP_KEYS="" + for _n in $_PICKS; do + case "$_n" in + 1) APP_KEYS="$APP_KEYS steam" ;; + 2) APP_KEYS="$APP_KEYS esde" ;; + 3) APP_KEYS="$APP_KEYS lutris" ;; + 4) APP_KEYS="$APP_KEYS retroarch" ;; + 5) APP_KEYS="$APP_KEYS prismlauncher" ;; + 6) APP_KEYS="$APP_KEYS kodi" ;; + 7) APP_KEYS="$APP_KEYS firefox" ;; + 8) APP_KEYS="$APP_KEYS desktop" ;; + esac + done + APP_KEYS="${APP_KEYS# }" fi - python3 - "$GAME_DIR" "$WOLF_CFG" << 'PYEOF' -import sys + echo " Applying: ${APP_KEYS:-none}" + [ -z "$APP_KEYS" ] && exit 0 -games = sys.argv[1].rstrip('/') -cfg = sys.argv[2] + python3 - "$GAME_DIR" "$WOLF_CFG" $APP_KEYS << 'PYEOF' +import sys, json + +games = sys.argv[1].rstrip('/') +cfg = sys.argv[2] +selected = set(sys.argv[3:]) + +STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN'] +STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'] +STD_RULES = ['c 13:* rmw', 'c 244:* rmw'] + +CATALOG = { + 'steam': dict( + name='WolfSteam', title='Steam', + icon='https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png', + image='ghcr.io/games-on-whales/steam:edge', + mounts=[f'{games}/steam:/home/retro/.steam:rw'], + env=['PROTON_LOG=1', 'RUN_SWAY=true', + 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'], + cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'], + security_opt=['seccomp=unconfined', 'apparmor=unconfined'], + ipc_mode='host', ulimits=[{'Name': 'nofile', 'Hard': 10240, 'Soft': 10240}], + privileged=False, + ), + 'esde': dict( + name='WolfES-DE', title='EmulationStation', + icon='https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png', + image='ghcr.io/games-on-whales/es-de:edge', + mounts=[f'{games}/roms:/ROMs:rw', + f'{games}/saves:/home/retro/.config/retroarch/saves:rw', + f'{games}/media:/media:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'lutris': dict( + name='WolfLutris', title='Lutris', + icon='https://games-on-whales.github.io/wildlife/apps/lutris/assets/icon.png', + image='ghcr.io/games-on-whales/lutris:edge', + mounts=[f'{games}/lutris:/home/retro/.local/share/lutris:rw'], + env=['RUN_SWAY=true', + 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'], + cap_add=['SYS_ADMIN', 'NET_RAW', 'MKNOD', 'NET_ADMIN'], + security_opt=['seccomp=unconfined', 'apparmor=unconfined'], + ipc_mode='host', ulimits=[], privileged=False, + ), + 'retroarch': dict( + name='WolfRetroArch', title='RetroArch', + icon='https://games-on-whales.github.io/wildlife/apps/retroarch/assets/icon.png', + image='ghcr.io/games-on-whales/retroarch:edge', + mounts=[f'{games}/roms:/ROMs:rw', + f'{games}/saves:/home/retro/.config/retroarch/saves:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'prismlauncher': dict( + name='WolfPrismLauncher', title='Prism Launcher', + icon='https://games-on-whales.github.io/wildlife/apps/prismlauncher/assets/icon.png', + image='ghcr.io/games-on-whales/prismlauncher:edge', + mounts=[f'{games}/minecraft:/home/retro/.local/share/PrismLauncher:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'kodi': dict( + name='WolfKodi', title='Kodi', + icon='https://games-on-whales.github.io/wildlife/apps/kodi/assets/icon.png', + image='ghcr.io/games-on-whales/kodi:edge', + mounts=[f'{games}/kodi:/home/retro/.kodi:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'firefox': dict( + name='WolfFirefox', title='Firefox', + icon='https://games-on-whales.github.io/wildlife/apps/firefox/assets/icon.png', + image='ghcr.io/games-on-whales/firefox:edge', + mounts=[f'{games}/firefox:/home/retro/.mozilla:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'desktop': dict( + name='WolfDesktop', title='Desktop', + icon='https://games-on-whales.github.io/wildlife/apps/desktop/assets/icon.png', + image='ghcr.io/games-on-whales/desktop:edge', + mounts=[], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), +} + +def make_app_block(app): + host_cfg = {'IpcMode': app['ipc_mode'], 'CapAdd': app['cap_add'], + 'Privileged': app['privileged'], 'DeviceCgroupRules': STD_RULES} + if app['security_opt']: host_cfg['SecurityOpt'] = app['security_opt'] + if app['ulimits']: host_cfg['Ulimits'] = app['ulimits'] + create_json = json.dumps({'HostConfig': host_cfg}, indent=2) + mounts_str = str(app['mounts']).replace('"', "'") + env_str = str(app['env']).replace('"', "'") + return ( + f"\n" + f" [[profiles.apps]]\n" + f" icon_png_path = '{app['icon']}'\n" + f" start_virtual_compositor = true\n" + f" title = '{app['title']}'\n" + f"\n" + f" [profiles.apps.runner]\n" + f" base_create_json = '''{create_json}\n" + f"'''\n" + f" devices = []\n" + f" env = {env_str}\n" + f" image = '{app['image']}'\n" + f" mounts = {mounts_str}\n" + f" name = '{app['name']}'\n" + f" ports = []\n" + f" type = 'docker'\n" + ) + +def update_mounts(lines, wolf_name, new_mounts): + for i, line in enumerate(lines): + if f"name = '{wolf_name}'" in line: + for j in range(max(0, i-10), min(len(lines), i+15)): + if lines[j].strip().startswith('mounts ='): + lines[j] = f" mounts = {new_mounts}\n" + return True + return False with open(cfg, 'r') as f: lines = f.readlines() @@ -856,105 +1093,41 @@ for i, line in enumerate(lines): if first_start is None: print('ERROR: no [[profiles]] section found'); sys.exit(1) -def update_mounts(lines, app_name, new_mounts_line): - for i, line in enumerate(lines): - if f"name = '{app_name}'" in line: - # scan forward for the mounts line within this app block - for j in range(i - 10, i + 10): - if 0 <= j < len(lines) and lines[j].strip().startswith('mounts ='): - lines[j] = new_mounts_line - return True - return False - first_block = lines[first_start:insert_at] -has_steam = any("name = 'WolfSteam'" in l for l in first_block) -has_esde = any("name = 'WolfES-DE'" in l for l in first_block) +added, updated, to_insert = [], [], [] -updated = [] -if has_steam: - new_line = f" mounts = [ '{games}/steam:/home/retro/.steam:rw' ]\n" - if update_mounts(lines, 'WolfSteam', new_line): - updated.append('Steam mounts updated') -if has_esde: - new_line = f" mounts = [ '{games}/roms:/ROMs:rw', '{games}/saves:/home/retro/.config/retroarch/saves:rw', '{games}/media:/media:rw' ]\n" - if update_mounts(lines, 'WolfES-DE', new_line): - updated.append('ES-DE mounts updated') - -first_block = lines[first_start:insert_at] -has_steam = any("name = 'WolfSteam'" in l for l in first_block) -has_esde = any("name = 'WolfES-DE'" in l for l in first_block) - -to_insert = [] -if not has_steam: - to_insert += [ - '\n', - " [[profiles.apps]]\n", - " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n", - " start_virtual_compositor = true\n", - " title = 'Steam'\n", - '\n', - " [profiles.apps.runner]\n", - " base_create_json = '''{\n", - ' "HostConfig": {\n', - ' "IpcMode": "host",\n', - ' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n', - ' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n', - ' "Ulimits": [{"Name":"nofile", "Hard":10240, "Soft":10240}],\n', - ' "Privileged": false,\n', - ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', - ' }\n', - "}\n", - "'''\n", - " devices = []\n", - " env = [ 'PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", - " image = 'ghcr.io/games-on-whales/steam:edge'\n", - " mounts = [ '" + games + "/steam:/home/retro/.steam:rw' ]\n", - " name = 'WolfSteam'\n", - " ports = []\n", - " type = 'docker'\n", - ] -if not has_esde: - to_insert += [ - '\n', - " [[profiles.apps]]\n", - " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png'\n", - " start_virtual_compositor = true\n", - " title = 'EmulationStation'\n", - '\n', - " [profiles.apps.runner]\n", - " base_create_json = '''{\n", - ' "HostConfig": {\n', - ' "IpcMode": "host",\n', - ' "Privileged": false,\n', - ' "CapAdd": ["NET_RAW", "MKNOD", "NET_ADMIN"],\n', - ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', - ' }\n', - "}\n", - "'''\n", - " devices = []\n", - " env = [ 'RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", - " image = 'ghcr.io/games-on-whales/es-de:edge'\n", - " mounts = [ '" + games + "/roms:/ROMs:rw', '" + games + "/saves:/home/retro/.config/retroarch/saves:rw', '" + games + "/media:/media:rw' ]\n", - " name = 'WolfES-DE'\n", - " ports = []\n", - " type = 'docker'\n", - ] +for key in list(CATALOG.keys()): + if key not in selected: continue + app = CATALOG[key] + wolf_name = app['name'] + new_mounts = str(app['mounts']).replace('"', "'") + already = any(f"name = '{wolf_name}'" in l for l in first_block) + if already: + if update_mounts(lines, wolf_name, new_mounts): + updated.append(app['title']) + else: + to_insert.append(make_app_block(app)) + added.append(app['title']) if to_insert: - new_lines = lines[:insert_at] + to_insert + lines[insert_at:] + block_lines = [] + for block in to_insert: + block_lines += [l + '\n' if not l.endswith('\n') else l + for l in block.splitlines()] + new_lines = lines[:insert_at] + block_lines + lines[insert_at:] with open(cfg, 'w') as f: f.writelines(new_lines) - added = [n for n, exists in [('Steam', has_steam), ('EmulationStation', has_esde)] if not exists] - print(f"Added to default profile: {', '.join(added)}") elif updated: with open(cfg, 'w') as f: f.writelines(lines) - print('; '.join(updated)) -else: - print('Both apps already in profile (paths unchanged)') + +if added: print(f"Added: {', '.join(added)}") +if updated: print(f"Updated mounts: {', '.join(updated)}") +if not added and not updated: + print('All selected apps already present and up to date') PYEOF docker compose restart wolf - echo "Wolf restarted. Steam and EmulationStation should now appear in Moonlight." + echo "Wolf restarted. Apps will appear in Moonlight on next connection." ;; backup) echo "Set up backups with the modular system: sudo ./setup.sh backup" @@ -997,9 +1170,7 @@ PYEOF echo " ./manage.sh status - Show Wolf + app containers" echo " ./manage.sh pin - Show recent Moonlight pairing PIN link" echo " ./manage.sh update - Pull latest Wolf image and restart" - echo " ./manage.sh add-apps - Add Steam + ES-DE to Wolf config" - echo " ./manage.sh update-storage [] - Update game storage path in Wolf config" - echo " (reads .env if no path given)" + echo " ./manage.sh apps - Add / update game launchers in Wolf" echo " ./manage.sh backup - How to set up backups" ;; esac @@ -1021,19 +1192,139 @@ MEOF done if [ -f "$WOLF_CFG" ]; then - log_info "Adding Steam and EmulationStation to Wolf config..." - python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" << 'PYEOF' -import sys, re + log_info "Injecting selected apps into Wolf config..." + python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" $([[ -n "$_APP_KEYS" ]] && echo "$_APP_KEYS" || echo "steam esde") << 'PYEOF' +import sys, json -games = sys.argv[1].rstrip('/') -cfg = sys.argv[2] +games = sys.argv[1].rstrip('/') +cfg = sys.argv[2] +selected = set(sys.argv[3:]) # app keys chosen by the user + +# ── App catalog ─────────────────────────────────────────────────────────────── +# Each entry: (wolf_name, title, icon_url, image, mounts, env, cap_add, +# security_opt, ipc_mode, ulimits, privileged) +STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN'] +STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'] +STD_RULES = ['c 13:* rmw', 'c 244:* rmw'] + +CATALOG = { + 'steam': dict( + name='WolfSteam', title='Steam', + icon='https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png', + image='ghcr.io/games-on-whales/steam:edge', + mounts=[f'{games}/steam:/home/retro/.steam:rw'], + env=['PROTON_LOG=1', 'RUN_SWAY=true', + 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'], + cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'], + security_opt=['seccomp=unconfined', 'apparmor=unconfined'], + ipc_mode='host', + ulimits=[{'Name': 'nofile', 'Hard': 10240, 'Soft': 10240}], + privileged=False, + ), + 'esde': dict( + name='WolfES-DE', title='EmulationStation', + icon='https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png', + image='ghcr.io/games-on-whales/es-de:edge', + mounts=[f'{games}/roms:/ROMs:rw', + f'{games}/saves:/home/retro/.config/retroarch/saves:rw', + f'{games}/media:/media:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'lutris': dict( + name='WolfLutris', title='Lutris', + icon='https://games-on-whales.github.io/wildlife/apps/lutris/assets/icon.png', + image='ghcr.io/games-on-whales/lutris:edge', + mounts=[f'{games}/lutris:/home/retro/.local/share/lutris:rw'], + env=['RUN_SWAY=true', + 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'], + cap_add=['SYS_ADMIN', 'NET_RAW', 'MKNOD', 'NET_ADMIN'], + security_opt=['seccomp=unconfined', 'apparmor=unconfined'], + ipc_mode='host', ulimits=[], privileged=False, + ), + 'retroarch': dict( + name='WolfRetroArch', title='RetroArch', + icon='https://games-on-whales.github.io/wildlife/apps/retroarch/assets/icon.png', + image='ghcr.io/games-on-whales/retroarch:edge', + mounts=[f'{games}/roms:/ROMs:rw', + f'{games}/saves:/home/retro/.config/retroarch/saves:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'prismlauncher': dict( + name='WolfPrismLauncher', title='Prism Launcher', + icon='https://games-on-whales.github.io/wildlife/apps/prismlauncher/assets/icon.png', + image='ghcr.io/games-on-whales/prismlauncher:edge', + mounts=[f'{games}/minecraft:/home/retro/.local/share/PrismLauncher:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'kodi': dict( + name='WolfKodi', title='Kodi', + icon='https://games-on-whales.github.io/wildlife/apps/kodi/assets/icon.png', + image='ghcr.io/games-on-whales/kodi:edge', + mounts=[f'{games}/kodi:/home/retro/.kodi:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'firefox': dict( + name='WolfFirefox', title='Firefox', + icon='https://games-on-whales.github.io/wildlife/apps/firefox/assets/icon.png', + image='ghcr.io/games-on-whales/firefox:edge', + mounts=[f'{games}/firefox:/home/retro/.mozilla:rw'], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), + 'desktop': dict( + name='WolfDesktop', title='Desktop', + icon='https://games-on-whales.github.io/wildlife/apps/desktop/assets/icon.png', + image='ghcr.io/games-on-whales/desktop:edge', + mounts=[], + env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', + ulimits=[], privileged=False, + ), +} + +def make_app_block(app): + """Render a [[profiles.apps]] TOML block from a catalog entry.""" + host_cfg = {'IpcMode': app['ipc_mode'], 'CapAdd': app['cap_add'], + 'Privileged': app['privileged'], 'DeviceCgroupRules': STD_RULES} + if app['security_opt']: host_cfg['SecurityOpt'] = app['security_opt'] + if app['ulimits']: host_cfg['Ulimits'] = app['ulimits'] + create_json = json.dumps({'HostConfig': host_cfg}, indent=2) + mounts_str = str(app['mounts']).replace('"', "'") + env_str = str(app['env']).replace('"', "'") + return ( + f"\n" + f" [[profiles.apps]]\n" + f" icon_png_path = '{app['icon']}'\n" + f" start_virtual_compositor = true\n" + f" title = '{app['title']}'\n" + f"\n" + f" [profiles.apps.runner]\n" + f" base_create_json = '''{create_json}\n" + f"'''\n" + f" devices = []\n" + f" env = {env_str}\n" + f" image = '{app['image']}'\n" + f" mounts = {mounts_str}\n" + f" name = '{app['name']}'\n" + f" ports = []\n" + f" type = 'docker'\n" + ) + +def update_mounts(lines, wolf_name, new_mounts): + for i, line in enumerate(lines): + if f"name = '{wolf_name}'" in line: + for j in range(max(0, i-10), min(len(lines), i+15)): + if lines[j].strip().startswith('mounts ='): + lines[j] = f" mounts = {new_mounts}\n" + return True + return False with open(cfg, 'r') as f: lines = f.readlines() -# Wolf uses [[profiles]] / [[profiles.apps]] format. -# Apps must be inserted into the FIRST profile (the default paired-client -# profile) before the second [[profiles]] section starts. profiles_seen, first_start, insert_at = 0, None, len(lines) for i, line in enumerate(lines): if line.strip() == '[[profiles]]': @@ -1045,109 +1336,46 @@ if first_start is None: print('[ERROR] No [[profiles]] section found in config'); sys.exit(1) first_block = lines[first_start:insert_at] -has_steam = any("name = 'WolfSteam'" in l for l in first_block) -has_esde = any("name = 'WolfES-DE'" in l for l in first_block) - -# If an app already exists, update its mounts line in place rather than skip. -def update_mounts(lines, app_name, new_mounts_line): - in_app = False - for i, line in enumerate(lines): - if f"name = '{app_name}'" in line: - in_app = True - if in_app and line.strip().startswith('mounts ='): - lines[i] = new_mounts_line - return True - return False - -updated = [] -if has_steam: - new_line = f" mounts = [ '{games}/steam:/home/retro/.steam:rw' ]\n" - if update_mounts(lines, 'WolfSteam', new_line): - updated.append('Steam mounts updated') -if has_esde: - new_line = f" mounts = [ '{games}/roms:/ROMs:rw', '{games}/saves:/home/retro/.config/retroarch/saves:rw', '{games}/media:/media:rw' ]\n" - if update_mounts(lines, 'WolfES-DE', new_line): - updated.append('ES-DE mounts updated') - -# Re-derive first_block after in-place updates -first_block = lines[first_start:insert_at] -has_steam = any("name = 'WolfSteam'" in l for l in first_block) -has_esde = any("name = 'WolfES-DE'" in l for l in first_block) +added, updated = [], [] to_insert = [] -if not has_steam: - to_insert += [ - '\n', - " [[profiles.apps]]\n", - " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n", - " start_virtual_compositor = true\n", - " title = 'Steam'\n", - '\n', - " [profiles.apps.runner]\n", - " base_create_json = '''{\n", - ' "HostConfig": {\n', - ' "IpcMode": "host",\n', - ' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n', - ' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n', - ' "Ulimits": [{"Name":"nofile", "Hard":10240, "Soft":10240}],\n', - ' "Privileged": false,\n', - ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', - ' }\n', - "}\n", - "'''\n", - " devices = []\n", - " env = [ 'PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", - " image = 'ghcr.io/games-on-whales/steam:edge'\n", - f" mounts = [ '{games}/steam:/home/retro/.steam:rw' ]\n", - " name = 'WolfSteam'\n", - " ports = []\n", - " type = 'docker'\n", - ] -if not has_esde: - to_insert += [ - '\n', - " [[profiles.apps]]\n", - " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png'\n", - " start_virtual_compositor = true\n", - " title = 'EmulationStation'\n", - '\n', - " [profiles.apps.runner]\n", - " base_create_json = '''{\n", - ' "HostConfig": {\n', - ' "IpcMode": "host",\n', - ' "Privileged": false,\n', - ' "CapAdd": ["NET_RAW", "MKNOD", "NET_ADMIN"],\n', - ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', - ' }\n', - "}\n", - "'''\n", - " devices = []\n", - " env = [ 'RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", - " image = 'ghcr.io/games-on-whales/es-de:edge'\n", - f" mounts = [ '{games}/roms:/ROMs:rw', '{games}/saves:/home/retro/.config/retroarch/saves:rw', '{games}/media:/media:rw' ]\n", - " name = 'WolfES-DE'\n", - " ports = []\n", - " type = 'docker'\n", - ] + +for key in list(CATALOG.keys()): # preserve display order + if key not in selected: + continue + app = CATALOG[key] + wolf_name = app['name'] + already = any(f"name = '{wolf_name}'" in l for l in first_block) + new_mounts = str(app['mounts']).replace('"', "'") + if already: + if update_mounts(lines, wolf_name, new_mounts): + updated.append(app['title']) + else: + to_insert.append(make_app_block(app)) + added.append(app['title']) if to_insert: - new_lines = lines[:insert_at] + to_insert + lines[insert_at:] + block_lines = [] + for block in to_insert: + block_lines += [l + '\n' if not l.endswith('\n') else l + for l in block.splitlines()] + new_lines = lines[:insert_at] + block_lines + lines[insert_at:] with open(cfg, 'w') as f: f.writelines(new_lines) - added = [n for n, exists in [('Steam', has_steam), ('EmulationStation', has_esde)] if not exists] - print(f"[INFO] Added to default profile: {', '.join(added)}") elif updated: with open(cfg, 'w') as f: f.writelines(lines) - print(f"[INFO] {'; '.join(updated)}") -else: - print('[INFO] Steam and EmulationStation already in default profile (paths unchanged)') + +if added: print(f"[INFO] Added: {', '.join(added)}") +if updated: print(f"[INFO] Updated mounts: {', '.join(updated)}") +if not added and not updated: + print('[INFO] All selected apps already present and up to date') PYEOF docker compose restart wolf log_success "Wolf restarted with updated config" else log_warning "Wolf config not generated in time. Add apps manually to /etc/wolf/cfg/config.toml" - log_warning "Then run: ./manage.sh restart" + log_warning "Then run: ./manage.sh apps" fi # Hand the folder back to the real user @@ -1238,7 +1466,7 @@ PYEOF echo " Online together → each player launches their own session," echo " all connect to the same game server" echo "" - echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|pin|update|add-apps}" + echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|pin|update|apps}" echo "" echo "── BACKUPS ───────────────────────────────────────────" echo "" @@ -1284,7 +1512,7 @@ cd $WOLF_DIR ./manage.sh logs # live logs ./manage.sh status # container status ./manage.sh update # pull latest image and restart -./manage.sh add-apps # add game launchers +./manage.sh apps # add / update game launchers \`\`\` ## Ports (open on firewall / router) From 54082ad8b044f107a10dd06957accb2bc0143948 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 04:46:48 +0000 Subject: [PATCH 2/2] wolf: fix Steam storage mount, add Azahar 3DS emulator, emulators/ dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Steam mount was steam:/home/retro/.steam — games download to /home/retro/.local/share/Steam/steamapps/ which was NOT mounted and lived in the ephemeral container layer. Fixed to steam:/home/retro so the full home dir (library, user data, Proton prefixes) persists. - Add emulators/ subdir to game storage layout; mounted into ES-DE container at /home/retro/Applications (ES-DE's default AppImage search path) - Add optional Azahar (open-source Citra fork) AppImage download at install time — fetches latest release from GitHub, chmod +x, places in emulators/ where ES-DE finds it automatically for nintendo3ds ROMs - Update README to document new storage layout and 3DS emulation setup Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs --- services/wolf.sh | 60 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/services/wolf.sh b/services/wolf.sh index 7197380..b051bb0 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -697,7 +697,7 @@ UDEV mkdir -p "$GAME_STORAGE_DIR/steam" "$GAME_STORAGE_DIR/saves" \ "$GAME_STORAGE_DIR/media" "$GAME_STORAGE_DIR/lutris" \ "$GAME_STORAGE_DIR/firefox" "$GAME_STORAGE_DIR/minecraft" \ - "$GAME_STORAGE_DIR/kodi" + "$GAME_STORAGE_DIR/kodi" "$GAME_STORAGE_DIR/emulators" # Pre-create ES-DE ROM directories so the user knows where to drop files # and ES-DE shows the system in its list immediately on first launch. @@ -718,9 +718,41 @@ UDEV done chown -R "$ACTUAL_USER:$ACTUAL_USER" "$GAME_STORAGE_DIR" 2>/dev/null || true - log_success "Storage layout: $GAME_STORAGE_DIR/{roms//,steam,saves,media,...}" + log_success "Storage layout: $GAME_STORAGE_DIR/{roms//,steam,saves,media,emulators,...}" log_info "ES-DE ROM directories pre-created. Drop ROMs in the matching subfolder." + # ── Optional: download Azahar (3DS emulator) ───────────────────────────── + # Azahar is an open-source Citra fork — the recommended legal 3DS emulator + # for ES-DE. AppImages dropped in emulators/ are found automatically by + # ES-DE's app finder (checks ~/Applications inside the container). + local _AZAHAR_DIR="$GAME_STORAGE_DIR/emulators" + if ! ls "$_AZAHAR_DIR"/azahar*.AppImage 2>/dev/null | grep -q .; then + local _get_az="" + echo "" + log_info "3DS emulation: Azahar (open-source Citra fork) can be auto-downloaded." + prompt_yn "Download Azahar 3DS emulator AppImage now? (y/n):" "y" _get_az + if [[ "$_get_az" =~ ^[Yy]$ ]]; then + log_info "Fetching latest Azahar release from GitHub..." + local _AZ_URL + _AZ_URL=$(curl -fsSL "https://api.github.com/repos/azahar-emu/azahar/releases/latest" \ + | python3 -c "import sys,json; r=json.load(sys.stdin); \ + print(next((a['browser_download_url'] for a in r['assets'] \ + if a['name'].endswith('.AppImage')), ''))" 2>/dev/null) + if [[ -n "$_AZ_URL" ]]; then + local _AZ_FILE="$_AZAHAR_DIR/$(basename "$_AZ_URL")" + curl -fL --progress-bar -o "$_AZ_FILE" "$_AZ_URL" \ + && chmod +x "$_AZ_FILE" \ + && chown "$ACTUAL_USER:$ACTUAL_USER" "$_AZ_FILE" \ + && log_success "Azahar downloaded: $_AZ_FILE" \ + || log_warning "Download failed — get it manually from https://github.com/azahar-emu/azahar/releases" + else + log_warning "Could not resolve download URL — get it manually from https://github.com/azahar-emu/azahar/releases" + fi + fi + else + log_info "Azahar already present in $GAME_STORAGE_DIR/emulators/" + fi + # ── App selection ───────────────────────────────────────────────────────── echo "" echo "═══════════════════════════════════════════════════════" @@ -972,7 +1004,7 @@ CATALOG = { name='WolfSteam', title='Steam', icon='https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png', image='ghcr.io/games-on-whales/steam:edge', - mounts=[f'{games}/steam:/home/retro/.steam:rw'], + mounts=[f'{games}/steam:/home/retro:rw'], env=['PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'], cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'], @@ -986,7 +1018,8 @@ CATALOG = { image='ghcr.io/games-on-whales/es-de:edge', mounts=[f'{games}/roms:/ROMs:rw', f'{games}/saves:/home/retro/.config/retroarch/saves:rw', - f'{games}/media:/media:rw'], + f'{games}/media:/media:rw', + f'{games}/emulators:/home/retro/Applications:rw'], env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', ulimits=[], privileged=False, ), @@ -1212,7 +1245,7 @@ CATALOG = { name='WolfSteam', title='Steam', icon='https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png', image='ghcr.io/games-on-whales/steam:edge', - mounts=[f'{games}/steam:/home/retro/.steam:rw'], + mounts=[f'{games}/steam:/home/retro:rw'], env=['PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'], cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'], @@ -1227,7 +1260,8 @@ CATALOG = { image='ghcr.io/games-on-whales/es-de:edge', mounts=[f'{games}/roms:/ROMs:rw', f'{games}/saves:/home/retro/.config/retroarch/saves:rw', - f'{games}/media:/media:rw'], + f'{games}/media:/media:rw', + f'{games}/emulators:/home/retro/Applications:rw'], env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host', ulimits=[], privileged=False, ), @@ -1523,9 +1557,17 @@ cd $WOLF_DIR | 47998–48000 | UDP | RTP video/audio/control | ## Game storage: \`$GAME_STORAGE_DIR\` -- \`roms/\` → /ROMs (EmulationStation) -- \`steam/\` → Steam data -- \`saves/\` → RetroArch saves +- \`roms//\` → /ROMs (EmulationStation — drop ROMs here) +- \`steam/\` → Steam home (library + user data + Proton prefixes) +- \`saves/\` → RetroArch saves & states +- \`emulators/\` → AppImages (Azahar 3DS, etc.) — ES-DE finds them automatically + +## 3DS emulation +Azahar (open-source Citra fork) — download AppImage to \`emulators/\`: +\`\`\`bash +# Or re-run manage.sh apps to trigger the download prompt +cd $WOLF_DIR && ./manage.sh apps +\`\`\` ## Backup \`\`\`bash