Compare commits

...
Author SHA1 Message Date
spoopyghosty0 2690514c7d Save backups: agent v95 (rift-on-frame already uses 94); game version from the deployment's source_version 2026-10-16 11:37:15 -04:00
spoopyghosty0 3ee879378d Save backups for Quest games (discussion #352)
Agent v94: _backup_saves / _restore_saves stream a tar.gz of a game's internal
and external Android storage over the SSH exec channel (caches, IL2CPP and
shader caches, FrameBridge files, videos/OBBs and files over 64 MiB left out;
manifest with sha256 last). File work runs in podman unshare, holds the
launch lock and is refused while the game runs. Restore treats the archive as
untrusted (confined paths, no links/devices, size limits, hashes checked,
staged first), keeps the replaced saves as saves-before-restore.tar.gz (undo)
and puts them back if swapping fails.

PC: install/saves.py (save-backups/<pkg>/, keep 10 by default), game menu
section Saves (back up, restore..., open backups folder), Frame page
"Back up all saves", back up before deleting saves on uninstall, Settings ->
Data keep count, CLI frameport saves backup|list|restore|undo.
2026-10-16 10:42:54 -04:00
19 changed files with 1920 additions and 12 deletions

No files matched your search

+8
View File
@@ -241,6 +241,14 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
flask icon (SCIENCE_OUTLINED) so they aren't confused with Play.
- `uninstall.py` + `frameport uninstall-app` + Settings: removes the data dir (after an optional key backup zip),
PC Steam shortcuts, the WSL Revive copy, and via agent `purge` FramePort's games/files on the Frame.
- Save backups (discussion #352, agent v95; docs/FRAME_RUNTIME.md "Save backups"): agent `_backup_saves`/
`_restore_saves '<json>'` stream a tar.gz of `lepton-data/internal/<pkg>` + `external/Android/data/<pkg>` (caches,
IL2CPP/shader caches, FrameBridge files, videos/OBBs, files >64 MiB left out; manifest `frameport-saves.json` last)
over the exec channel, file work in `podman unshare` (`_saves_ns`), launch lock held, refused while running;
restore = untrusted input (staged + checked first), rollback copy `<base>/saves-before-restore.tar.gz` (undo =
`rollback: true`). PC `install/saves.py` (`<data>/save-backups/<pkg>/<ts>.tar.gz` + `.json`, keep `saves.keep`
= 10), GUI `ui/views/saves_dialog.py` (game menu section "Saves", Frame page "Back up all saves", uninstall's
"back up before deleting"), CLI `frameport saves backup|list|restore|undo`.
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
+590 -1
View File
@@ -8,6 +8,7 @@ Commands: info, prepare, finalize, shortcuts, shortcut_status, launch_test, stop
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr,
controller_models, list_screenshots, delete_screenshots (and more: see the cmd_* functions).
Streaming: python3 frameport_agent.py _keyboard (a virtual keyboard: JSON lines on stdin, see keyboard_session)
python3 frameport_agent.py _backup_saves|_restore_saves '<json>' (save backups: tar.gz on stdout/stdin)
Install layout (one Lepton container per game; same as the manual installs from 2026-09):
~/Applications/quest-frame/<pkg>/ anchor: launch.sh, deployment.json, artwork/ (always internal storage)
@@ -44,7 +45,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 91
AGENT_VERSION = 95
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -7906,6 +7907,588 @@ def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=Non
return 0
# ------------------------------------------------------------------------------------------ save backups (#352)
# Steam doesn't sync Quest games' saves: the PC app backs them up and restores them. The game's two storage roots (its
# internal /data/data/<pkg> folder and its /sdcard/Android/data/<pkg> folder) go into one tar.gz streamed over the SSH
# exec channel: `_backup_saves '<json>'` = archive on stdout, one result JSON line on stderr; `_restore_saves '<json>'`
# = archive on stdin, one result JSON line on stdout. Both refuse while the game runs and hold its launch lock. Files
# Lepton's containers own are read and written in podman's user namespace (`podman unshare`, where the steamos user
# owns every mapped id). Caches, shader/program binaries, IL2CPP copies, FrameBridge's own files, videos/OBBs and
# anything over SAVES_MAX_FILE stay out; the manifest (last member) lists files, sizes, sha256 and what was left out.
SAVES_MANIFEST = "frameport-saves.json"
SAVES_ROLLBACK = "saves-before-restore.tar.gz"
SAVES_FORMAT = 1
SAVES_MAX_FILE = 64 << 20 # larger files are left out of a backup (the manifest's "skipped", reason "size")
SAVES_MAX_TOTAL = 4 << 30 # a bigger backup is refused (that's game content, not saves)
SAVES_RESTORE_MAX_FILE = 1 << 30 # restore (untrusted input): one member at most this big ...
SAVES_RESTORE_MAX_TOTAL = 8 << 30 # ... all of them together at most this big
SAVES_RESTORE_MAX_MEMBERS = 200000
SAVES_MANIFEST_MAX = 32 << 20
# folder names (any depth, case-insensitive) that only hold what a game rebuilds by itself (Android's own backup
# leaves out cache/code_cache/no_backup too)
SAVES_SKIP_DIRS = {"cache", "code_cache", "code cache", "no_backup", "il2cpp", "unityshadercache", "gpucache",
"shadercache", "grshadercache", "dawncache", "dawngraphitecache", "dawnwebgpucache",
"programbinarycache", "vulkanprogrambinarycache", "pipelinecaches", "fp_vk_shaders", "fp_spirv",
"cachestorage", "scriptcache"}
SAVES_SKIP_UNDER_SAVED = {"logs", "crashes"} # Unreal's Saved/Logs and Saved/Crashes
SAVES_SKIP_EXT = (".obb", ".apk", ".foz", ".mp4", ".m4v", ".mkv", ".webm", ".mov")
SAVES_SKIP_PREFIX = ("fb_snap_", "framebridge") # FrameBridge's snapshots, config and state (FramePort writes them)
def saves_roots(base, pkg):
"""{"internal": .../internal/<pkg> (= /data/data/<pkg>), "external": .../external/Android/data/<pkg>}."""
data = os.path.join(base, "lepton-data")
return {"internal": os.path.join(data, "internal", pkg),
"external": os.path.join(data, "external", "Android", "data", pkg)}
def _saves_skip_dir(rel_parts, name):
low = name.lower()
if low in SAVES_SKIP_DIRS:
return True
return low in SAVES_SKIP_UNDER_SAVED and bool(rel_parts) and rel_parts[-1].lower() == "saved"
def _saves_skip_file(name):
low = name.lower()
return low.endswith(SAVES_SKIP_EXT) or low.startswith(SAVES_SKIP_PREFIX)
def collect_saves(roots, max_file=SAVES_MAX_FILE):
"""What a backup holds: {"dirs": [(arc, path, stat)], "files": [(arc, path, stat)], "skipped": [{path, size,
reason}]}, sorted. Symlinks (Lepton's links), devices and pipes are never followed or included."""
import stat as st_
out = {"dirs": [], "files": [], "skipped": []}
for key in sorted(roots):
root = roots[key]
try:
rst = os.lstat(root)
except OSError:
continue
if not st_.S_ISDIR(rst.st_mode):
out["skipped"].append({"path": key, "size": 0, "reason": "not a folder"})
continue
out["dirs"].append((key, root, rst))
for cur, dirs, files in os.walk(root, topdown=True, followlinks=False):
rel = os.path.relpath(cur, root)
parts = [] if rel == "." else rel.split(os.sep)
keep = []
for d in sorted(dirs):
p = os.path.join(cur, d)
arc = "/".join([key, *parts, d])
try:
s = os.lstat(p)
except OSError:
continue
if st_.S_ISLNK(s.st_mode):
out["skipped"].append({"path": arc, "size": 0, "reason": "link"})
elif _saves_skip_dir(parts, d):
out["skipped"].append({"path": arc + "/", "size": 0, "reason": "cache"})
else:
keep.append(d)
out["dirs"].append((arc, p, s))
dirs[:] = keep
for f in sorted(files):
p = os.path.join(cur, f)
arc = "/".join([key, *parts, f])
try:
s = os.lstat(p)
except OSError:
continue
if not st_.S_ISREG(s.st_mode):
out["skipped"].append({"path": arc, "size": 0,
"reason": "link" if st_.S_ISLNK(s.st_mode) else "special"})
elif _saves_skip_file(f):
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "not a save"})
elif s.st_size > max_file:
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "size"})
elif not os.access(p, os.R_OK):
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "unreadable"})
else:
out["files"].append((arc, p, s))
return out
class _HashingReader:
"""A file read for the archive, hashed on the way (tarfile reads exactly the member's size)."""
def __init__(self, f):
self.f, self.h = f, hashlib.sha256()
def read(self, n=-1):
data = self.f.read(n)
self.h.update(data)
return data
def _saves_tarinfo(arc, s, is_dir):
import tarfile
ti = tarfile.TarInfo(arc)
ti.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
ti.mode = s.st_mode & 0o7777
ti.uid, ti.gid = s.st_uid, s.st_gid
ti.uname = ti.gname = ""
ti.mtime = int(s.st_mtime)
ti.size = 0 if is_dir else s.st_size
return ti
def _dep_version(dep):
"""The installed build's game version (deployments record the APK's version as source_version)."""
return str((dep or {}).get("version") or (dep or {}).get("source_version") or "")
def write_saves_tar(out, pkg, roots, dep=None, max_file=SAVES_MAX_FILE, max_total=SAVES_MAX_TOTAL):
"""Stream the game's saves as tar.gz to `out` (a binary file object). Returns the manifest (also the archive's
last member, frameport-saves.json)."""
import gzip
import io
import tarfile
found = collect_saves(roots, max_file)
total = sum(s.st_size for _a, _p, s in found["files"])
if total > max_total:
raise AgentError(f"{pkg}'s saves are {total / 2**30:.1f} GiB, more than the {max_total / 2**30:.0f} GiB a "
f"save backup takes (that's game content, not saves)")
manifest = {"format": SAVES_FORMAT, "package": pkg, "title": (dep or {}).get("title") or pkg,
"version": _dep_version(dep), "created": time.time(), "agent": AGENT_VERSION,
"roots": sorted(roots), "max_file": max_file, "files": [], "dirs": [], "skipped": found["skipped"]}
gz = gzip.GzipFile(fileobj=out, mode="wb", compresslevel=6)
with tarfile.open(fileobj=gz, mode="w|", format=tarfile.PAX_FORMAT) as tar:
for arc, _p, s in found["dirs"]:
tar.addfile(_saves_tarinfo(arc, s, True))
manifest["dirs"].append({"path": arc, "mode": s.st_mode & 0o7777, "uid": s.st_uid, "gid": s.st_gid})
for arc, p, s in found["files"]:
with open(p, "rb") as f:
reader = _HashingReader(f)
tar.addfile(_saves_tarinfo(arc, s, False), reader)
manifest["files"].append({"path": arc, "size": s.st_size, "sha256": reader.h.hexdigest(),
"mode": s.st_mode & 0o7777, "uid": s.st_uid, "gid": s.st_gid,
"mtime": int(s.st_mtime)})
raw = json.dumps(manifest, indent=1).encode()
ti = tarfile.TarInfo(SAVES_MANIFEST)
ti.size, ti.mtime, ti.mode = len(raw), int(manifest["created"]), 0o644
tar.addfile(ti, io.BytesIO(raw))
gz.close()
return manifest
def saves_summary(manifest):
return {"package": manifest.get("package"), "files": len(manifest.get("files") or []),
"bytes": sum(int(f.get("size") or 0) for f in manifest.get("files") or []),
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
"created": manifest.get("created"), "version": manifest.get("version")}
def _saves_member_name(name):
"""A member's path as parts, or None when it isn't confined to internal/ or external/ (absolute, '..', NUL,
backslashes, overlong)."""
if not isinstance(name, str) or not name or "\0" in name or "\\" in name or name.startswith("/"):
return None
parts = [p for p in name.split("/") if p not in ("", ".")]
if not parts or parts[0] not in ("internal", "external") or ".." in parts:
return None
if len(name) > 4096 or any(len(p) > 255 for p in parts):
return None
return parts
def stage_saves_archive(inp, staging, max_file=SAVES_RESTORE_MAX_FILE, max_total=SAVES_RESTORE_MAX_TOTAL,
max_members=SAVES_RESTORE_MAX_MEMBERS):
"""Read an untrusted save archive from `inp` into `staging` (flat numbered files, never the members' names).
Returns (entries {parts tuple: {kind, mode, mtime, staged, size, sha256}}, manifest). Refuses links, devices, paths
outside the two roots, duplicates, oversize members and files that don't match the manifest."""
import tarfile
entries, manifest, total, count = {}, None, 0, 0
try:
tar = tarfile.open(fileobj=inp, mode="r|gz")
except (tarfile.TarError, OSError, EOFError) as exc:
raise AgentError(f"not a save backup (unreadable archive: {exc})") from None
try:
for m in tar:
count += 1
if count > max_members:
raise AgentError("the backup has too many files")
if m.name == SAVES_MANIFEST:
if not m.isreg() or m.size > SAVES_MANIFEST_MAX or manifest is not None:
raise AgentError("the backup's manifest is invalid")
try:
manifest = json.loads(tar.extractfile(m).read().decode("utf-8"))
except ValueError:
raise AgentError("the backup's manifest is invalid") from None
continue
parts = _saves_member_name(m.name)
if parts is None:
raise AgentError(f"the backup has a file outside the game's save folders: {m.name[:200]!r}")
key = tuple(parts)
if key in entries:
raise AgentError(f"the backup has {m.name[:200]!r} twice")
if m.isdir():
entries[key] = {"kind": "dir", "mode": m.mode & 0o7777, "mtime": int(m.mtime or 0)}
continue
if not m.isreg():
raise AgentError(f"the backup has a link or special file: {m.name[:200]!r}")
if len(parts) < 2:
raise AgentError(f"the backup has a file in place of a save folder: {m.name[:200]!r}")
if m.size > max_file:
raise AgentError(f"{m.name[:200]!r} in the backup is too big ({m.size} bytes)")
total += m.size
if total > max_total:
raise AgentError("the backup is too big to restore")
staged = os.path.join(staging, f"{len(entries):08d}")
src = tar.extractfile(m)
h = hashlib.sha256()
with open(staged, "wb") as f:
left = m.size
while left:
chunk = src.read(min(left, 1 << 20))
if not chunk:
raise AgentError("the backup is cut off")
h.update(chunk)
f.write(chunk)
left -= len(chunk)
entries[key] = {"kind": "file", "mode": m.mode & 0o7777, "mtime": int(m.mtime or 0), "staged": staged,
"size": m.size, "sha256": h.hexdigest()}
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
raise AgentError(f"the backup is damaged or cut off ({exc})") from None
finally:
try:
tar.close()
except Exception: # noqa: BLE001
pass
if not isinstance(manifest, dict) or manifest.get("format") != SAVES_FORMAT:
raise AgentError("not a FramePort save backup (no manifest)")
listed = {}
for f in manifest.get("files") or []:
parts = _saves_member_name(str((f or {}).get("path", "")))
if parts is not None:
listed[tuple(parts)] = f
for key, e in entries.items():
if e["kind"] != "file":
continue
f = listed.get(key)
if not f or f.get("sha256") != e["sha256"] or f.get("size") != e["size"]:
raise AgentError(f"the backup is damaged: {'/'.join(key)[:200]} doesn't match its manifest")
return entries, manifest
def _saves_dest(roots, parts):
return os.path.join(roots[parts[0]], *parts[1:])
def check_saves_destinations(roots, entries):
"""No restored path may pass through or replace a link, or swap a file for a folder (Lepton keeps links in a
game's storage: writing through one would land outside it)."""
import stat as st_
for key, e in entries.items():
for i in range(1, len(key) + 1):
sub = key[:i]
path = _saves_dest(roots, sub)
try:
s = os.lstat(path)
except FileNotFoundError:
break # the rest gets created
if st_.S_ISLNK(s.st_mode):
raise AgentError(f"won't restore through the link {'/'.join(sub)}")
want_dir = i < len(key) or e["kind"] == "dir"
if want_dir and not st_.S_ISDIR(s.st_mode):
raise AgentError(f"{'/'.join(sub)} is a file on the Frame but a folder in the backup")
if not want_dir and not st_.S_ISREG(s.st_mode):
raise AgentError(f"{'/'.join(sub)} is a folder on the Frame but a file in the backup")
def _saves_parent_stat(path):
"""stat of path's nearest existing folder."""
p = path
while True:
try:
return os.stat(p)
except OSError:
parent = os.path.dirname(p)
if parent == p:
raise
p = parent
def _saves_chown(path, uid, gid):
try:
s = os.lstat(path)
if (s.st_uid, s.st_gid) != (uid, gid):
os.chown(path, uid, gid)
except OSError:
pass # (outside podman's namespace only our own ids can be set)
def _saves_mkdir(path, mode=None):
"""Create a missing folder (and its parents) owned like its parent folder; mode None = the parent's mode."""
if os.path.isdir(path):
return
parent = os.path.dirname(path)
_saves_mkdir(parent)
ps = os.stat(parent)
os.mkdir(path)
os.chmod(path, ((ps.st_mode if mode is None else mode) & 0o2777) | 0o700)
_saves_chown(path, ps.st_uid, ps.st_gid)
def apply_staged_saves(roots, entries, manifest):
"""Put staged files in place: existing files keep their owner and mode, new ones get their folder's owner and the
backup's mode (at least u+rw); each file is swapped in with one rename. Save files the backup doesn't have go
(the same rules as a backup decide what's a save; files too big for the backup stay). Returns counts."""
import stat as st_
restored = removed = 0
for key in sorted((k for k, e in entries.items() if e["kind"] == "dir"), key=len):
_saves_mkdir(_saves_dest(roots, key), entries[key]["mode"])
for root in roots.values():
_saves_mkdir(root)
for key, e in sorted(entries.items()):
if e["kind"] != "file":
continue
dest = _saves_dest(roots, key)
_saves_mkdir(os.path.dirname(dest))
try:
s = os.lstat(dest)
uid, gid, mode = s.st_uid, s.st_gid, st_.S_IMODE(s.st_mode)
except FileNotFoundError:
ps = _saves_parent_stat(os.path.dirname(dest))
uid, gid, mode = ps.st_uid, ps.st_gid, (e["mode"] & 0o777) | 0o600
staged = e["staged"]
os.chmod(staged, mode)
_saves_chown(staged, uid, gid)
if e.get("mtime"):
os.utime(staged, (e["mtime"], e["mtime"]))
os.replace(staged, dest)
restored += 1
keep = {"/".join(k) for k, e in entries.items() if e["kind"] == "file"}
max_file = manifest.get("max_file")
current = collect_saves(roots, max_file if isinstance(max_file, int) and max_file > 0 else SAVES_MAX_FILE)
for arc, path, _s in current["files"]:
if arc not in keep:
try:
os.remove(path)
removed += 1
except OSError:
pass
return {"restored": restored, "removed": removed}
def saves_context(args):
"""(pkg, dep, roots) of an installed Quest game (its roots are real folders, or missing)."""
pkg = check_pkg(args.get("package"))
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
if dep.get("kind") in ("pcvr", "linux"):
raise AgentError("save backups are for Quest (Android) games only")
base = dep["base"]
if not os.path.isdir(os.path.join(base, "lepton-app")) and not os.path.isdir(os.path.join(base, "lepton-data")):
raise AgentError(f"{pkg}'s files are missing at {base} (storage not mounted?)")
roots = saves_roots(base, pkg)
data = os.path.realpath(os.path.join(base, "lepton-data"))
for r in roots.values():
if os.path.islink(r) or (os.path.lexists(r) and not _inside(os.path.realpath(r), data)):
raise AgentError(f"{r} is a link: refusing")
return pkg, dep, roots
class _SavesLock:
"""The game's launch lock (launch.sh holds it while the game runs): a backup or restore never overlaps a play
session, and a Play meanwhile is ignored like a double launch."""
def __init__(self, dep):
self.path, self.fd = os.path.join(dep["base"], ".launch.lock"), None
def __enter__(self):
if fcntl is None:
return self
self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(self.fd)
self.fd = None
raise AgentError("the game is running") from None
return self
def __exit__(self, *exc):
if self.fd is not None:
os.close(self.fd)
self.fd = None
def saves_running(dep):
return bool(dep.get("appid")) and container_running(dep["appid"])
def _saves_in_namespace(op, args):
"""Run op again inside podman's user namespace (stdio inherited); None = run it in this process."""
owner = as_owner()
if not owner or os.environ.get("FRAMEPORT_SAVES_NS") == "1":
return None
env = dict(os.environ, FRAMEPORT_SAVES_NS="1")
return subprocess.run(owner + [sys.executable or "python3", os.path.abspath(__file__), "_saves_ns", op,
json.dumps(args)], env=env).returncode
def saves_backup_session(args, out, err):
"""`_backup_saves`: the archive on `out`, one JSON line (ok + summary, or error) on `err`; exit 0 = complete."""
def emit(obj):
err.write(json.dumps(obj) + "\n")
err.flush()
def here():
pkg, dep, roots = saves_context(args)
manifest = write_saves_tar(out, pkg, roots, dep, int(args.get("max_file") or SAVES_MAX_FILE),
int(args.get("max_total") or SAVES_MAX_TOTAL))
out.flush()
emit({"ok": True, "result": saves_summary(manifest)})
return 0
try:
return _saves_run("backup", args, here)
except AgentError as exc:
emit({"ok": False, "error": str(exc)})
return 1
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
def saves_restore_session(args, inp, out):
"""`_restore_saves`: the archive on `inp` (or the rollback copy with args.rollback), one JSON line on `out`."""
def emit(obj):
out.write(json.dumps(obj) + "\n")
out.flush()
def here():
emit({"ok": True, "result": restore_saves(args, inp)})
return 0
try:
return _saves_run("restore", args, here)
except AgentError as exc:
emit({"ok": False, "error": str(exc)})
return 1
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
def _saves_run(op, args, here):
"""Outside podman's namespace: check the game, take its launch lock, then run `op` in the namespace (stdio
inherited). Inside it (FRAMEPORT_SAVES_NS=1), or without podman (tests): run `here` in this process."""
if os.environ.get("FRAMEPORT_SAVES_NS") == "1":
return here()
_pkg, dep, _roots = saves_context(args)
if saves_running(dep):
raise AgentError("the game is running")
with _SavesLock(dep):
code = _saves_in_namespace(op, args)
return here() if code is None else code
def restore_saves(args, inp):
"""Stage (and check) the whole archive first, keep the current saves as <base>/saves-before-restore.tar.gz (one
copy), then swap the files in. A failure while swapping puts the rollback copy back. args.rollback restores that
copy instead of `inp` (the current saves become the new rollback copy: undo of the undo)."""
pkg, dep, roots = saves_context(args)
base = dep["base"]
rollback = os.path.join(base, SAVES_ROLLBACK)
staging = tempfile.mkdtemp(prefix=".saves-restore-", dir=base)
limits = (SAVES_RESTORE_MAX_FILE, SAVES_RESTORE_MAX_TOTAL, SAVES_RESTORE_MAX_MEMBERS)
try:
if args.get("rollback"):
if not os.path.isfile(rollback):
raise AgentError("there's no copy of the saves from before the last restore")
with open(rollback, "rb") as f:
entries, manifest = stage_saves_archive(f, staging, *limits)
else:
entries, manifest = stage_saves_archive(inp, staging, *limits)
if manifest.get("package") != pkg:
raise AgentError(f"this backup is of {manifest.get('package')}, not {pkg}")
check_saves_destinations(roots, entries)
tmp = rollback + ".tmp"
with open(tmp, "wb") as f:
write_saves_tar(f, pkg, roots, dep, SAVES_MAX_FILE, 1 << 62)
os.replace(tmp, rollback)
try:
counts = apply_staged_saves(roots, entries, manifest)
except Exception as exc: # noqa: BLE001
undo = os.path.join(staging, "undo")
os.mkdir(undo)
try:
with open(rollback, "rb") as f:
back, back_manifest = stage_saves_archive(f, undo)
apply_staged_saves(roots, back, back_manifest)
state = "the saves from before were put back"
except Exception as again: # noqa: BLE001
state = f"putting the saves from before back failed too ({again}); they're in {rollback}"
raise AgentError(f"restoring failed ({exc}); {state}") from exc
return {**counts, "package": pkg, "rollback": rollback, "rollback_size": os.path.getsize(rollback),
"backup_created": manifest.get("created"), "backup_version": manifest.get("version")}
finally:
shutil.rmtree(staging, ignore_errors=True)
def _saves_info(found):
return {"files": len(found["files"]), "bytes": sum(s.st_size for _a, _p, s in found["files"]),
"skipped": [s for s in found["skipped"] if s["reason"] in ("size", "unreadable")]}
def cmd_saves_info(args):
"""What a save backup of the game would hold now (files, bytes, large files left out), whether the game runs, and
the rollback copy of the last restore."""
pkg, dep, roots = saves_context(args)
max_file = int(args.get("max_file") or SAVES_MAX_FILE)
owner = as_owner()
if owner: # the app's files may belong to mapped ids: count them in podman's namespace
p = run(owner + [sys.executable or "python3", os.path.abspath(__file__), "_saves_ns", "info",
json.dumps(args)], env=dict(os.environ, FRAMEPORT_SAVES_NS="1"))
try:
info = json.loads(p.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
raise AgentError(f"couldn't read {pkg}'s saves: {p.stderr.strip()[-300:]}") from None
else:
info = _saves_info(collect_saves(roots, max_file))
rollback = os.path.join(dep["base"], SAVES_ROLLBACK)
info.update(package=pkg, running=saves_running(dep), version=_dep_version(dep),
rollback={"size": os.path.getsize(rollback), "time": os.path.getmtime(rollback)}
if os.path.isfile(rollback) else None)
return info
def cmd_saves_rollback_delete(args):
"""Remove the copy of the saves from before the last restore."""
_pkg, dep, _roots = saves_context(args)
path = os.path.join(dep["base"], SAVES_ROLLBACK)
if os.path.isfile(path):
os.remove(path)
return {"removed": True}
return {"removed": False}
def saves_ns_main(op, raw):
"""`_saves_ns <op> '<json>'`: the part of a save command that runs inside `podman unshare`."""
os.environ["FRAMEPORT_SAVES_NS"] = "1"
args = json.loads(raw)
if op == "backup":
return saves_backup_session(args, sys.stdout.buffer, sys.stderr)
if op == "restore":
return saves_restore_session(args, sys.stdin.buffer, sys.stdout)
if op == "info":
try:
_pkg, _dep, roots = saves_context(args)
print(json.dumps(_saves_info(collect_saves(roots, int(args.get("max_file") or SAVES_MAX_FILE)))))
return 0
except AgentError as exc:
print(str(exc), file=sys.stderr)
return 1
return 2
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
@@ -7918,6 +8501,12 @@ def main():
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_round":
print(json.dumps(overlay_autostart_round()), flush=True)
return
if len(sys.argv) >= 3 and sys.argv[1] == "_backup_saves":
return saves_backup_session(json.loads(sys.argv[2]), sys.stdout.buffer, sys.stderr)
if len(sys.argv) >= 3 and sys.argv[1] == "_restore_saves":
return saves_restore_session(json.loads(sys.argv[2]), sys.stdin.buffer, sys.stdout)
if len(sys.argv) >= 4 and sys.argv[1] == "_saves_ns":
return saves_ns_main(sys.argv[2], sys.argv[3])
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
return monitor_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
+13
View File
@@ -178,6 +178,19 @@ draw more than the battery delivers, especially at lower charge, and the Frame t
Saves stay on the Frame when you update or reinstall a game.
## Can I back up a game's saves?
Yes. Steam doesn't sync Quest games' saves, so FramePort copies them to your PC: the game's menu (**…** on its page) →
**Saves** → **Back up saves**, or **Back up all saves** on the Steam Frame page (Installed games). Close the game
first. A backup holds the game's own storage (`/data/data/<package>` and `/sdcard/Android/data/<package>`), without
caches, shader caches and large downloaded content (videos, OBBs, files over 64 MB); the job says when something was
left out. FramePort keeps the 10 newest backups per game (Settings → Data). They're in FramePort's data folder under
`save-backups/<package>/` (**Open backups folder** in the game's menu).
**Restore saves…** puts one back: it replaces the game's saves on the Frame. The Frame keeps a copy of the saves it
had, so **Undo last restore** in the same window takes you back. When you uninstall a game and tick *Also delete its
saves*, FramePort backs them up first. Command line: `frameport saves backup|list|restore|undo <game>`.
## A game with mods crashes
How to tell: play once without mods and custom content. If it then works, a mod is the cause; the diagnostics zip
+26
View File
@@ -60,6 +60,32 @@
`launch.sh` at the new folder and only then deletes the old copy; on the same filesystem it renames instead.
Moving a game whose Lepton data holds absolute paths, or a PC VR prefix, is not yet verified on the device.
## Save backups (Quest games, agent v95, discussion #352)
- A Lepton game's saves are two folders of `<base>/lepton-data`: `internal/<pkg>` (= `/data/data/<pkg>`:
`shared_prefs/`, `files/`, `databases/`; Unreal's `files/UE4Game|UnrealGame/.../Saved` often lives here) and
`external/Android/data/<pkg>` (= `/sdcard/Android/data/<pkg>`: Unity's `files/`, Unreal's external `Saved`). On the
dev Frame (2026-10-16) every file in them belongs to `steamos` (Lepton's `keep-id:uid=0`: the container's root and
app = steamos), mostly mode 600/660 in 700/771/775/2771 folders; Lepton's links (`Android/obb/<pkg>`, `Movies`,
`Download`, `Documents`) sit outside these two folders.
- Agent `_backup_saves '<json>'` streams a tar.gz of both (members `internal/…`, `external/…`, PAX, numeric owners and
modes; last member `frameport-saves.json` = manifest with size + sha256 per file and what was left out) on the SSH
exec channel's stdout, the result JSON on stderr. Left out: folders named cache/code_cache/no_backup/il2cpp/
UnityShaderCache/(Vulkan)ProgramBinaryCache/GPUCache/… at any depth, Unreal `Saved/Logs|Crashes`, FrameBridge's
`fb_snap_*`/`framebridge*` files, `.obb/.apk/.foz/.mp4/.mkv/.webm/.mov`, symlinks and special files, and files over
64 MiB (big downloaded content; e.g. BattleTalent's `files/Video`). More than 4 GiB of saves is refused.
- `_restore_saves '<json>'` reads the archive from stdin and treats it as untrusted: only regular files and folders
under `internal/`/`external/` (no `..`, absolute paths, links, devices, duplicates), ≤1 GiB per file / 8 GiB in all,
each file's sha256 must match the manifest, the manifest's package must be the game. Everything is staged in
`<base>/.saves-restore-*` first, a path that would pass through a link on the Frame is refused, then the current
saves are written to `<base>/saves-before-restore.tar.gz` (one copy; `rollback: true` restores it and keeps the
replaced saves as the new copy) and each file is renamed into place. Existing files keep their owner and mode, new
ones get their folder's owner and the backup's mode (at least u+rw); save files the backup doesn't have are removed
(left-out files stay). A failure while swapping puts the rollback copy back.
- Both run their file work in `podman unshare` (the agent re-runs itself as `_saves_ns <op>` with stdio inherited) so
files owned by mapped ids are readable and keep their ids, take the game's launch lock (`<base>/.launch.lock`, a
Play meanwhile is ignored as a double launch) and refuse while its container runs. `saves_info` sizes a backup and
reports the rollback copy, `saves_rollback_delete` removes it.
## OpenXR runtime (as seen by games through overport's loader)
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
KHR_vulkan_enable(2), KHR_opengl_es_enable, KHR_composition_layer_depth, FB_display_refresh_rate, EXT_hand_tracking
+9
View File
@@ -220,6 +220,14 @@ The **Files** tab copies files between your PC and the Frame: videos, documents,
`/sdcard/Download`, `/sdcard/Documents`).
- **Game storage** holds each game's own files. A game's menu → **Add videos and files…** opens it.
## Backing up saves
Steam doesn't sync Quest games' saves. A game's menu (**…** on its page) → **Saves** → **Back up saves** copies them
to your PC (close the game first); **Back up all saves** on the Steam Frame page does every installed Quest game.
**Restore saves…** puts a backup back on the Frame and keeps a copy of the replaced saves there (**Undo last
restore**). Backups live in FramePort's data folder under `save-backups/`; the 10 newest per game are kept (Settings →
Data). Caches and large downloaded content stay out. More in the [FAQ](FAQ.md#can-i-back-up-a-games-saves).
## Share a recipe or report a problem
- **Share working recipe…** (in the game's menu) opens a GitHub issue with the game's recipe filled in. Accepted
@@ -265,6 +273,7 @@ option. The main ones:
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
| `frame send <files> --dest videos` / `frame storage` / `frame cleanup` | copy files to the Frame, list where they can go, free space |
| `frame drives` / `frame move <game> --to <drive>` / `install --dest <drive>` | list the Frame's drives, move a game, install to a drive |
| `saves backup <game>\|--all` / `saves list <game>` / `saves restore <game> [n]` / `saves undo <game>` | back up Quest games' saves to this PC, restore one, undo the last restore |
| `tools status` / `tools install` | the tools FramePort downloads |
| `open-link "<link>"` | install from an install button's address or a download link (`--yes`, `--no-install`) |
| `diag report <game>` / `share-recipe <game>` | report a problem / share a working recipe |
+25
View File
@@ -135,6 +135,9 @@ class FakeFS:
return {"shots": shots, "total": len(shots), "games": list(games.values())}
if command == "take_screenshot": # the Screenshots tab's button: a new shot of the running game
return self.take_screenshot()
if command == "saves_info": # Restore saves…: what the Frame has now, and the copy from the last restore
return {"package": args.get("package"), "files": 14, "bytes": 2_310_000, "skipped": [],
"running": False, "version": "1.0", "rollback": {"size": 812_000, "time": time.time() - 86400}}
raise RuntimeError(f"fake Frame: {command} not available")
@property
@@ -598,6 +601,28 @@ def scan_job(app, folder: str, entries: list[dict], pace: float = 1.0):
return app.submit(f"Scan {folder.rstrip('/').rsplit('/', 1)[-1]}", run, None, "scan")
def fake_save_backups(pkg: str) -> None:
"""Two pretend save backups of `pkg` on this PC (yesterday's and last week's), for the Restore saves… dialog."""
import io
import json
import tarfile
from frameport.install import saves
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
for days, n, size in ((1, 14, 2_300_000), (7, 11, 1_900_000)):
created = time.time() - days * 86400
path = d / time.strftime("%Y%m%d-%H%M%S.tar.gz", time.localtime(created))
manifest = {"format": 1, "package": pkg, "created": created, "version": "1.0",
"files": [{"path": f"external/files/save{i}.dat", "size": size // n} for i in range(n)]}
raw = json.dumps(manifest).encode()
with tarfile.open(path, "w:gz") as tar:
ti = tarfile.TarInfo(saves.MANIFEST)
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
def attach_fake_frame(app: FramePortApp, monitor_session=None) -> None:
"""Connect `app` to a FakeTarget (the shared monitor stream, used by the Monitor tab and the Frame card, too)."""
app.monitor_hub.session_factory = monitor_session or FakeMonitorSession
+4
View File
@@ -27,6 +27,7 @@ from showcase.fakes import ( # noqa: E402,F401
add_fake_linux_apps,
attach_fake_frame,
fake_live_stream,
fake_save_backups,
install_fakes,
open_monitor_details,
open_type_tab,
@@ -153,6 +154,9 @@ def main() -> int:
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# discussion #352: pick a save backup (two pretend ones) to restore, "Undo last restore" from the Frame
steps.append(("restore-saves-dialog", lambda a: (a.page.pop_dialog(), fake_save_backups(game),
a.restore_saves(game))))
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
+107
View File
@@ -22,6 +22,7 @@ Errors are one line on stderr; FRAMEPORT_DEBUG=1 shows the traceback.
frameport diag report <pkg> # zip + prefilled GitHub problem report
frameport share-recipe <pkg> --status works # prefilled GitHub issue submitting a working recipe
frameport open-link "<install link>" # FrameDrop button / frameport:// link: download, add, install
frameport saves backup <pkg>|--all # Quest games' saves from the Frame to this PC (list, restore, undo)
"""
from __future__ import annotations
@@ -50,7 +51,9 @@ tools_app = typer.Typer(help="Manage the portable toolchain.")
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
pc_app = typer.Typer(help="This PC as a target for PC VR games (Steam and SteamVR; Revive for Oculus games).")
diag_app = typer.Typer(help="Diagnostics bundles and problem reports.")
saves_app = typer.Typer(help="Back up Quest games' saves from the Frame to this PC and restore them.")
app.add_typer(tools_app, name="tools")
app.add_typer(saves_app, name="saves")
app.add_typer(frame_app, name="frame")
app.add_typer(pc_app, name="pc")
app.add_typer(diag_app, name="diag")
@@ -709,6 +712,110 @@ def frame_move(package: str = typer.Argument(..., help="an installed game"),
typer.echo(f"moved to {st.get('base') or st.get('to')}")
# ------------------------------------------------------------------------------------------ save backups (#352)
def _save_pkg(package: str) -> str:
"""A library game (package or part of its title), else a package name as it is (installed from another PC)."""
import re
try:
return _pkgs(package, False)[0]
except typer.BadParameter:
if re.match(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$", package):
return package
raise
@saves_app.command("backup")
def saves_backup(package: Optional[str] = typer.Argument(None, help="the game (package or part of its title)"),
all_: bool = typer.Option(False, "--all", help="every Quest game installed on the Frame"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Copy a Quest game's saves from the Frame to this PC (caches and big downloaded content stay out). The newest
backups are kept (10 per game, setting saves.keep). The game must be closed."""
from .install import saves
target = _target(frame)
if all_:
pkgs = [g["package"] for g in target.installed() if g.get("kind") not in ("pcvr", "linux")
and not g.get("overlay")]
elif package:
pkgs = [_save_pkg(package)]
else:
raise typer.BadParameter("give a package or --all")
failed = 0
for pkg in pkgs:
try:
b = saves.backup(target.frame, pkg, printing_reporter(verbose=False))
except saves.SavesError as exc:
failed += 1
typer.echo(f"{pkg}: not backed up: {exc}", err=True)
continue
typer.echo(f"{pkg}: {b.files} file(s), {b.bytes / 2**20:.1f} MiB of saves -> {b.path}"
+ (f" ({b.skipped} large file(s) left out)" if b.skipped else ""))
if failed:
raise typer.Exit(1)
@saves_app.command("list")
def saves_list(package: str = typer.Argument(..., help="the game (package or part of its title)"),
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
"""This PC's backups of a game, newest first (the number is what `saves restore` takes)."""
from .install import saves
pkg = _save_pkg(package)
found = saves.list_backups(pkg)
if as_json:
typer.echo(json.dumps([{"path": str(b.path), "created": b.created, "size": b.size, "files": b.files,
"bytes": b.bytes, "version": b.version, "skipped": b.skipped} for b in found],
indent=1))
return
if not found:
typer.echo(f"{pkg}: no backups yet (frameport saves backup {pkg}); folder: {saves.backups_dir(pkg)}")
return
for i, b in enumerate(found, 1):
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(b.created))
typer.echo(f"{i:3} {when} {b.files:5} file(s) {b.bytes / 2**20:8.1f} MiB"
+ (f" v{b.version}" if b.version else "") + f" {b.path.name}")
@saves_app.command("restore")
def saves_restore(package: str = typer.Argument(..., help="the game (package or part of its title)"),
backup: Optional[str] = typer.Argument(None, help="a number from `saves list` (default 1 = the "
"newest) or a backup file"),
yes: bool = typer.Option(False, "--yes", "-y", help="don't ask"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Put a backup's saves back on the Frame. The game's current saves there are replaced; the Frame keeps a copy of
them (`saves undo` puts them back). The game must be closed."""
from .install import saves
pkg = _save_pkg(package)
if backup and not backup.isdigit():
path = Path(backup)
if not path.is_file():
raise typer.BadParameter(f"no such file: {backup}")
else:
found = saves.list_backups(pkg)
n = int(backup or 1)
if not 1 <= n <= len(found):
raise typer.BadParameter(f"{pkg} has {len(found)} backup(s) (frameport saves list {pkg})")
path = found[n - 1].path
if not yes:
typer.confirm(f"Replace {pkg}'s saves on the Frame with {path.name}?", abort=True)
r = saves.restore(_target(frame).frame, pkg, path, printing_reporter(verbose=False))
typer.echo(f"{pkg}: restored {r.get('restored', 0)} file(s), removed {r.get('removed', 0)}; the saves from "
f"before are kept on the Frame (frameport saves undo {pkg})")
@saves_app.command("undo")
def saves_undo(package: str = typer.Argument(..., help="the game (package or part of its title)"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Put back the saves the last `saves restore` replaced (they're kept on the Frame)."""
from .install import saves
pkg = _save_pkg(package)
r = saves.undo_restore(_target(frame).frame, pkg)
typer.echo(f"{pkg}: the saves from before the last restore are back ({r.get('restored', 0)} file(s))")
@frame_app.command("info")
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
+301
View File
@@ -0,0 +1,301 @@
"""Save backups of Quest games (GitHub discussion #352: Steam doesn't sync them).
The Frame agent streams a game's saves as one tar.gz over an SSH exec channel (`_backup_saves`: its internal
/data/data/<pkg> folder and its /sdcard/Android/data/<pkg> folder, caches and big content left out; the archive's
last member `frameport-saves.json` lists every file with size and sha256). Backups live on this PC in
<user data>/save-backups/<pkg>/<YYYYmmdd-HHMMSS>.tar.gz with a <same>.json sidecar (the manifest without the file
list); the newest `keep_count()` per game are kept (setting `saves.keep`). Restoring streams one back
(`_restore_saves`): the agent checks the whole archive first (paths, links, sizes, hashes), keeps the Frame's current
saves as <base>/saves-before-restore.tar.gz (one copy: `undo_restore`), then swaps the files in. Both refuse while the
game runs. No Flet here: the GUI and the CLI share it.
"""
from __future__ import annotations
import json
import re
import tarfile
import time
from dataclasses import dataclass
from pathlib import Path
from ..core import library
from ..core.events import Reporter
from ..core.paths import user_data_dir
KEEP_DEFAULT = 10
KEEP_CHOICES = (3, 5, 10, 20, 50)
MANIFEST = "frameport-saves.json"
_NAME = re.compile(r"^\d{8}-\d{6}(-\d+)?\.tar\.gz$")
_PKG = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
CHUNK = 1 << 16
EXEC_TIMEOUT = 600 # s without data from the Frame (a dead link must not hang a job)
class SavesError(RuntimeError):
"""The Frame refused or failed (its own sentence, e.g. "the game is running")."""
@dataclass
class Backup:
package: str
path: Path
created: float
size: int # the archive on this PC
files: int = 0
bytes: int = 0 # the save files' size (uncompressed)
version: str = ""
title: str = ""
skipped: int = 0 # large files left out
@property
def sidecar(self) -> Path:
return sidecar_of(self.path)
def root_dir() -> Path:
return user_data_dir() / "save-backups"
def backups_dir(pkg: str) -> Path:
if not _PKG.match(pkg):
raise ValueError(f"bad package name {pkg!r}")
return root_dir() / pkg
def sidecar_of(archive: Path) -> Path:
return archive.with_name(archive.name[: -len(".tar.gz")] + ".json")
def keep_count() -> int:
try:
n = int(library.setting("saves.keep", KEEP_DEFAULT))
except (TypeError, ValueError):
n = KEEP_DEFAULT
return max(1, n)
def read_manifest(archive: Path) -> dict:
"""The manifest inside a backup (reads the whole archive: also checks it's complete). ValueError if it isn't a
FramePort save backup."""
try:
with tarfile.open(archive, "r:gz") as tar:
found = None
for m in tar:
if m.name == MANIFEST and m.isreg():
found = json.loads(tar.extractfile(m).read().decode("utf-8"))
except (OSError, tarfile.TarError, EOFError, ValueError) as exc:
raise ValueError(f"{archive.name} isn't a readable save backup ({exc})") from None
if not isinstance(found, dict) or found.get("format") != 1 or not found.get("package"):
raise ValueError(f"{archive.name} isn't a FramePort save backup")
return found
def _sidecar_data(manifest: dict, archive: Path) -> dict:
files = manifest.get("files") or []
return {"package": manifest.get("package"), "created": manifest.get("created"),
"version": manifest.get("version") or "", "title": manifest.get("title") or "",
"files": len(files), "bytes": sum(int(f.get("size") or 0) for f in files),
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
"archive": archive.name, "agent": manifest.get("agent")}
def _from_sidecar(archive: Path, data: dict) -> Backup:
return Backup(package=str(data.get("package") or archive.parent.name), path=archive,
created=float(data.get("created") or archive.stat().st_mtime), size=archive.stat().st_size,
files=int(data.get("files") or 0), bytes=int(data.get("bytes") or 0),
version=str(data.get("version") or ""), title=str(data.get("title") or ""),
skipped=len(data.get("skipped") or []))
def list_backups(pkg: str) -> list[Backup]:
"""This game's backups on this PC, newest first (a missing/broken sidecar is rebuilt from the archive)."""
d = backups_dir(pkg)
out = []
for archive in sorted(d.glob("*.tar.gz")) if d.is_dir() else []:
if not _NAME.match(archive.name):
continue
try:
data = json.loads(sidecar_of(archive).read_text(encoding="utf-8"))
except (OSError, ValueError):
try:
data = _sidecar_data(read_manifest(archive), archive)
sidecar_of(archive).write_text(json.dumps(data, indent=1), encoding="utf-8")
except (OSError, ValueError):
continue
try:
out.append(_from_sidecar(archive, data))
except OSError:
continue
return sorted(out, key=lambda b: b.created, reverse=True)
def has_backups(pkg: str) -> bool:
try:
d = backups_dir(pkg)
except ValueError:
return False
return d.is_dir() and any(_NAME.match(p.name) for p in d.glob("*.tar.gz"))
def prune(pkg: str, keep: int | None = None, protect: Path | None = None) -> list[Path]:
"""Delete all but the newest `keep` backups of a game (never `protect`). Returns the deleted archives."""
keep = keep_count() if keep is None else max(1, keep)
gone = []
for b in list_backups(pkg)[keep:]:
if protect is not None and b.path == protect:
continue
for p in (b.path, b.sidecar):
p.unlink(missing_ok=True)
gone.append(b.path)
return gone
def delete(backup: Backup) -> None:
backup.path.unlink(missing_ok=True)
backup.sidecar.unlink(missing_ok=True)
# ------------------------------------------------------------------------------------------ the Frame side
def _quote(s: str) -> str:
return "'" + s.replace("'", "'\"'\"'") + "'"
def _exec(frame, command: str, args: dict):
remote = frame.ensure_agent()
return frame.client.exec_command(f"python3 {_quote(remote)} {command} {_quote(json.dumps(args))}",
timeout=EXEC_TIMEOUT)
def _last_json(text: str) -> dict | None:
for line in reversed(text.splitlines()):
line = line.strip()
if line.startswith("{"):
try:
return json.loads(line)
except ValueError:
return None
return None
def info(frame, pkg: str) -> dict:
"""{files, bytes, skipped, running, version, rollback: {size, time} | None} of the game's saves on the Frame."""
return frame.agent("saves_info", package=pkg)
def _new_name(d: Path) -> Path:
stamp = time.strftime("%Y%m%d-%H%M%S")
path, n = d / f"{stamp}.tar.gz", 1
while path.exists() or path.with_name(path.name + ".part").exists():
path, n = d / f"{stamp}-{n}.tar.gz", n + 1
return path
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None = None) -> Backup:
"""Back up the game's saves from the Frame into a new archive on this PC, then keep the newest `keep`."""
rep = reporter or Reporter()
rep.stage("saves", "Backing up saves")
try:
expected = int(info(frame, pkg).get("bytes") or 0)
except Exception: # noqa: BLE001 - only for the progress bar
expected = 0
d = backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
dest = _new_name(d)
part = dest.with_name(dest.name + ".part")
stdin, stdout, stderr = _exec(frame, "_backup_saves", {"package": pkg})
received = 0
try:
stdin.channel.shutdown_write()
with open(part, "wb") as f:
while True:
rep.check_cancel()
chunk = stdout.read(CHUNK)
if not chunk:
break
f.write(chunk)
received += len(chunk)
if expected: # compressed: an estimate, never "done" before the end
rep.progress(min(0.95, received / expected), f"{received / 2**20:.1f} MiB")
code = stdout.channel.recv_exit_status()
reply = _last_json(stderr.read().decode("utf-8", "replace")) or {}
if code or not reply.get("ok"):
raise SavesError(reply.get("error") or f"the Frame couldn't back up the saves (exit {code})")
manifest = read_manifest(part)
if manifest.get("package") != pkg:
raise SavesError(f"the Frame sent saves of {manifest.get('package')}, not {pkg}")
part.replace(dest)
except BaseException:
try:
stdout.channel.close()
except Exception: # noqa: BLE001
pass
part.unlink(missing_ok=True)
raise
data = _sidecar_data(manifest, dest)
sidecar_of(dest).write_text(json.dumps(data, indent=1), encoding="utf-8")
result = _from_sidecar(dest, data)
prune(pkg, keep, protect=dest)
rep.progress(1.0, "")
rep.log(f"saved {result.files} file(s), {result.bytes / 2**20:.1f} MiB of saves, to {dest}"
+ (f"; {result.skipped} large file(s) left out" if result.skipped else ""))
return result
def restore(frame, pkg: str, archive: Path, reporter: Reporter | None = None) -> dict:
"""Send a backup to the Frame; its current saves are kept there as a rollback copy (`undo_restore`)."""
rep = reporter or Reporter()
archive = Path(archive)
manifest = read_manifest(archive) # a damaged or foreign file fails here, before the Frame is touched
if manifest.get("package") != pkg:
raise SavesError(f"this backup is of {manifest.get('package')}, not {pkg}")
rep.stage("saves", "Restoring saves")
size = archive.stat().st_size
stdin, stdout, stderr = _exec(frame, "_restore_saves", {"package": pkg})
sent = 0
try:
with open(archive, "rb") as f:
while True:
rep.check_cancel()
chunk = f.read(CHUNK)
if not chunk:
break
try:
stdin.write(chunk)
except OSError:
break # the Frame stopped reading (refused): its reply says why
sent += len(chunk)
rep.progress(min(0.95, sent / max(1, size)), f"{sent / 2**20:.1f} MiB")
try:
stdin.channel.shutdown_write()
except OSError:
pass
except BaseException:
try:
stdout.channel.close() # a cancelled restore: the agent sees a cut-off archive and changes nothing
except Exception: # noqa: BLE001
pass
raise
out = stdout.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
reply = _last_json(out) or {}
if not reply.get("ok"):
err = stderr.read().decode("utf-8", "replace").strip()
raise SavesError(reply.get("error") or f"the Frame couldn't restore the saves (exit {code}) {err[-300:]}")
rep.progress(1.0, "")
return reply["result"]
def undo_restore(frame, pkg: str) -> dict:
"""Put back the saves the last restore replaced (the Frame's rollback copy; the replaced ones become the new
rollback copy)."""
stdin, stdout, stderr = _exec(frame, "_restore_saves", {"package": pkg, "rollback": True})
try:
stdin.channel.shutdown_write()
except OSError:
pass
reply = _last_json(stdout.read().decode("utf-8", "replace")) or {}
stdout.channel.recv_exit_status()
if not reply.get("ok"):
raise SavesError(reply.get("error") or "the Frame couldn't put the saves back")
return reply["result"]
+42
View File
@@ -5,6 +5,7 @@
],
" (connect the Frame first)": "",
" (list cut off)": "",
" (newest)": "",
" (update: {latest})": "",
" ({len} can't be installed there)": "",
" + Revive": "",
@@ -193,7 +194,17 @@
"Automatic ({auto:.0%})": "",
"Automatic SteamVR performance settings (this PC)": "",
"Back to library": "",
"Back up all saves": "",
"Back up its saves to this PC before deleting them": "",
"Back up saves": "",
"Back up saves: {title}": "",
"Back up the signing keys ({len}) to {shown_dir} first": "",
"Backed up the saves of {n} game": [
"",
""
],
"Backed up {done} of {total} games' saves; not: {failed}": "",
"Backing up {title}": "",
"Banner": "",
"Battery": "",
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
@@ -238,11 +249,13 @@
"Check program": "",
"Check that the Frame and this PC are on the same network.": "",
"Checking for updates": "",
"Checking the saves on the Frame…": "",
"Checking this PC…": "",
"Checking tools…": "",
"Checking which app opens these links…": "",
"checksum checked": "",
"Check…": "",
"Choose a backup to put back on the Frame. Close the game first.": "",
"Choose a file": "",
"Choose a folder": "",
"Choose a folder with Quest, Android or PC VR games. FramePort finds them and gets them ready.": "",
@@ -316,6 +329,7 @@
"Copy this theme as a file": "",
"Corrects graphics code the Frame's drivers reject (black screen with sound).": "",
"Corrects specific shaders that hang the game on the Frame. Comes from a game's recipe.": "",
"Couldn't check the saves on the Frame: {error}": "",
"Couldn't check this PC": "",
"Couldn't connect: {exc}": "",
"Couldn't copy the screenshot: {error}": "",
@@ -952,6 +966,7 @@
"No": "",
"No activity": "",
"no answer from the Frame": "",
"No backups of {title} on this PC yet. Back up its saves first (game menu → Back up saves).": "",
"no checksum": "",
"No dev build is published right now": "",
"No file at that path": "",
@@ -1021,6 +1036,7 @@
"ON AIR": "",
"On Frame": "",
"On the desktop": "",
"On the Frame now: {line}": "",
"On the Frame, open Konsole (SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole) and run:": "",
"On the Frame, open Settings → Developer → Pair new host and approve FramePort there.": "",
"On the Frame, open Settings → Developer → Pair new host and approve FramePort.": "",
@@ -1039,11 +1055,13 @@
"Only internal storage": "",
"Open": "",
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
"Open backups folder": "",
"Open frameport:// links in FramePort": "",
"Open game": "",
"Open game page": "",
"Open GitHub issue": "",
"Open in browser": "",
"Open save backups": "",
"Open the Monitor": "",
"Open {scheme}:// links with FramePort?": "",
"Open {title}": "",
@@ -1161,6 +1179,7 @@
"Put a password, link or note in the box and press Type it.": "",
"Put the Frame to sleep": "",
"Put the Frame to sleep?": "",
"Puts back the saves {title} had before the last restore. The saves it has now become the copy you can undo to.": "",
"Puts settings files next to the game on the Frame, for example to turn off an unsupported effect.": "",
"Quality": "",
"Quest": "",
@@ -1254,6 +1273,7 @@
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (for example Vader Immortal's lightspeed jump).": "",
"Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).": "",
"Replace application label and icon": "",
"Replace the saves on the Frame?": "",
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the Frame: GLES sessions, cylinder→quad layers, sRGB format fallback, 30 s VR-mode deadline, emulated time conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (for example The Climb 2, Path of the Warrior); OVRPort cannot translate those.": "",
"Report a problem": "",
"Report a problem · {title}": "",
@@ -1274,6 +1294,10 @@
"Restart the Frame?": "",
"Restart the Frame…": "",
"Restarting": "",
"Restore": "",
"Restore saves · {title}": "",
"Restore saves: {title}": "",
"Restore saves…": "",
"Resume": "",
"Resuming — copied files are skipped": "",
"Retry refused picture formats": "",
@@ -1306,6 +1330,7 @@
"Runs xrSyncActions one at a time with xrPollEvent and skips it for 250 ms after focus returns. For games that crash in the runtime's input code right after focus comes back (SIGSEGV in vrclient.so UpdateActionStateInternal / xrSyncActions, for example Myst).": "",
"Save": "",
"Save as known-good recipe": "",
"Save backups to keep per game": "",
"Save the screenshots to which folder on this PC?": "",
"Saved as a known-good recipe": "",
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
@@ -1313,6 +1338,7 @@
"Saved {name} — drag it into the GitHub issue that opened": "",
"Saved. Restart the game to use the new settings.": "",
"Saved. They apply when the game is installed.": "",
"Saves": "",
"Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.": "",
"saves and settings": "",
"Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the Frame. Shows what the runtime gets without anyone wearing the headset.": "",
@@ -1456,6 +1482,7 @@
"Steady controller input after focus": "",
"Steam": "",
"Steam and SteamVR": "",
"Steam doesn't sync Quest games' saves. This copies each installed Quest game's saves to this PC (caches and big downloaded content stay out). Restore one from the game's menu → Restore saves….": "",
"Steam for Windows not found": "",
"Steam Frame": "",
"Steam Frame patches": "",
@@ -1562,6 +1589,7 @@
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's current saves on the Frame are replaced. FramePort keeps a copy of them on the Frame, so you can undo the last restore.": "",
"The game's files or settings changed. Update applies only what's needed; saves are kept.": "",
"The game's {kind} isn't at {path} any more. Add the game again from the folder that has it.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
@@ -1659,6 +1687,10 @@
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (remove it afterwards with: sudo ufw delete allow {port}/tcp)": "",
"unclear": "",
"Undo last restore ({when})": "",
"Undo restore": "",
"Undo restore: {title}": "",
"Undo the last restore?": "",
"Uninstall": "",
"Uninstall FramePort": "",
"Uninstall FramePort?": "",
@@ -1762,6 +1794,7 @@
"Vermillion shows a canvas being painted as a separate compositor layer, which the Frame draws in front of everything: the hand and brush disappear over the canvas while they still paint. Rewrites PaintableMesh.HasCompositorLayerAvailable and ShouldUseCompositorOverlay -> false in libil2cpp.so (found with Cpp2IL), so the game uses its own fallback and draws the canvas in the scene. The canvas may look a little less sharp.": "",
"Vermillion: paint canvases in the scene": "",
"Version": "",
"version {version}": "",
"Very high": "",
"Vibration strength": "",
"Video panels that an Android media player draws into (XR_KHR_android_surface_swapchain, for example I Am Monkey's intro video), which the Frame's runtime refuses, get an Android Surface from FrameBridge: each video frame is copied into an ordinary swapchain the panel shows. Without it these panels are missing and a game waiting for its video stays black. 0 = leave them to the runtime.": "",
@@ -1935,6 +1968,10 @@
"",
""
],
"{n} large file left out": [
"",
""
],
"{n} line": [
"",
""
@@ -1968,6 +2005,7 @@
"{rate}/s": "",
"{remaining} more after this": "",
"{size:.1f} GiB free": "",
"{size} of saves": "",
"{title} couldn't be added to the Frame's Steam library: {why}": "",
"{title} ended.": "",
"{title} failed: {error}": "",
@@ -1977,6 +2015,7 @@
"{title} joins the Frame's Steam library once the running game closes": "",
"{title} joins the Frame's Steam library when you're back in Gaming Mode — start it there": "",
"{title} moved to {label}": "",
"{title}'s saves on the Frame are replaced by the backup from {when}. The Frame keeps a copy of the saves it has now, so you can undo this.": "",
"{title}: analyzed again": "",
"{title}: checks failed": "",
"{title}: details from {value}": "",
@@ -1987,7 +2026,10 @@
"{title}: newer version added": "",
"{title}: newer version added. Press Update to install it.": "",
"{title}: ready": "",
"{title}: saves backed up ({line})": "",
"{title}: saves from {when} restored ({n} files)": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: the saves from before the last restore are back": "",
"{title}: {line}": "",
"{used} of {total}": "",
"{used}/{max} kernel keys used": "",
+47 -4
View File
@@ -1168,13 +1168,17 @@ class FramePortApp:
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
"remove": lambda e: self.remove_from_library(pkg),
"backup_saves": lambda e: self.backup_saves(pkg),
"restore_saves": lambda e: self.restore_saves(pkg),
"saves_folder": lambda e: self.open_saves_folder(pkg),
}
enabled = (lambda opts: [(label, icon, handler) for label, icon, handler, disabled, _ in opts
if handler and not disabled])
busy = bool(job)
on_frame = connected and C.install_state(g, self.frame_info) in ("installed", "outdated")
st = MenuState(
quick=quick, connected=connected, job=busy,
on_frame=connected and C.install_state(g, self.frame_info) in ("installed", "outdated"),
quick=quick, connected=connected, job=busy, on_frame=on_frame,
saves=on_frame and not rift and not linux,
on_pc=rift and pkg in self.pc_installs(),
plays=[] if busy or not quick else enabled(self.play_options(g)),
installs=[] if busy or not quick else enabled(self.install_options(g)),
@@ -1765,6 +1769,30 @@ class FramePortApp:
return tr("{title}: Steam artwork updated on the Frame").format(title=self._title(pkg))
return self.submit(tr("Update Steam art: {title}").format(title=self._title(pkg)), run, pkg, "art")
# Save backups (discussion #352): views/saves_dialog.py, install/saves.py
def backup_saves(self, pkg: str) -> Job:
from .views import saves_dialog
return saves_dialog.backup_saves(self, pkg)
def restore_saves(self, pkg: str) -> None:
from .views import saves_dialog
saves_dialog.restore_dialog(self, pkg)
def open_saves_folder(self, pkg: str) -> None:
from .views import saves_dialog
saves_dialog.open_folder(pkg)
def backup_all_saves(self) -> Job | None:
"""Frame page: one job backing up every installed Quest game's saves."""
from .views import saves_dialog
pkgs = [d.get("package") for d in (self.frame_info or {}).get("installed", [])
if d.get("package") and d.get("kind") not in ("pcvr", "linux") and not d.get("overlay")]
return saves_dialog.backup_all(self, pkgs)
def move_game(self, pkg: str) -> None:
"""Move to…: pick another drive of the Frame (microSD card, internal storage) for an installed game; the copy
runs as a Frame job (GitHub #90)."""
@@ -1842,13 +1870,28 @@ class FramePortApp:
"The game folder stays.").format(title=title)
delete_local, extra = self._delete_local_option(pkg)
delete_data = None
delete_data = backup_first = None
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
delete_data, row = C.checkbox(tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = row if extra is None else ft.Column([row, extra], spacing=T.px(8), tight=True)
rows = [row]
if (library.game(pkg) or {}).get("kind") not in ("rift", "linux"): # discussion #352
backup_first, backup_row = C.checkbox(tr("Back up its saves to this PC before deleting them"),
value=True, active_color=T.ACCENT, disabled=True)
def follow(e):
backup_first.disabled = not delete_data.value
C.update(backup_first)
delete_data.on_change = follow
rows.append(ft.Container(backup_row, padding=ft.Padding(T.S5, 0, 0, 0)))
rows += [extra] if extra is not None else []
extra = ft.Column(rows, spacing=T.px(8), tight=True) if len(rows) > 1 else row
def run(job: Job):
if delete_data and delete_data.value and backup_first and backup_first.value:
from ..install import saves
saves.backup(self._target_for(to).frame, pkg, job.reporter) # fails = nothing deleted
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
self._pc_cache = None
if delete_local and delete_local.value:
+2
View File
@@ -120,6 +120,8 @@ HELP: dict[str, str] = _Translated({
"type_on_frame": "This PC's keyboard types on the Frame while this tab is open. In the headset, select a text "
"field, then type here.",
"free_space": "Deletes copies kept from earlier installs and unfinished uploads. Games and saves stay.",
"save_backups": "Steam doesn't sync Quest games' saves. This copies each installed Quest game's saves to this PC "
"(caches and big downloaded content stay out). Restore one from the game's menu → Restore saves….",
"install_drive": "Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → "
"Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.",
"move_game": "Copies the game and its saves to the other drive, checks the copy, then removes the old one. The "
+10 -1
View File
@@ -36,6 +36,7 @@ class MenuState:
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
saves: bool = False # a Quest/Android game installed on the connected Frame: back up / restore saves
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
@@ -62,6 +63,13 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
saves: list = [] # GitHub discussion #352: Steam doesn't sync Quest games' saves
if st.saves and not job:
saves += [(tr("Back up saves"), ft.Icons.BACKUP_ROUNDED, act("backup_saves")),
(tr("Restore saves…"), ft.Icons.RESTORE_ROUNDED, act("restore_saves"))]
if st.android:
saves.append((tr("Open backups folder"), ft.Icons.FOLDER_OPEN_ROUNDED, act("saves_folder")))
artwork = [(tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")),
(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
(tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_OUTLINED, act("custom_art"))]
@@ -87,7 +95,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
danger = [(tr("Remove from library…"), ft.Icons.REMOVE_CIRCLE_OUTLINE_ROUNDED, act("remove"))]
out: list = list(top)
for head, items in ((tr("Name and artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
for head, items in ((tr("Saves"), saves), (tr("Name and artwork"), artwork), (tr("Recipe"), recipe),
(tr("Troubleshoot"), trouble)):
if items:
out += [None, Header(head), *items]
return out + [None, *danger]
+6 -4
View File
@@ -192,11 +192,13 @@ class FrameView:
padding=T.S5)
send = C.ghost(tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda e: self.app.go("files"),
tooltip=C.tip(HELP["files"]))
quest = any(d.get("kind") not in ("pcvr", "linux") and not d.get("overlay") for d in items)
backup = [C.ghost(tr("Back up all saves"), ft.Icons.BACKUP_ROUNDED, lambda e: self.app.backup_all_saves(),
tooltip=C.tip(HELP["save_backups"]))] if quest else []
free = C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED, lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))
return C.section(tr("Installed games ({len})").format(len=len(items)), body,
action=ft.Row([send, C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED,
lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))], spacing=T.S2, tight=True)
if items else send)
action=ft.Row([send, *backup, free], spacing=T.S2, tight=True) if items else send)
def _fill_installed(self, items: list[dict], col: ft.Column) -> None:
def settings_slot() -> ft.Control:
+164
View File
@@ -0,0 +1,164 @@
"""Save backups of Quest games (GitHub discussion #352): "Back up saves" (a job), "Restore saves…" (pick a backup,
confirm; the Frame keeps the replaced saves for one undo), "Open backups folder", and "Back up all saves" on the Frame
page. The work itself is install/saves.py (shared with the CLI)."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import flet as ft
from ...errors import explain
from ...i18n import fmt_size, tr, tr_n
from ...install import saves
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
from ..jobs import Job
def _frame(app: FramePortApp):
return app._target_for("frame").frame
def when(t: float) -> str:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(t))
def backup_line(b: saves.Backup) -> str:
""""12 files · 3.4 MB of saves · version 1.2" (+ large files left out)."""
parts = [tr_n("{n} file", "{n} files", b.files), tr("{size} of saves").format(size=fmt_size(b.bytes))]
if b.version:
parts.append(tr("version {version}").format(version=b.version))
if b.skipped:
parts.append(tr_n("{n} large file left out", "{n} large files left out", b.skipped))
return " · ".join(parts)
def backup_saves(app: FramePortApp, pkg: str) -> Job:
"""Back up the game's saves from the Frame to this PC (a job)."""
title = app._title(pkg)
def run(job: Job):
b = saves.backup(_frame(app), pkg, job.reporter)
return tr("{title}: saves backed up ({line})").format(title=title, line=backup_line(b))
return app.submit(tr("Back up saves: {title}").format(title=title), run, pkg, "tool-frame")
def backup_all(app: FramePortApp, packages: list[str]) -> Job | None:
"""One job backing up every installed Quest game's saves (a game that runs or fails is reported, the rest go
on)."""
if not packages:
return None
def run(job: Job):
done, failed = 0, []
for i, pkg in enumerate(packages):
job.reporter.check_cancel()
job.reporter.log(tr("Backing up {title}").format(title=app._title(pkg)))
try:
saves.backup(_frame(app), pkg, job.reporter)
done += 1
except saves.SavesError as exc:
failed.append(f"{app._title(pkg)}: {explain(exc)}")
job.reporter.log(failed[-1])
job.reporter.progress((i + 1) / len(packages))
if failed:
return tr("Backed up {done} of {total} games' saves; not: {failed}").format(
done=done, total=len(packages), failed="; ".join(failed[:3]) + (" …" if len(failed) > 3 else ""))
return tr_n("Backed up the saves of {n} game", "Backed up the saves of {n} games", done)
return app.submit(tr("Back up all saves"), run, None, "tool-frame")
def open_folder(pkg: str) -> None:
from ...core import winhost
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
winhost.open_folder(d)
def restore_dialog(app: FramePortApp, pkg: str) -> None:
"""Pick one of the game's backups and restore it on the Frame (after a confirmation); undo the last restore."""
title = app._title(pkg)
found = saves.list_backups(pkg)
by_path = {str(b.path): b for b in found}
group = ft.RadioGroup(value=str(found[0].path) if found else None, content=ft.Column([
ft.Row([ft.Radio(value=str(b.path), active_color=T.ACCENT),
ft.Column([C.body(when(b.created) + (tr(" (newest)") if i == 0 else ""), T.TEXT),
C.meta(backup_line(b))], spacing=T.px(2), expand=True)],
spacing=T.S2)
for i, b in enumerate(found)], spacing=T.S2, scroll=ft.ScrollMode.AUTO, height=min(T.px(300),
T.px(52) * len(found))))
undo_row = ft.Row([C.meta(tr("Checking the saves on the Frame…"))], spacing=T.S2,
vertical_alignment=ft.CrossAxisAlignment.CENTER)
def restore(e):
b = by_path.get(group.value or "")
if not b:
return
def go():
app.page.pop_dialog()
def run(job: Job):
r = saves.restore(_frame(app), pkg, b.path, job.reporter)
return tr("{title}: saves from {when} restored ({n} files)").format(
title=title, when=when(b.created), n=r.get("restored", 0))
app.submit(tr("Restore saves: {title}").format(title=title), run, pkg, "tool-frame")
C.confirm(app.page, tr("Replace the saves on the Frame?"),
tr("{title}'s saves on the Frame are replaced by the backup from {when}. The Frame keeps a copy of "
"the saves it has now, so you can undo this.").format(title=title, when=when(b.created)),
tr("Restore"), go, danger=True)
def undo(e):
def go():
app.page.pop_dialog()
def run(job: Job):
saves.undo_restore(_frame(app), pkg)
return tr("{title}: the saves from before the last restore are back").format(title=title)
app.submit(tr("Undo restore: {title}").format(title=title), run, pkg, "tool-frame")
C.confirm(app.page, tr("Undo the last restore?"),
tr("Puts back the saves {title} had before the last restore. The saves it has now become the copy "
"you can undo to.").format(title=title), tr("Undo restore"), go, danger=True)
def fill():
try:
info = saves.info(_frame(app), pkg)
except Exception as exc: # noqa: BLE001
undo_row.controls = [C.meta(tr("Couldn't check the saves on the Frame: {error}").format(
error=explain(exc)))]
C.update(undo_row)
return
now = tr("On the Frame now: {line}").format(line=" · ".join(
[tr_n("{n} file", "{n} files", int(info.get("files") or 0)),
tr("{size} of saves").format(size=fmt_size(int(info.get("bytes") or 0)))]))
rows: list[ft.Control] = [ft.Container(C.meta(now), expand=True)]
rb = info.get("rollback")
if rb:
rows.append(C.ghost(tr("Undo last restore ({when})").format(when=when(rb.get("time") or 0)),
ft.Icons.UNDO_ROUNDED, on_click=undo))
undo_row.controls = rows
C.update(undo_row)
restore_btn = C.primary(tr("Restore"), ft.Icons.RESTORE_ROUNDED, on_click=restore, disabled=not found)
if found:
listing: ft.Control = group
else:
listing = C.callout(tr("No backups of {title} on this PC yet. Back up its saves first (game menu → Back up "
"saves).").format(title=title))
content = ft.Column([
C.body(tr("Choose a backup to put back on the Frame. Close the game first."), T.TEXT_2),
listing,
C.callout(tr("The game's current saves on the Frame are replaced. FramePort keeps a copy of them on the Frame, "
"so you can undo the last restore."), "warn"),
undo_row,
], spacing=T.S3, tight=True)
app.page.show_dialog(C.dialog(
tr("Restore saves · {title}").format(title=title), content, size="m",
actions=[C.ghost(tr("Open backups folder"), ft.Icons.FOLDER_OPEN_ROUNDED, on_click=lambda e: open_folder(pkg)),
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()), restore_btn]))
app.run_bg(fill)
+20
View File
@@ -135,6 +135,25 @@ class SettingsView:
ft.Container(height=T.S2), self.ssh_keys()],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def save_backups(self) -> ft.Control:
"""Settings → Data: how many save backups to keep per game (install/saves.py, setting saves.keep)."""
from ...core import library, winhost
from ...install import saves
def changed(e):
library.set_setting("saves.keep", int(e.control.value))
def open_root(e):
saves.root_dir().mkdir(parents=True, exist_ok=True)
winhost.open_folder(saves.root_dir())
keep = C.dropdown(label=tr("Save backups to keep per game"), value=str(saves.keep_count()), width=T.px(260),
options=[ft.DropdownOption(str(n), str(n))
for n in sorted({*saves.KEEP_CHOICES, saves.keep_count()})],
on_select=changed)
return ft.Row([keep, C.help_icon("save_backups"),
C.ghost(tr("Open save backups"), ft.Icons.FOLDER_OPEN_ROUNDED, open_root)],
spacing=T.S2, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER)
def ssh_keys(self) -> ft.Control:
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
EXTRA_KEYS_SETTING; the Frame connection and OpenSSH uploads both use them, GitHub #242)."""
@@ -512,6 +531,7 @@ class SettingsView:
C.kv(tr("Catalog"), tr("{len} tested recipes (built in, online and yours)")
.format(len=len(catalog.load())), "catalog"),
self.catalog_updates(),
self.save_backups(),
], spacing=T.S2))),
# text above, buttons below (side by side, the buttons squeezed the text in a narrow window)
"feedback": C.section(tr("Problems and feedback"), C.card(ft.Column([
+19 -2
View File
@@ -27,7 +27,7 @@ def _states():
"""(name, game, state) for the cases the menus have to handle."""
return [
("quest on Frame", QUEST, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
settings=True, selectable=True, android=True)),
settings=True, selectable=True, android=True, saves=True)),
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
settings=True, selectable=True, android=True)),
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
@@ -110,7 +110,7 @@ def test_sections_and_conditions():
st.quick = False
entries = menu_sections(g, st, _key)
heads = [e.label for e in entries if isinstance(e, Header)]
assert heads == ["Name and artwork", "Recipe", "Troubleshoot"]
assert heads == ["Saves", "Name and artwork", "Recipe", "Troubleshoot"]
assert _section_of(entries, "Analyze again") == "Troubleshoot"
assert _section_of(entries, "Update art on Frame") == "Name and artwork"
assert _section_of(entries, "Rename…") == "Name and artwork"
@@ -188,3 +188,20 @@ def test_newer_version_for_linux_apps_and_pc_vr_games():
assert ("Update with a newer version…" in full) == want, name
if g is not QUEST:
assert "Update with a newer APK…" not in quick + full, name
def test_save_backups_in_the_full_menu_of_quest_games():
"""Discussion #352: back up / restore a Quest game's saves (installed on the Frame, no job running); the backups
folder for every Quest game; nothing for PC VR games and Linux apps; never in the short right-click menu."""
for name, g, st in _states():
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
assert not {"Back up saves", "Restore saves…", "Open backups folder"} & set(quick), name
st.quick = False
entries = menu_sections(g, st, _key)
full = [e[0] for e in _items(entries)]
live = g is QUEST and st.saves and not st.job
assert ("Back up saves" in full) == live, name
assert ("Restore saves…" in full) == live, name
assert ("Open backups folder" in full) == (g is QUEST), name
if g is QUEST:
assert _section_of(entries, "Open backups folder") == "Saves"
+204
View File
@@ -0,0 +1,204 @@
"""Save backups on the PC (GitHub discussion #352): storage, retention, manifests, and the stream to/from the agent,
which runs here for real (a stand-in `podman` whose `unshare` just runs the command)."""
import json
import os
import subprocess
import sys
import tarfile
import time
from pathlib import Path
import pytest
from frameport.core import library
from frameport.install import saves
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
PKG = "com.example.game"
class _Chan:
def __init__(self, proc):
self.proc = proc
def shutdown_write(self):
if not self.proc.stdin.closed:
self.proc.stdin.close()
def recv_exit_status(self):
return self.proc.wait(30)
def close(self):
self.proc.kill()
class _Stream:
def __init__(self, f, chan):
self.f, self.channel = f, chan
def read(self, n=-1):
return self.f.read(n)
def write(self, data):
self.f.write(data)
self.f.flush()
class FakeFrame:
"""The agent run as a local process over pipes, like an SSH exec channel."""
def __init__(self, home: Path, env: dict):
self.home, self.env = home, env
self.client = self
def ensure_agent(self):
return str(AGENT)
def exec_command(self, cmd, timeout=None):
proc = subprocess.Popen(["sh", "-c", cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=self.env)
chan = _Chan(proc)
return _Stream(proc.stdin, chan), _Stream(proc.stdout, chan), _Stream(proc.stderr, chan)
def agent(self, command, **args):
p = subprocess.run([sys.executable, str(AGENT), command], input=json.dumps(args), capture_output=True,
text=True, env=self.env)
reply = json.loads(p.stdout.strip().splitlines()[-1])
if not reply["ok"]:
raise RuntimeError(reply["error"])
return reply["result"]
@pytest.fixture
def frame(tmp_path, monkeypatch):
home = tmp_path / "frame-home"
anchor = home / "Applications/quest-frame" / PKG
(anchor / "lepton-app").mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": PKG, "appid": 7, "version": "2.0"}))
internal = anchor / "lepton-data/internal" / PKG
external = anchor / "lepton-data/external/Android/data" / PKG
for path, data in ((internal / "shared_prefs/p.xml", "<map/>"), (internal / "files/slot.sav", "level 3"),
(external / "files/save.dat", "coins 10"), (external / "cache/x", "cache")):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
podman = bin_dir / "podman"
podman.write_text('#!/bin/sh\nif [ "$1" = unshare ]; then shift; exec "$@"; fi\n'
'if [ "$1" = ps ]; then cat "$FAKE_PS" 2>/dev/null; fi\nexit 0\n')
podman.chmod(0o755)
env = dict(os.environ, HOME=str(home), PATH=f"{bin_dir}:{os.environ['PATH']}", FAKE_PS=str(tmp_path / "ps"))
env.pop("FRAMEPORT_SAVES_NS", None)
return FakeFrame(home, env), anchor, internal, external, tmp_path
def test_backup_restore_round_trip_through_the_agent(frame):
fr, anchor, internal, external, tmp = frame
b = saves.backup(fr, PKG)
assert b.path.parent == saves.backups_dir(PKG) and b.path.exists() and b.sidecar.exists()
assert b.files == 3 and b.version == "2.0" and b.bytes == len("<map/>level 3coins 10")
m = saves.read_manifest(b.path)
assert {f["path"] for f in m["files"]} == {"internal/shared_prefs/p.xml", "internal/files/slot.sav",
"external/files/save.dat"}
assert saves.list_backups(PKG)[0].path == b.path
(external / "files/save.dat").write_text("coins 0")
r = saves.restore(fr, PKG, b.path)
assert (external / "files/save.dat").read_text() == "coins 10"
assert Path(r["rollback"]) == anchor / "saves-before-restore.tar.gz"
assert saves.info(fr, PKG)["rollback"]["size"] > 0
saves.undo_restore(fr, PKG)
assert (external / "files/save.dat").read_text() == "coins 0"
def test_backup_refused_while_the_game_runs(frame):
fr, anchor, internal, external, tmp = frame
(tmp / "ps").write_text("lepton-steamlaunch-7\n")
with pytest.raises(saves.SavesError, match="running"):
saves.backup(fr, PKG)
assert not list(saves.backups_dir(PKG).glob("*.part")) and not saves.list_backups(PKG)
with pytest.raises(saves.SavesError, match="running"):
saves.restore(fr, PKG, _fake_backup(PKG))
def _fake_backup(pkg: str, created: float | None = None, name: str | None = None) -> Path:
import io
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
path = d / (name or time.strftime("%Y%m%d-%H%M%S.tar.gz"))
raw = json.dumps({"format": 1, "package": pkg, "created": created or time.time(), "files": []}).encode()
with tarfile.open(path, "w:gz") as tar:
ti = tarfile.TarInfo(saves.MANIFEST)
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
return path
def test_retention_keeps_the_newest(tmp_path):
for i in range(6):
_fake_backup(PKG, created=1000 + i, name=f"2026010{i}-120000.tar.gz")
assert len(saves.list_backups(PKG)) == 6 # sidecars rebuilt from the archives
assert all(p.with_suffix("").with_suffix(".json").exists() for p in saves.backups_dir(PKG).glob("*.tar.gz"))
gone = saves.prune(PKG, keep=4)
assert sorted(p.name for p in gone) == ["20260100-120000.tar.gz", "20260101-120000.tar.gz"]
left = saves.list_backups(PKG)
assert [b.created for b in left] == [1005, 1004, 1003, 1002]
assert not (saves.backups_dir(PKG) / "20260100-120000.json").exists()
def test_keep_setting(tmp_path):
assert saves.keep_count() == saves.KEEP_DEFAULT
library.set_setting("saves.keep", 3)
assert saves.keep_count() == 3
library.set_setting("saves.keep", "junk")
assert saves.keep_count() == saves.KEEP_DEFAULT
def test_restore_refuses_a_foreign_or_broken_file_before_touching_the_frame(tmp_path):
other = _fake_backup("com.other.game")
class NoFrame:
def ensure_agent(self):
raise AssertionError("the Frame must not be contacted")
with pytest.raises(saves.SavesError, match="com.other.game"):
saves.restore(NoFrame(), PKG, other)
junk = tmp_path / "junk.tar.gz"
junk.write_bytes(b"nope")
with pytest.raises(ValueError):
saves.restore(NoFrame(), PKG, junk)
def test_cli_backup_list_restore_undo(frame, monkeypatch):
from types import SimpleNamespace
from typer.testing import CliRunner
from frameport import cli
fr, anchor, internal, external, tmp = frame
target = SimpleNamespace(frame=fr, installed=lambda: [{"package": PKG}, {"package": "rift.x", "kind": "pcvr"}])
monkeypatch.setattr(cli, "_target", lambda frame=None, password=None, to="frame": target)
run = CliRunner().invoke
r = run(cli.app, ["saves", "backup", "--all"])
assert r.exit_code == 0, r.output
assert f"{PKG}: 3 file(s)" in r.output and "rift.x" not in r.output
r = run(cli.app, ["saves", "list", PKG])
assert r.exit_code == 0 and r.output.lstrip().startswith("1 ")
(external / "files/save.dat").write_text("lost")
r = run(cli.app, ["saves", "restore", PKG, "1", "--yes"])
assert r.exit_code == 0, r.output
assert (external / "files/save.dat").read_text() == "coins 10"
r = run(cli.app, ["saves", "undo", PKG])
assert r.exit_code == 0, r.output
assert (external / "files/save.dat").read_text() == "lost"
r = run(cli.app, ["saves", "restore", PKG, "7", "--yes"])
assert r.exit_code == 2 # no 7th backup
(tmp / "ps").write_text("lepton-steamlaunch-7\n")
r = run(cli.app, ["saves", "backup", PKG])
assert r.exit_code == 1 and "running" in r.output
def test_bad_package_names_never_make_paths():
with pytest.raises(ValueError):
saves.backups_dir("../../etc")
assert saves.has_backups("../x") is False
+323
View File
@@ -0,0 +1,323 @@
"""Save backups on the Frame (GitHub discussion #352): the agent's archive builder, the untrusted-archive checks on
restore, refusing while the game runs, and the rollback copy. podman unshare is switched off (files are ours here)."""
import importlib.util
import io
import json
import os
import tarfile
from pathlib import Path
import pytest
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
PKG = "com.example.game"
def load_agent(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("FRAMEPORT_SAVES_NS", raising=False)
spec = importlib.util.spec_from_file_location("frameport_agent_saves", AGENT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
monkeypatch.setattr(mod, "as_owner", lambda: [])
monkeypatch.setattr(mod, "container_running", lambda appid: False)
return mod
def write(path: Path, data, mode=0o660):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data if isinstance(data, bytes) else data.encode())
os.chmod(path, mode)
return path
@pytest.fixture
def game(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = Path(a.ANCHORS) / PKG
anchor.mkdir(parents=True)
(anchor / "lepton-app").mkdir()
(anchor / "deployment.json").write_text(json.dumps({"package": PKG, "appid": 123, "version": "1.2.3",
"title": "Example"}))
internal = anchor / "lepton-data/internal" / PKG
external = anchor / "lepton-data/external/Android/data" / PKG
write(internal / "shared_prefs/prefs.xml", "<map><int name='level' value='7'/></map>")
write(internal / "files/UnrealGame/G/G/Saved/SaveGames/slot1.sav", b"\x01" * 100)
write(internal / "files/UnrealGame/G/G/Saved/Logs/G.log", "log")
write(internal / "cache/tmp.bin", b"x" * 10)
write(internal / "code_cache/x", b"x")
write(internal / "databases/game.db", b"db")
write(external / "files/SaveData.es3", "save")
write(external / "files/fb_snap_0.ppm", b"P6")
write(external / "files/framebridge.conf", "scale=1")
write(external / "files/il2cpp/Metadata/global-metadata.dat", b"m" * 50)
write(external / "files/VulkanProgramBinaryCache/x.bin", b"v")
write(external / "files/Video/intro.mp4", b"vid")
write(external / "files/big.bundle", b"b" * 2000)
write(external / "cache/ga.sqlite3", b"g")
os.symlink("/etc", external / "files/escape")
return a, anchor, internal, external
def backup_bytes(a, max_file=1000):
out, err = io.BytesIO(), io.StringIO()
assert a.saves_backup_session({"package": PKG, "max_file": max_file}, out, err) == 0, err.getvalue()
reply = json.loads(err.getvalue().strip().splitlines()[-1])
assert reply["ok"]
return out.getvalue(), reply["result"]
def names(data: bytes) -> list[str]:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
return tar.getnames()
def manifest_of(data: bytes) -> dict:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
return json.loads(tar.extractfile("frameport-saves.json").read())
def test_backup_holds_saves_and_leaves_caches_out(game):
a, anchor, internal, external = game
data, summary = backup_bytes(a)
got = names(data)
assert got[-1] == "frameport-saves.json"
files = {n for n in got if not n.endswith(".json")}
assert "internal/shared_prefs/prefs.xml" in files
assert "internal/files/UnrealGame/G/G/Saved/SaveGames/slot1.sav" in files
assert "internal/databases/game.db" in files
assert "external/files/SaveData.es3" in files
for left_out in ("internal/cache/tmp.bin", "internal/code_cache/x",
"internal/files/UnrealGame/G/G/Saved/Logs/G.log",
"external/files/fb_snap_0.ppm", "external/files/framebridge.conf",
"external/files/il2cpp/Metadata/global-metadata.dat",
"external/files/VulkanProgramBinaryCache/x.bin", "external/files/Video/intro.mp4",
"external/files/big.bundle", "external/cache/ga.sqlite3", "external/files/escape"):
assert left_out not in files, left_out
m = manifest_of(data)
assert m["package"] == PKG and m["version"] == "1.2.3" and m["format"] == 1
by_path = {f["path"]: f for f in m["files"]}
assert by_path["internal/files/UnrealGame/G/G/Saved/SaveGames/slot1.sav"]["size"] == 100
assert len(by_path["external/files/SaveData.es3"]["sha256"]) == 64
assert by_path["internal/shared_prefs/prefs.xml"]["mode"] == 0o660
reasons = {s["path"]: s["reason"] for s in m["skipped"]}
assert reasons["external/files/big.bundle"] == "size" and reasons["external/files/escape"] == "link"
assert summary["files"] == len(m["files"])
assert [s["path"] for s in summary["skipped"]] == ["external/files/big.bundle"]
def test_backup_refused_while_the_game_runs(game, monkeypatch):
a, *_ = game
monkeypatch.setattr(a, "container_running", lambda appid: True)
out, err = io.BytesIO(), io.StringIO()
assert a.saves_backup_session({"package": PKG}, out, err) == 1
assert out.getvalue() == b"" and "running" in json.loads(err.getvalue())["error"]
def test_backup_and_restore_refused_while_the_launcher_holds_its_lock(game):
import fcntl
a, anchor, *_ = game
fd = os.open(anchor / ".launch.lock", os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
try:
err = io.StringIO()
assert a.saves_backup_session({"package": PKG}, io.BytesIO(), err) == 1
assert "running" in err.getvalue()
out = io.StringIO()
assert a.saves_restore_session({"package": PKG}, io.BytesIO(b""), out) == 1
assert "running" in out.getvalue()
finally:
os.close(fd)
def test_backup_refuses_pc_vr_and_unknown_games(game):
a, anchor, *_ = game
err = io.StringIO()
assert a.saves_backup_session({"package": "com.not.installed"}, io.BytesIO(), err) == 1
assert "not installed" in err.getvalue()
err = io.StringIO()
assert a.saves_backup_session({"package": "../etc"}, io.BytesIO(), err) == 1
def restore(a, data: bytes, **extra) -> dict:
out = io.StringIO()
a.saves_restore_session({"package": PKG, **extra}, io.BytesIO(data), out)
return json.loads(out.getvalue().strip().splitlines()[-1])
def test_restore_round_trip_keeps_modes_and_makes_a_rollback_copy(game):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
save = external / "files/SaveData.es3"
prefs = internal / "shared_prefs/prefs.xml"
os.chmod(prefs, 0o600) # the Frame's current file keeps its own mode
save.write_text("newer progress")
write(internal / "files/new_slot.sav", "made after the backup")
big = external / "files/big.bundle"
reply = restore(a, data)
assert reply["ok"], reply
r = reply["result"]
assert save.read_text() == "save"
assert not (internal / "files/new_slot.sav").exists() # replaced, not merged
assert big.exists() and (internal / "cache/tmp.bin").exists() # left-out files stay
assert (prefs.stat().st_mode & 0o777) == 0o600
assert r["restored"] >= 4 and r["removed"] == 1
rollback = anchor / "saves-before-restore.tar.gz"
assert Path(r["rollback"]) == rollback and rollback.exists()
assert "internal/files/new_slot.sav" in names(rollback.read_bytes())
assert not list(anchor.glob(".saves-restore-*")) # staging cleaned up
# undo: the rollback copy goes back, and the restored state becomes the new rollback copy
reply = restore(a, b"", rollback=True)
assert reply["ok"], reply
assert save.read_text() == "newer progress" and (internal / "files/new_slot.sav").exists()
assert "internal/files/new_slot.sav" not in names(rollback.read_bytes())
def test_restore_onto_a_fresh_install_creates_the_folders(game):
import shutil
a, anchor, internal, external = game
data, _ = backup_bytes(a)
shutil.rmtree(anchor / "lepton-data")
reply = restore(a, data)
assert reply["ok"], reply
assert (internal / "files/UnrealGame/G/G/Saved/SaveGames/slot1.sav").read_bytes() == b"\x01" * 100
assert (external / "files/SaveData.es3").read_text() == "save"
assert internal.is_dir() and not (external / "cache").exists()
def evil_archive(members: list[tuple[tarfile.TarInfo, bytes | None]], manifest: dict | None = None) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for ti, data in members:
tar.addfile(ti, io.BytesIO(data) if data is not None else None)
if manifest is not None:
raw = json.dumps(manifest).encode()
ti = tarfile.TarInfo("frameport-saves.json")
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
return buf.getvalue()
def reg(name: str, data: bytes) -> tuple[tarfile.TarInfo, bytes]:
ti = tarfile.TarInfo(name)
ti.size = len(data)
return ti, data
def good_manifest(*files: tuple[str, bytes]) -> dict:
import hashlib
return {"format": 1, "package": PKG, "files": [{"path": n, "size": len(d), "sha256": hashlib.sha256(d).hexdigest()}
for n, d in files]}
@pytest.mark.parametrize("name", ["../../../etc/passwd", "/etc/passwd", "internal/../../x", "other/file", "internal",
"internal\\..\\x"])
def test_restore_refuses_paths_outside_the_save_folders(game, name):
a, anchor, *_ = game
data = evil_archive([reg(name, b"x")], good_manifest((name, b"x")))
reply = restore(a, data)
assert not reply["ok"]
assert not (anchor / "saves-before-restore.tar.gz").exists() # nothing touched
@pytest.mark.parametrize("kind", [tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.CHRTYPE, tarfile.FIFOTYPE])
def test_restore_refuses_links_and_devices(game, kind):
a, *_ = game
ti = tarfile.TarInfo("internal/files/x")
ti.type, ti.linkname = kind, "/etc/passwd"
reply = restore(a, evil_archive([(ti, None)], good_manifest()))
assert not reply["ok"] and "link or special" in reply["error"]
def test_restore_refuses_writing_through_a_link_on_the_frame(game):
a, anchor, internal, external = game
os.symlink(str(anchor.parent), internal / "lib")
data = evil_archive([reg("internal/lib/evil.txt", b"x")], good_manifest(("internal/lib/evil.txt", b"x")))
reply = restore(a, data)
assert not reply["ok"] and "link" in reply["error"]
assert not (anchor.parent / "evil.txt").exists()
def test_restore_refuses_damaged_foreign_or_manifestless_archives(game):
a, *_ = game
good = ("internal/files/s.sav", b"save")
assert "no manifest" in restore(a, evil_archive([reg(*good)]))["error"]
bad_hash = good_manifest(good)
bad_hash["files"][0]["sha256"] = "0" * 64
assert "doesn't match" in restore(a, evil_archive([reg(*good)], bad_hash))["error"]
other = good_manifest(good)
other["package"] = "com.other.game"
assert "com.other.game" in restore(a, evil_archive([reg(*good)], other))["error"]
assert not restore(a, b"not a tar at all")["ok"]
whole, _ = backup_bytes(a)
assert not restore(a, whole[: len(whole) // 2])["ok"] # cut off mid-stream
def test_restore_refuses_oversize_members(game, monkeypatch):
a, *_ = game
monkeypatch.setattr(a, "SAVES_RESTORE_MAX_FILE", 3)
good = ("internal/files/s.sav", b"save")
reply = restore(a, evil_archive([reg(*good)], good_manifest(good)))
assert not reply["ok"] and "too big" in reply["error"]
def test_restore_refused_while_running_leaves_everything(game, monkeypatch):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
monkeypatch.setattr(a, "container_running", lambda appid: True)
reply = restore(a, data)
assert not reply["ok"] and "running" in reply["error"]
assert not (anchor / "saves-before-restore.tar.gz").exists()
def test_failed_swap_puts_the_old_saves_back(game, monkeypatch):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
save = external / "files/SaveData.es3"
save.write_text("current")
real = a.apply_staged_saves
calls = []
def flaky(roots, entries, manifest):
calls.append(1)
if len(calls) == 1:
real(roots, {k: e for k, e in entries.items() if e["kind"] == "dir" or "SaveData" in k[-1]}, manifest)
raise OSError("disk full")
return real(roots, entries, manifest)
monkeypatch.setattr(a, "apply_staged_saves", flaky)
reply = restore(a, data)
assert not reply["ok"] and "put back" in reply["error"]
assert save.read_text() == "current"
def test_saves_info_and_rollback_delete(game):
a, anchor, *_ = game
info = a.cmd_saves_info({"package": PKG, "max_file": 1000})
assert info["files"] >= 4 and info["rollback"] is None and info["running"] is False
assert [s["path"] for s in info["skipped"]] == ["external/files/big.bundle"]
data, _ = backup_bytes(a)
assert restore(a, data)["ok"]
assert a.cmd_saves_info({"package": PKG})["rollback"]["size"] > 0
assert a.cmd_saves_rollback_delete({"package": PKG}) == {"removed": True}
assert a.cmd_saves_info({"package": PKG})["rollback"] is None
def test_namespace_handoff_runs_the_agent_in_podman_unshare(game, monkeypatch):
a, *_ = game
seen = {}
class Done:
returncode = 0
def fake_run(cmd, env=None, **kw):
seen["cmd"], seen["ns"] = cmd, env.get("FRAMEPORT_SAVES_NS")
return Done()
monkeypatch.setattr(a, "as_owner", lambda: ["podman", "unshare"])
monkeypatch.setattr(a.subprocess, "run", fake_run)
assert a.saves_backup_session({"package": PKG}, io.BytesIO(), io.StringIO()) == 0
assert seen["cmd"][:2] == ["podman", "unshare"] and seen["cmd"][3:5] == [str(AGENT), "_saves_ns"]
assert seen["cmd"][5] == "backup" and json.loads(seen["cmd"][6]) == {"package": PKG}
assert seen["ns"] == "1"