Add a machine-wide uninstaller and the empty-directory rule

A store's own engine can take that store away. What was missing was a way to
take the framework off a machine without having to remember what is on it:
uninstall.sh finds every store home under both known roots, has each store's
own engine purge what it installed, and then removes the store, its launcher,
its Ports entry and the engine files.

It delegates the removing rather than repeating it, because only the engine's
state.json knows which ROMs, playlists, thumbnails or gamelist entries were a
store's. If an engine cannot finish — RetroArch running, a ROMs root unmounted
— the run stops there instead of deleting the engine that knew what it had
installed.

POSIX sh, not bash, so `curl … | sh` works on a machine whose /bin/sh is dash;
checked with dash. `prune_empty_dirs` moves here for the same reason the delete
guard `within()` did: both engines need it, and both need it to be careful. It
removes only directories that are actually empty, and only inside the subtree
the store owns, so one surprise file is enough to keep a directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:31:52 +02:00
co-authored by Claude Opus 5
parent 5b02633fb8
commit a39a29ffde
3 changed files with 186 additions and 7 deletions
+32 -3
View File
@@ -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 `<scope>:<name>`.
@@ -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
# --------------------------------------------------------------------------