Add purge and an uninstaller, and move to the stores org
`purge` removes every title this store installed, then the playlists, the thumbnail folders and the store's own directories — what a shell script cannot do, because only state.json knows which files were ours. uninstall.sh runs it before deleting the launcher and the store home, and stops if the engine cannot finish, so you are never left with the files but not the engine that knew about them. What survives on purpose: content you put in the store's folder (directories go only when empty), and a playlist that still holds an entry of yours — only items pointing inside the store's content folder are dropped, and a playlist that ends up empty is deleted while one that does not is rewritten without us. A playlist backup is no longer made while purging. It was being created on the uninstall's first touch of a playlist we had written ourselves, which meant the run reported a backup it had just made — and described it as possibly holding entries that were never ours, which was the opposite of true. install.sh and uninstall.sh are POSIX sh now, checked with dash, so `curl … | sh` works where /bin/sh is not bash. The repository moved from the tools org to stores; the old raw URLs still redirect, but every reference is updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
```
|
||||
<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.<store id>-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
|
||||
|
||||
+4
-4
@@ -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:-}"
|
||||
|
||||
+79
-7
@@ -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.<id>-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)
|
||||
|
||||
|
||||
Executable
+103
@@ -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=<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
|
||||
Reference in New Issue
Block a user