diff --git a/README.md b/README.md index 69f1564..39e4f68 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ any particular site: the host, the store's name and where its games land all com from a `config.json` that lives in a separate **store repository**. The shared part — catalog, releases, state, HTTP — is [`warpstore`](https://git.teletypegames.org/engines/warpstore), which this engine -has in common with the [Batocera engine](https://git.teletypegames.org/tools/warp-engine-batocera-store). +has in common with the [Batocera engine](https://git.teletypegames.org/stores/warp-engine-batocera-store). ``` warpstore the shared core @@ -23,7 +23,7 @@ teletypegames.org ``` The reference store is -[`ttg-retroarch-store`](https://git.teletypegames.org/tools/ttg-retroarch-store). +[`ttg-retroarch-store`](https://git.teletypegames.org/stores/ttg-retroarch-store). ``` -retroarch-store sync @@ -66,8 +66,8 @@ trigger: the sync runs from a shell or from a scheduler. 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-retroarch-store/raw/branch/master/install.sh) +curl -fsSL https://git.teletypegames.org/stores/warp-engine-retroarch-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: @@ -103,6 +103,7 @@ $S sync # download everything new, refresh the playlists $S sync blessingofra # just one title (never prunes) $S -n sync # dry run $S remove c64:c64demo # uninstall (bare name works too) +$S purge # uninstall everything this store installed $S config # effective configuration ``` @@ -116,6 +117,45 @@ memory and writes them back when it exits — favourites, last played, sort orde so writing underneath it can be undone. Close it, or pass `--force` if you know what you are doing (`behavior.refuse_while_running: false` to stop asking). +## Uninstalling + +```sh +curl -fsSL https://git.teletypegames.org/stores/warp-engine-retroarch-store/raw/branch/master/uninstall.sh | sh +``` + +or `./uninstall.sh` from a checkout. It removes the games first, through the +engine — `purge` — and only then the launcher and the store home: the shell +script has no way of knowing which playlists, thumbnails and content files were +the store's, and the engine's `state.json` does. If the engine cannot finish +(RetroArch running, a directory gone) 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 | +| `FORCE` | `1` to purge even while RetroArch is running | +| `STORE_ROOT`, `BIN_DIR` | as for the installer | + +What it deliberately leaves behind: + +- **content you put in the store's folder yourself.** Directories are removed only when empty, so one stray file keeps its directory. +- **a playlist that still has entries of yours.** Only items pointing inside the store's content folder are dropped; a playlist that ends up empty is deleted, one that does not is rewritten without us. +- **`*.lpl.-backup`**, listed at the end of the run. Those are the playlists as we first found them, so they can hold entries that were never ours — 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 ``` @@ -240,12 +280,13 @@ open question. ## Writing a store repository -Three files: +Four small files: ``` my-retroarch-store/ ├── config.json the store: URL, name, subfolder, platform → core mapping ├── install.sh a wrapper that hands that config to the engine installer +├── uninstall.sh the same for the engine's uninstaller └── README.md ``` @@ -254,9 +295,17 @@ my-retroarch-store/ ```sh #!/bin/bash set -euo pipefail -ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-retroarch-store/raw/branch/master}" +ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-retroarch-store/raw/branch/master}" export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-retroarch-store/raw/branch/master/config.json}" -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 diff --git a/install.sh b/install.sh index 4d97667..ebe67bc 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Install a WarpEngine store into a RetroArch installation. # # 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-retroarch-store/raw/branch/master/install.sh) +# curl -fsSL https://git.teletypegames.org/stores/warp-engine-retroarch-store/raw/branch/master/install.sh | sh # -set -euo pipefail +set -eu STORE_ROOT="${STORE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/warp-engine-store}" BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" -ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-retroarch-store/raw/branch/master}" +ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-retroarch-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/retroarch_store.py b/retroarch_store.py index 27335c6..1e7a9e2 100755 --- a/retroarch_store.py +++ b/retroarch_store.py @@ -40,7 +40,7 @@ except ImportError: "engine (https://git.teletypegames.org/engines/warpstore)") from warpstore import debug, die, log -VERSION = "1.0.0" +VERSION = "1.1.0" # The playlist format version RetroArch writes today. Bump only after checking # what a current RetroArch produces — the field is how it decides how to read @@ -604,12 +604,15 @@ def read_playlist(path): return data -def write_playlist(cfg, tree, platform, records, dry_run=False): +def write_playlist(cfg, tree, platform, records, dry_run=False, backup=True): """Write one platform's playlist, keeping anything the user added to it. The file carries the store's name, so it is ours — but a user may still have added an entry of their own to it, and that survives: only items pointing inside this store's content folder are rewritten. + + `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. """ spec = cfg["platforms"].get(platform) if not spec: @@ -622,13 +625,10 @@ def write_playlist(cfg, tree, platform, records, dry_run=False): existing = read_playlist(path) foreign = [i for i in (existing or {}).get("items", []) if not target_within(i.get("path"), ours)] - if existing and not dry_run: - backup = f"{path}.{cfg['store']['id']}-backup" - if not os.path.exists(backup): - os.makedirs(os.path.dirname(backup), exist_ok=True) - shutil.copy2(path, backup) if not records and not foreign: + # Nothing of ours left and nothing of anyone else's: the file goes. No + # backup — what it held was our own entries, or it would have survived. if os.path.isfile(path): if dry_run: log(f"would remove the now-empty playlist {path}") @@ -637,6 +637,12 @@ def write_playlist(cfg, tree, platform, records, dry_run=False): log(f"removed the now-empty playlist {path}") return + if existing and backup and not dry_run: + backup_path = f"{path}.{cfg['store']['id']}-backup" + if not os.path.exists(backup_path): + os.makedirs(os.path.dirname(backup_path), exist_ok=True) + shutil.copy2(path, backup_path) + core_path, core_name, note = resolve_core(cfg, tree, platform) if note: log(f"{playlist_name(cfg, platform, spec)}: {note}") @@ -866,6 +872,67 @@ def cmd_remove(cfg, args): return 0 +def playlist_backups(cfg, tree): + """The `.lpl.-backup` files we made, if any are still around.""" + suffix = f".lpl.{cfg['store']['id']}-backup" + try: + names = os.listdir(tree.playlists_dir) + except OSError: + return [] + return sorted(os.path.join(tree.playlists_dir, n) for n in names if n.endswith(suffix)) + + +def cmd_purge(cfg, args): + """Uninstall every title, 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 RetroArch installation 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. + """ + guard_running(cfg, args) + tree = local_tree(cfg, args) + state = ws.load_state() + installed = state["installed"] + if not installed: + log("nothing is installed by this store") + + scopes, thumb_dirs = set(), set() + for key, record in list(installed.items()): + remove_game(cfg, tree, record, dry_run=args.dry_run) + scopes.add(ws.record_scope(record)) + if record.get("playlist"): + thumb_dirs.add(os.path.join(tree.thumbnails_dir, record["playlist"])) + if not args.dry_run: + del installed[key] + if not args.dry_run: + ws.save_state(state) + + # Every platform in the config too, not only the ones we had entries for: a + # playlist can outlive a config change with nothing of ours left in it. + platforms = sorted(scopes | set(cfg["platforms"])) + for platform in platforms: + write_playlist(cfg, tree, platform, [], dry_run=args.dry_run, backup=False) + + content_dirs = [tree.content_dir(cfg, p) for p in platforms if p in cfg["platforms"]] + ws.prune_empty_dirs(content_dirs + [tree.owned_root(cfg)], + root=tree.content_root, dry_run=args.dry_run) + for base in sorted(thumb_dirs): + kinds = [os.path.join(base, k) for k in cfg["playlist"]["thumbnail_kinds"]] + ws.prune_empty_dirs(kinds + [base], root=tree.thumbnails_dir, dry_run=args.dry_run) + + leftovers = playlist_backups(cfg, tree) + if leftovers: + log("left in place — a backup can hold entries that were never ours:") + for path in leftovers: + log(f" {path}") + if args.dry_run: + log("dry run — nothing removed") + else: + log("purged. Restart RetroArch to see the playlists go.") + return 0 + + def cmd_paths(cfg, args): """Where this machine's RetroArch keeps things, and where each answer came from. @@ -946,6 +1013,11 @@ def main(argv=None): p_rm.add_argument("--force", action="store_true", help="write even when RetroArch is running") p_rm.set_defaults(func=cmd_remove) + p_purge = sub.add_parser("purge", help="uninstall everything this store installed") + p_purge.add_argument("--force", action="store_true", + help="write even when RetroArch is running") + p_purge.set_defaults(func=cmd_purge) + p_paths = sub.add_parser("paths", help="show the resolved RetroArch directories and core status") p_paths.set_defaults(func=cmd_paths) diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..45c1e55 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# Remove a WarpEngine store from this machine's RetroArch. +# +# The engine does the removing, not this script: it is the only thing that knows +# which playlists, thumbnails and content files were the store's — so it runs +# `purge` first, and only then does the store itself 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 +# curl -fsSL https://git.teletypegames.org/stores/warp-engine-retroarch-store/raw/branch/master/uninstall.sh | sh +# +set -eu + +STORE_ROOT="${STORE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/warp-engine-store}" +BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" +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 and the launcher. Useful before a reinstall. +KEEP_HOME="${KEEP_HOME:-0}" +# Purge even while RetroArch is running. It writes its playlists back on exit, +# so this can undo the uninstall; closing RetroArch is the better answer. +FORCE="${FORCE:-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="$BIN_DIR/$STORE_ID-retroarch-store" +[ -d "$STORE_HOME" ] || die "no store '$STORE_ID' in $STORE_ROOT" + +say "removing the store '$STORE_ID' from $STORE_HOME" + +# The games first, through the engine. If it cannot finish — RetroArch running, +# a ROM folder gone — stop here rather than delete the engine that knows what it +# installed and leave the files orphaned. +if [ -f "$STORE_HOME/retroarch_store.py" ] && [ -f "$STORE_HOME/config.json" ]; then + DRY="" + [ "$DRY_RUN" = "1" ] && DRY="--dry-run" + PURGE_FLAGS="" + [ "$FORCE" = "1" ] && PURGE_FLAGS="--force" + # shellcheck disable=SC2086 — the flags are ours and deliberately word-split + RETROARCH_STORE_HOME="$STORE_HOME" python3 "$STORE_HOME/retroarch_store.py" \ + --config "$STORE_HOME/config.json" $DRY purge $PURGE_FLAGS \ + || die "the engine could not remove the games (see above) — nothing else was touched. + Close RetroArch and run this again, or FORCE=1 to purge anyway." +else + say "warning: no engine in $STORE_HOME — playlists, thumbnails and content have to go by hand" +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 machine. + 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. Restart RetroArch to see the playlists go." +fi