diff --git a/README.md b/README.md index aae9c0d..594f7e8 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ can carry a game of the same name without colliding. | Area | Functions | |---|---| | Logging | `set_tag`, `set_verbose`, `log`, `debug`, `die` | -| Files | `load_json`, `write_json`, `write_atomic`, `within` | +| Files | `load_json`, `write_json`, `write_atomic`, `within`, `prune_empty_dirs` | | Config | `deep_merge`, `load_config` | | Store home | `init`, then `HOME`, `CONFIG_PATH`, `STATE_PATH`, `CATALOG_CACHE` | | HTTP | `http_get`, `http_download`, `api_url`, `download_url`, `user_agent` | @@ -67,7 +67,7 @@ can carry a game of the same name without colliding. | Catalog | `fetch_catalog`, `select_games`, `pick_release`, `asset_basename`, `download_image` | | State | `load_state`, `save_state`, `game_key`, `record_scope`, `records_by_scope`, `match_keys`, `limit_to_names` | -Three of them carry decisions worth knowing about. +Four of them carry decisions worth knowing about. **`resolve_for_host(value, host)`** — a config value that may depend on the machine. A plain string is the same everywhere (that is what a `cartridge` is: @@ -80,10 +80,42 @@ skipped rather than installing something that cannot run. store may only remove files from the subtree it owns, never from the user's own library and never from another store's. +**`prune_empty_dirs(dirs, root)`** — the uninstall side of the same rule. A store +that has removed everything should not leave its folders behind, but only empty +directories go, and only inside `root`: one surprise file is enough to keep a +directory. + **`write_atomic`** — nothing is ever written in place. A store interrupted mid-sync would otherwise leave a half-written playlist or gamelist, which is worse than an old one. +## Taking every store off a machine + +```sh +curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh +``` + +A single store is better removed by its own engine's `uninstall.sh`, which knows +that host's extras — a Batocera Ports entry, say. This one is for taking the +whole framework off a machine without having to remember what is installed: it +finds every store home under the known roots, has each store's **own engine** +purge what it installed, and then deletes the store, its launcher and the engine +files. + +| Environment variable | Meaning | +|---|---| +| `DRY_RUN` | `1` to print what would go and remove nothing | +| `KEEP_GAMES` | `1` to remove only the scripts and leave the installed games | +| `FORCE` | `1` to purge even while RetroArch is running | +| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR` | where to look on a Batocera box | +| `STORE_ROOT`, `BIN_DIR` | where to look on a desktop | + +If any engine cannot finish — RetroArch running, a ROMs root unmounted — the run +stops there rather than deleting the engine that knows what it installed. + +The script is POSIX `sh`, not bash, so `| sh` works on a machine whose `/bin/sh` +is dash. The engines' own installers and uninstallers are too. + ## State `state.json`, version 2, keyed `:`: @@ -104,8 +136,8 @@ keyed by bare software name, is re-keyed on first run. ## Users -- [`warp-engine-batocera-store`](https://git.teletypegames.org/tools/warp-engine-batocera-store) — EmulationStation ROM folders, `gamelist.xml`, Ports launchers -- [`warp-engine-retroarch-store`](https://git.teletypegames.org/tools/warp-engine-retroarch-store) — RetroArch `.lpl` playlists and thumbnail folders +- [`warp-engine-batocera-store`](https://git.teletypegames.org/stores/warp-engine-batocera-store) — EmulationStation ROM folders, `gamelist.xml`, Ports launchers +- [`warp-engine-retroarch-store`](https://git.teletypegames.org/stores/warp-engine-retroarch-store) — RetroArch `.lpl` playlists and thumbnail folders Both pin nothing: they fetch `warpstore.py` from this repository's `master` at install time. A change here therefore reaches every store on its next install — diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..ac90f3d --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# Remove every WarpEngine store from this machine — the games, the store +# engines, this shared core and the launchers. +# +# curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh +# +# A single store is better removed by its own engine's uninstaller, which knows +# that host's extras (a Batocera Ports entry, say). This one is for taking the +# whole framework off a machine without having to remember what is installed: +# it finds every store home under the known roots, has each store's own engine +# purge what it installed, and then deletes the store. +# +# DRY_RUN=1 print what would go, remove nothing +# KEEP_GAMES=1 leave the installed games alone, remove only the scripts +# FORCE=1 purge even while RetroArch is running +# +# POSIX sh on purpose: `| sh` has to work on a machine whose /bin/sh is dash. +set -eu + +BATOCERA_STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}" +BATOCERA_PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}" +STORE_ROOT="${STORE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/warp-engine-store}" +BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" +DRY_RUN="${DRY_RUN:-0}" +KEEP_GAMES="${KEEP_GAMES:-0}" +FORCE="${FORCE:-0}" + +say() { echo "[uninstall-all] $*"; } +run() { + if [ "$DRY_RUN" = "1" ]; then echo "[uninstall-all] would: $*"; else "$@"; fi +} + +command -v python3 >/dev/null 2>&1 || { + echo "[uninstall-all] error: python3 is required to ask the engines what they installed" >&2 + exit 1 +} + +FOUND=0 + +# `purge` is the engine's own "remove everything I installed": the shell has no +# way of knowing which ROMs, playlists, thumbnails or gamelist entries were a +# store's, and state.json does. +purge_store() { # purge_store + home="$1"; engine="$2"; homevar="$3"; extra="$4" + if [ "$KEEP_GAMES" = "1" ]; then + say "keeping the games of '$(basename "$home")' (KEEP_GAMES=1)" + return 0 + fi + if [ ! -f "$home/$engine" ] || [ ! -f "$home/config.json" ]; then + say "warning: no engine in $home — its games have to go by hand" + return 0 + fi + dry="" + [ "$DRY_RUN" = "1" ] && dry="--dry-run" + # shellcheck disable=SC2086 + env "$homevar=$home" python3 "$home/$engine" --config "$home/config.json" $dry purge $extra || { + say "error: the engine in $home could not finish — stopping before anything else is removed." + say " Close RetroArch (or FORCE=1), fix what it reported, and run this again." + exit 1 + } +} + +# --- Batocera stores ------------------------------------------------------- +if [ -d "$BATOCERA_STORE_ROOT" ]; then + for home in "$BATOCERA_STORE_ROOT"/*/; do + [ -f "$home/config.json" ] || continue + id="$(basename "$home")" + FOUND=$((FOUND + 1)) + say "batocera store '$id' in $home" + purge_store "${home%/}" store.py BATOCERA_STORE_HOME "" + + # The Ports entry's name is whatever the store repository chose, not + # store.name — but it always runs this store's launcher, so it is found by + # looking inside the scripts rather than by guessing a file name. + if [ -d "$BATOCERA_PORTS_DIR" ]; then + for entry in "$BATOCERA_PORTS_DIR"/*.sh; do + [ -f "$entry" ] || continue + if grep -q -F "${home%/}" "$entry" 2>/dev/null; then + run rm -f "$entry" + fi + done + fi + run rm -f "$BATOCERA_STORE_ROOT/$id-store" + run rm -rf "${home%/}" + done + [ "$DRY_RUN" = "1" ] || rmdir "$BATOCERA_STORE_ROOT" 2>/dev/null || true +fi + +# --- RetroArch stores ------------------------------------------------------ +if [ -d "$STORE_ROOT" ]; then + extra="" + [ "$FORCE" = "1" ] && extra="--force" + for home in "$STORE_ROOT"/*/; do + [ -f "$home/config.json" ] || continue + id="$(basename "$home")" + FOUND=$((FOUND + 1)) + say "retroarch store '$id' in $home" + purge_store "${home%/}" retroarch_store.py RETROARCH_STORE_HOME "$extra" + run rm -f "$BIN_DIR/$id-retroarch-store" + run rm -rf "${home%/}" + done + [ "$DRY_RUN" = "1" ] || rmdir "$STORE_ROOT" 2>/dev/null || true +fi + +if [ "$FOUND" = "0" ]; then + say "no WarpEngine store found. Looked in:" + say " $BATOCERA_STORE_ROOT" + say " $STORE_ROOT" + say "Point at them with BATOCERA_STORE_ROOT / STORE_ROOT if they live elsewhere." + exit 0 +fi + +if [ "$DRY_RUN" = "1" ]; then + say "dry run — nothing was removed ($FOUND store(s) found)." +else + say "done: $FOUND store(s) removed, engines and the shared core with them." + say "Restart EmulationStation or RetroArch to see the games go." +fi diff --git a/warpstore.py b/warpstore.py index cd4de00..4895af8 100644 --- a/warpstore.py +++ b/warpstore.py @@ -8,8 +8,8 @@ matching the machine we are running on, remembering what we put where — is her Two engines use it today: - tools/warp-engine-batocera-store EmulationStation ROM folders + gamelist.xml - tools/warp-engine-retroarch-store RetroArch .lpl playlists + thumbnails + stores/warp-engine-batocera-store EmulationStation ROM folders + gamelist.xml + stores/warp-engine-retroarch-store RetroArch .lpl playlists + thumbnails An adapter supplies three things: a `DEFAULT_CONFIG` describing its own host, an `accept()` callback deciding which catalog entries that host can run, and the @@ -28,7 +28,7 @@ import urllib.error import urllib.parse import urllib.request -VERSION = "1.0.0" +VERSION = "1.1.0" # Bumped when the on-disk shape of state.json changes. v1 keyed `installed` by # bare software name; v2 keys by `:`. @@ -131,6 +131,35 @@ def within(path, root): return path == root or path.startswith(root + os.sep) +def prune_empty_dirs(dirs, root=None, dry_run=False): + """Remove those of `dirs` that are now empty, deepest first. + + A store that has uninstalled everything should not leave its folders behind. + Only empty directories go, and only inside `root` when one is given — so one + surprise file is enough to keep a directory, and a wrong path cannot reach + outside the store's own subtree. + """ + ordered = sorted({os.path.abspath(d) for d in dirs if d}, + key=lambda p: p.count(os.sep), reverse=True) + for path in ordered: + if root and not within(path, root): + log(f"warning: refusing to remove {path} (outside {root})") + continue + if not os.path.isdir(path): + continue + if os.listdir(path): + debug(f"keeping {path} (not empty)") + continue + if dry_run: + log(f"would remove the empty {path}") + continue + try: + os.rmdir(path) + debug(f"removed the empty {path}") + except OSError as exc: + debug(f"cannot remove {path}: {exc}") + + # -------------------------------------------------------------------------- # config # --------------------------------------------------------------------------