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:
+32
-3
@@ -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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user