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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
This commit is contained in:
Claude
2026-06-20 04:41:59 +00:00
parent fa906186c3
commit 73a41e7252
+443 -215
View File
@@ -694,10 +694,84 @@ UDEV
log_success "Game storage: $GAME_STORAGE_DIR" log_success "Game storage: $GAME_STORAGE_DIR"
# Create the storage sub-directories # Create the storage sub-directories
mkdir -p "$GAME_STORAGE_DIR/roms" "$GAME_STORAGE_DIR/steam" \ mkdir -p "$GAME_STORAGE_DIR/steam" "$GAME_STORAGE_DIR/saves" \
"$GAME_STORAGE_DIR/saves" "$GAME_STORAGE_DIR/media" "$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 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/<system>/,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 ──────────────────────────────────────────────────── # ── docker-compose.yml ────────────────────────────────────────────────────
log_info "Generating docker-compose.yml..." log_info "Generating docker-compose.yml..."
@@ -803,45 +877,208 @@ case "$1" in
docker compose pull docker compose pull
docker compose up -d docker compose up -d
;; ;;
add-apps|update-storage) apps|add-apps|update-storage)
WOLF_CFG=/etc/wolf/cfg/config.toml 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)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
if [ -f "$ENV_FILE" ]; then # Resolve game storage dir: explicit arg > .env > prompt
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$ENV_FILE" | cut -d= -f2-) GAME_DIR="${2:-}"
if [ -z "$GAME_DIR" ]; then
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
fi 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 fi
if [ -z "$GAME_DIR" ]; then
echo "Usage: ./manage.sh add-apps <path>"
echo " ./manage.sh update-storage [<new-path>]"
echo ""
echo " <path> root of game storage, e.g. /home/user/drives/games"
echo " update-storage with no path reads GAME_STORAGE_DIR from .env"
exit 1
fi
if [ ! -f "$WOLF_CFG" ]; then if [ ! -f "$WOLF_CFG" ]; then
echo "Wolf config not found at $WOLF_CFG — is Wolf running?" echo "Wolf config not found at $WOLF_CFG — is Wolf running?"
exit 1 exit 1
fi fi
# Persist new path in .env when update-storage is called with explicit path # Show which apps are already installed and what's available
if [ "$1" = "update-storage" ] && [ -n "${2:-}" ]; then echo ""
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" echo " Checking current Wolf config..."
sed -i "s|^GAME_STORAGE_DIR=.*|GAME_STORAGE_DIR=${GAME_DIR}|" "$SCRIPT_DIR/.env" 2>/dev/null \ INSTALLED=$(grep "^ name = 'Wolf" "$WOLF_CFG" 2>/dev/null | sed "s/.*name = '//;s/'//" | tr '\n' ' ')
|| echo "GAME_STORAGE_DIR=${GAME_DIR}" >> "$SCRIPT_DIR/.env" [ -n "$INSTALLED" ] && echo " Already installed: $INSTALLED" || echo " No Wolf apps installed yet."
echo "Updated .env: GAME_STORAGE_DIR=${GAME_DIR}" 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 fi
python3 - "$GAME_DIR" "$WOLF_CFG" << 'PYEOF' echo " Applying: ${APP_KEYS:-none}"
import sys [ -z "$APP_KEYS" ] && exit 0
python3 - "$GAME_DIR" "$WOLF_CFG" $APP_KEYS << 'PYEOF'
import sys, json
games = sys.argv[1].rstrip('/') games = sys.argv[1].rstrip('/')
cfg = sys.argv[2] 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: with open(cfg, 'r') as f:
lines = f.readlines() lines = f.readlines()
@@ -856,105 +1093,41 @@ for i, line in enumerate(lines):
if first_start is None: if first_start is None:
print('ERROR: no [[profiles]] section found'); sys.exit(1) 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] first_block = lines[first_start:insert_at]
has_steam = any("name = 'WolfSteam'" in l for l in first_block) added, updated, to_insert = [], [], []
has_esde = any("name = 'WolfES-DE'" in l for l in first_block)
updated = [] for key in list(CATALOG.keys()):
if has_steam: if key not in selected: continue
new_line = f" mounts = [ '{games}/steam:/home/retro/.steam:rw' ]\n" app = CATALOG[key]
if update_mounts(lines, 'WolfSteam', new_line): wolf_name = app['name']
updated.append('Steam mounts updated') new_mounts = str(app['mounts']).replace('"', "'")
if has_esde: already = any(f"name = '{wolf_name}'" in l for l in first_block)
new_line = f" mounts = [ '{games}/roms:/ROMs:rw', '{games}/saves:/home/retro/.config/retroarch/saves:rw', '{games}/media:/media:rw' ]\n" if already:
if update_mounts(lines, 'WolfES-DE', new_line): if update_mounts(lines, wolf_name, new_mounts):
updated.append('ES-DE mounts updated') updated.append(app['title'])
else:
first_block = lines[first_start:insert_at] to_insert.append(make_app_block(app))
has_steam = any("name = 'WolfSteam'" in l for l in first_block) added.append(app['title'])
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",
]
if to_insert: 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: with open(cfg, 'w') as f:
f.writelines(new_lines) 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: elif updated:
with open(cfg, 'w') as f: with open(cfg, 'w') as f:
f.writelines(lines) f.writelines(lines)
print('; '.join(updated))
else: if added: print(f"Added: {', '.join(added)}")
print('Both apps already in profile (paths unchanged)') 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 PYEOF
docker compose restart wolf 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) backup)
echo "Set up backups with the modular system: sudo ./setup.sh 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 status - Show Wolf + app containers"
echo " ./manage.sh pin - Show recent Moonlight pairing PIN link" echo " ./manage.sh pin - Show recent Moonlight pairing PIN link"
echo " ./manage.sh update - Pull latest Wolf image and restart" echo " ./manage.sh update - Pull latest Wolf image and restart"
echo " ./manage.sh add-apps <path> - Add Steam + ES-DE to Wolf config" echo " ./manage.sh apps - Add / update game launchers in Wolf"
echo " ./manage.sh update-storage [<new-path>] - Update game storage path in Wolf config"
echo " (reads .env if no path given)"
echo " ./manage.sh backup - How to set up backups" echo " ./manage.sh backup - How to set up backups"
;; ;;
esac esac
@@ -1021,19 +1192,139 @@ MEOF
done done
if [ -f "$WOLF_CFG" ]; then if [ -f "$WOLF_CFG" ]; then
log_info "Adding Steam and EmulationStation to Wolf config..." log_info "Injecting selected apps into Wolf config..."
python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" << 'PYEOF' python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" $([[ -n "$_APP_KEYS" ]] && echo "$_APP_KEYS" || echo "steam esde") << 'PYEOF'
import sys, re import sys, json
games = sys.argv[1].rstrip('/') games = sys.argv[1].rstrip('/')
cfg = sys.argv[2] 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: with open(cfg, 'r') as f:
lines = f.readlines() 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) profiles_seen, first_start, insert_at = 0, None, len(lines)
for i, line in enumerate(lines): for i, line in enumerate(lines):
if line.strip() == '[[profiles]]': if line.strip() == '[[profiles]]':
@@ -1045,109 +1336,46 @@ if first_start is None:
print('[ERROR] No [[profiles]] section found in config'); sys.exit(1) print('[ERROR] No [[profiles]] section found in config'); sys.exit(1)
first_block = lines[first_start:insert_at] 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 = [] to_insert = []
if not has_steam:
to_insert += [ for key in list(CATALOG.keys()): # preserve display order
'\n', if key not in selected:
" [[profiles.apps]]\n", continue
" icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n", app = CATALOG[key]
" start_virtual_compositor = true\n", wolf_name = app['name']
" title = 'Steam'\n", already = any(f"name = '{wolf_name}'" in l for l in first_block)
'\n', new_mounts = str(app['mounts']).replace('"', "'")
" [profiles.apps.runner]\n", if already:
" base_create_json = '''{\n", if update_mounts(lines, wolf_name, new_mounts):
' "HostConfig": {\n', updated.append(app['title'])
' "IpcMode": "host",\n', else:
' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n', to_insert.append(make_app_block(app))
' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n', added.append(app['title'])
' "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",
]
if to_insert: 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: with open(cfg, 'w') as f:
f.writelines(new_lines) 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: elif updated:
with open(cfg, 'w') as f: with open(cfg, 'w') as f:
f.writelines(lines) f.writelines(lines)
print(f"[INFO] {'; '.join(updated)}")
else: if added: print(f"[INFO] Added: {', '.join(added)}")
print('[INFO] Steam and EmulationStation already in default profile (paths unchanged)') 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 PYEOF
docker compose restart wolf docker compose restart wolf
log_success "Wolf restarted with updated config" log_success "Wolf restarted with updated config"
else else
log_warning "Wolf config not generated in time. Add apps manually to /etc/wolf/cfg/config.toml" 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 fi
# Hand the folder back to the real user # Hand the folder back to the real user
@@ -1238,7 +1466,7 @@ PYEOF
echo " Online together → each player launches their own session," echo " Online together → each player launches their own session,"
echo " all connect to the same game server" echo " all connect to the same game server"
echo "" 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 ""
echo "── BACKUPS ───────────────────────────────────────────" echo "── BACKUPS ───────────────────────────────────────────"
echo "" echo ""
@@ -1284,7 +1512,7 @@ cd $WOLF_DIR
./manage.sh logs # live logs ./manage.sh logs # live logs
./manage.sh status # container status ./manage.sh status # container status
./manage.sh update # pull latest image and restart ./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) ## Ports (open on firewall / router)