diff --git a/README.md b/README.md index 4627e41..f54ed9a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ about any particular site: the host, the store's name and where its games land all come from a `config.json` that lives in a separate **store repository**. The part that is not about Batocera — catalog, releases, host matching, state, HTTP — is [`warpstore`](https://git.teletypegames.org/engines/warpstore), shared -with the [RetroArch engine](https://git.teletypegames.org/tools/warp-engine-retroarch-store). +with the [RetroArch engine](https://git.teletypegames.org/stores/warp-engine-retroarch-store). ``` warpstore the shared core — warpstore.py @@ -28,7 +28,7 @@ teletypegames.org my.example other.example ``` The reference store is -[`ttg-batocera-store`](https://git.teletypegames.org/tools/ttg-batocera-store). +[`ttg-batocera-store`](https://git.teletypegames.org/stores/ttg-batocera-store). ``` Ports ▸ "" @@ -48,8 +48,8 @@ Python 3 standard library only — Batocera ships python3 and no pip. The installer takes the store as a parameter, so it works with any config: ```sh -STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \ - bash <(curl -fsSL https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master/install.sh) +curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/install.sh | + STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json sh ``` From a checkout, with a local config: @@ -75,12 +75,13 @@ Installer knobs, all environment variables: `STORE_CONFIG` (required), ## Writing a store repository -Three files: +Four small files: ``` my-batocera-store/ ├── config.json the store: URL, name, subfolder, platform mapping ├── install.sh a wrapper that hands that config to the engine installer +├── uninstall.sh the same for the engine's uninstaller └── README.md ``` @@ -89,10 +90,18 @@ my-batocera-store/ ```sh #!/bin/bash set -euo pipefail -ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master}" +ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master}" export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-batocera-store/raw/branch/master/config.json}" export BATOCERA_PORT_NAME="My Store" -curl -fsSL "$ENGINE_RAW_BASE/install.sh" | bash +curl -fsSL "$ENGINE_RAW_BASE/install.sh" | sh +``` + +`uninstall.sh` is the same shape, except that it passes `STORE_ID` — no config +fetch is needed to take a store away, and the id is what names its home: + +```sh +export STORE_ID="${STORE_ID:-example}" +curl -fsSL "$ENGINE_RAW_BASE/uninstall.sh" | sh ``` Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps @@ -114,11 +123,51 @@ $S sync # download everything new, refresh gamelists $S sync blessingofra # just one title (never prunes) $S -n sync # dry run $S remove c64:demo # uninstall (bare name works too) +$S purge # uninstall everything this store installed $S config # effective configuration ``` Global flags go **before** the subcommand: `$S --roms-root /tmp/roms sync`. +## Uninstalling + +```sh +ssh root@batocera 'curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/uninstall.sh | sh' +``` + +or `./uninstall.sh` from a checkout on the box. It removes the games first, +through the engine — `purge` — and only then the Ports entry, the launcher and the +store home: the shell script has no way of knowing which ROMs, box art, Ports +payloads and gamelist entries were the store's, and the engine's `state.json` +does. If the engine cannot finish — an unmounted ROMs root, say — nothing else is +touched, so you are never left with the files but not the engine that knows about +them. + +| Environment variable | Meaning | +|---|---| +| `STORE_ID` | which store to remove; needed only when several are installed | +| `STORE_CONFIG` | alternative to `STORE_ID` — the id is read out of the config | +| `DRY_RUN` | `1` to print what would go and remove nothing | +| `KEEP_HOME` | `1` to keep the store home (state, catalog cache, log) for a reinstall | +| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR`, `BATOCERA_PORT_NAME` | as for the installer | + +What it deliberately leaves behind: + +- **ROMs you put in the store's subfolder yourself.** Directories are removed only when empty, so one stray file keeps its directory. `ports/.data` itself also stays: another store may still keep a payload there. +- **your gamelists.** They belong to the box: our `` and `` nodes go, the rest — your own games, their play counts and favourites — is written back untouched. A gamelist we *created* and that is left empty is deleted, since it was never yours. +- **`gamelist.xml.-backup`**, listed at the end of the run: your gamelists as we first found them. Deleting them is your call. + +A dry run cannot predict which directories will end up empty, so it lists the +files but not the directory removals. + +To take **every** store off a machine — both engines, the shared core, the +launchers — there is one script for that in +[`warpstore`](https://git.teletypegames.org/engines/warpstore): + +```sh +curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh +``` + ## Where things land ``` diff --git a/install.sh b/install.sh index 92a702a..797bb28 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Install a WarpEngine store on a Batocera box. # # This installer belongs to the engine, so it takes the store as a parameter: @@ -8,13 +8,13 @@ # # STORE_CONFIG=./config.json ./install.sh # STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \ -# bash <(curl -fsSL https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master/install.sh) +# curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/install.sh | sh # -set -euo pipefail +set -eu STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}" PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}" -ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master}" +ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master}" # The shared core lives in its own repository, because both store engines use it. WARPSTORE_RAW_BASE="${WARPSTORE_RAW_BASE:-https://git.teletypegames.org/engines/warpstore/raw/branch/master}" STORE_CONFIG="${STORE_CONFIG:-}" diff --git a/store.py b/store.py index 969f049..6134683 100755 --- a/store.py +++ b/store.py @@ -40,7 +40,7 @@ except ImportError: "(https://git.teletypegames.org/engines/warpstore)") from warpstore import debug, die, log -VERSION = "3.0.0" +VERSION = "3.1.0" # Every store keeps its code, config, state and log in one directory, so a box # can carry several stores side by side without them treading on each other. @@ -367,12 +367,15 @@ def normalize_path(text): return text[2:] if text.startswith("./") else text -def merge_gamelist(cfg, system, records, dry_run=False): +def merge_gamelist(cfg, system, records, dry_run=False, backup=True): """Rewrite only the / nodes under our subfolder. Everything else in the file — the user's own scraped ROMs, their play counts, favourites, and whatever another store installed under its own subfolder — is parsed and written back untouched. + + `backup=False` is for the uninstall: by then any copy worth having was made + on the first sync, and backing up again would only preserve our own entries. """ path = gamelist_path(cfg, system) prefix = subfolder(cfg) + "/" @@ -384,9 +387,9 @@ def merge_gamelist(cfg, system, records, dry_run=False): log(f"warning: {path} is not valid XML ({exc}) — starting a fresh gamelist") root = ET.Element("gameList") else: - backup = f"{path}.{cfg['store']['id']}-backup" - if not os.path.exists(backup) and not dry_run: - shutil.copy2(path, backup) + backup_path = f"{path}.{cfg['store']['id']}-backup" + if backup and not os.path.exists(backup_path) and not dry_run: + shutil.copy2(path, backup_path) else: root = ET.Element("gameList") @@ -660,6 +663,88 @@ def cmd_remove(cfg, args): return 0 +def drop_empty_gamelist(cfg, system, dry_run=False): + """Delete a gamelist that is empty and that we created. + + No `.-backup` next to it means we never found a gamelist there — it is + ours alone, and with our nodes gone there is nothing in it. Leaving the empty + shell behind would mean the uninstall did not quite put the box back. + """ + path = gamelist_path(cfg, system) + if not os.path.isfile(path) or os.path.exists(f"{path}.{cfg['store']['id']}-backup"): + return + try: + root = ET.parse(path).getroot() + except ET.ParseError: + return + if len(root): + return + if dry_run: + log(f"would remove the now-empty {path}") + else: + os.unlink(path) + log(f"removed the now-empty {path}") + + +def gamelist_backups(cfg, systems): + """The `gamelist.xml.-backup` copies we made, if any are still around.""" + paths = [f"{gamelist_path(cfg, system)}.{cfg['store']['id']}-backup" for system in systems] + return sorted(p for p in paths if os.path.isfile(p)) + + +def cmd_purge(cfg, args): + """Uninstall every game, and take the store's own folders with it. + + What `remove` does for one game, for all of them at once — then the empty + directories, so an uninstall leaves the box looking as it did before. This is + what `uninstall.sh` runs before deleting the store itself: the shell script + has no way of knowing which files were ours. + + The gamelists are merged, not deleted: they belong to the box, and after our + nodes are gone the rest of the user's library is still in them. + """ + state = ws.load_state() + installed = state["installed"] + if not installed: + log("nothing is installed by this store") + + systems = set() + for key, record in list(installed.items()): + remove_game(cfg, record, dry_run=args.dry_run) + systems.add(ws.record_scope(record)) + if not args.dry_run: + del installed[key] + if not args.dry_run: + ws.save_state(state) + + for system in sorted(systems): + merge_gamelist(cfg, system, [], dry_run=args.dry_run, backup=False) + drop_empty_gamelist(cfg, system, dry_run=args.dry_run) + + dirs = [] + for system in sorted(systems): + base = system_dir(cfg, system) + dirs += [os.path.join(base, subfolder(cfg), "images"), + os.path.join(base, subfolder(cfg)), + # `.data` itself stays: it is shared ground, another store may + # still be keeping a payload there. + os.path.join(base, ".data", subfolder(cfg))] + ws.prune_empty_dirs(dirs, root=roms_root(cfg), dry_run=args.dry_run) + + leftovers = gamelist_backups(cfg, systems) + if leftovers: + log("left in place — these are copies of your gamelists as we first found them:") + for path in leftovers: + log(f" {path}") + + if args.dry_run: + log("dry run — nothing removed") + return 0 + if cfg["emulationstation"].get("restart") and not args.no_restart and es_pid(): + restart_es() + return 0 + + def cmd_config(cfg, args): if args.write: if os.path.exists(args.config) and not args.force: @@ -712,6 +797,10 @@ def main(argv=None): p_rm.add_argument("name", nargs="+") p_rm.set_defaults(func=cmd_remove) + p_purge = sub.add_parser("purge", help="uninstall everything this store installed") + p_purge.add_argument("--no-restart", action="store_true", help="never restart EmulationStation") + p_purge.set_defaults(func=cmd_purge) + p_cfg = sub.add_parser("config", help="print the effective config") p_cfg.add_argument("--write", action="store_true", help="write a template config file") p_cfg.add_argument("--force", action="store_true") diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..715cfb9 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,131 @@ +#!/bin/sh +# Remove a WarpEngine store from a Batocera box. +# +# The engine does the removing, not this script: it is the only thing that knows +# which ROMs, box art, Ports payloads and gamelist entries were the store's — so +# it runs `purge` first, and only then do the launcher, the Ports entry and the +# store home go. +# +# ./uninstall.sh # the only store installed, or say which one +# STORE_ID=example ./uninstall.sh +# DRY_RUN=1 ./uninstall.sh # show what would go, remove nothing +# ssh root@batocera 'curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/uninstall.sh | sh' +# +set -eu + +STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}" +PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}" +STORE_ID="${STORE_ID:-}" +STORE_CONFIG="${STORE_CONFIG:-}" +DRY_RUN="${DRY_RUN:-0}" +# Keep the store home — its state.json, catalog cache and log — and take only the +# games, the launcher and the Ports entry. Useful before a reinstall. +KEEP_HOME="${KEEP_HOME:-0}" + +say() { echo "[uninstall] $*"; } +die() { echo "[uninstall] error: $*" >&2; exit 1; } +run() { + if [ "$DRY_RUN" = "1" ]; then echo "[uninstall] would: $*"; else "$@"; fi +} + +command -v python3 >/dev/null 2>&1 || die "python3 is required" + +# Which store? An explicit id wins, then the id in a config, then — if exactly +# one store is installed — that one. Guessing between several would be worse than +# asking. +if [ -z "$STORE_ID" ] && [ -n "$STORE_CONFIG" ]; then + TMP_CFG="$(mktemp)" + trap 'rm -f "$TMP_CFG"' EXIT + case "$STORE_CONFIG" in + http://*|https://*) curl -fsSL "$STORE_CONFIG" -o "$TMP_CFG" ;; + *) [ -f "$STORE_CONFIG" ] || die "no such file: $STORE_CONFIG"; cp "$STORE_CONFIG" "$TMP_CFG" ;; + esac + STORE_ID="$(python3 -c 'import json,sys; print((json.load(open(sys.argv[1])).get("store") or {}).get("id") or "")' "$TMP_CFG")" + [ -n "$STORE_ID" ] || die "no store.id in $STORE_CONFIG" +fi + +if [ -z "$STORE_ID" ]; then + [ -d "$STORE_ROOT" ] || die "nothing is installed under $STORE_ROOT" + FOUND="" + COUNT=0 + for d in "$STORE_ROOT"/*/; do + if [ -f "$d/config.json" ]; then + FOUND="$FOUND $(basename "$d")" + COUNT=$((COUNT + 1)) + fi + done + case "$COUNT" in + 0) die "nothing is installed under $STORE_ROOT" ;; + 1) STORE_ID="${FOUND# }"; say "the only store installed is '$STORE_ID'" ;; + *) die "several stores are installed ($FOUND) — pick one: STORE_ID= $0" ;; + esac +fi + +STORE_HOME="$STORE_ROOT/$STORE_ID" +LAUNCHER="$STORE_ROOT/$STORE_ID-store" +[ -d "$STORE_HOME" ] || die "no store '$STORE_ID' in $STORE_ROOT" + +# The Ports entry cannot be derived from the config: a store repository may set +# BATOCERA_PORT_NAME to something else entirely (ours is "Teletype Games Store", +# while store.name is "Teletype Games"). What is certain is that the entry runs +# this store's launcher, so the entry is found by looking inside the scripts — +# which also works for stores installed before this uninstaller existed. +PORTS_ENTRIES="" +if [ -n "${BATOCERA_PORT_NAME:-}" ]; then + [ -f "$PORTS_DIR/$BATOCERA_PORT_NAME.sh" ] && PORTS_ENTRIES="$PORTS_DIR/$BATOCERA_PORT_NAME.sh" +elif [ -d "$PORTS_DIR" ]; then + for entry in "$PORTS_DIR"/*.sh; do + [ -f "$entry" ] || continue + if grep -q -F "$STORE_HOME" "$entry" 2>/dev/null; then + PORTS_ENTRIES="$PORTS_ENTRIES +$entry" + fi + done +fi + +say "removing the store '$STORE_ID' from $STORE_HOME" + +# The games first, through the engine. If it cannot finish — an unmounted ROMs +# root, say — stop here rather than delete the engine that knows what it +# installed and leave the files orphaned. +if [ -f "$STORE_HOME/store.py" ] && [ -f "$STORE_HOME/config.json" ]; then + DRY="" + [ "$DRY_RUN" = "1" ] && DRY="--dry-run" + # shellcheck disable=SC2086 — the flag is ours and deliberately word-split + BATOCERA_STORE_HOME="$STORE_HOME" python3 "$STORE_HOME/store.py" \ + --config "$STORE_HOME/config.json" $DRY purge \ + || die "the engine could not remove the games (see above) — nothing else was touched." +else + say "warning: no engine in $STORE_HOME — the ROMs and gamelist entries have to go by hand" +fi + +# Newline-separated so a Ports entry with spaces in its name survives the loop. +if [ -n "$PORTS_ENTRIES" ]; then + printf '%s\n' "$PORTS_ENTRIES" | while IFS= read -r entry; do + [ -n "$entry" ] || continue + if [ "$DRY_RUN" = "1" ]; then + echo "[uninstall] would: rm -f $entry" + else + rm -f "$entry" + say "removed the Ports entry $entry" + fi + done +else + say "no Ports entry found in $PORTS_DIR" +fi +run rm -f "$LAUNCHER" + +if [ "$KEEP_HOME" = "1" ]; then + say "keeping $STORE_HOME (KEEP_HOME=1)" +else + run rm -rf "$STORE_HOME" + # Only if this was the last store on the box. + rmdir "$STORE_ROOT" 2>/dev/null && say "removed the now-empty $STORE_ROOT" || true +fi + +if [ "$DRY_RUN" = "1" ]; then + say "dry run — nothing was removed." +else + say "done. EmulationStation was restarted if it was running; if not, the games are" + say "already gone from the gamelists." +fi