mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 20:01:52 +02:00
Compare commits
5
Commits
gvr-detection
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3890676dcb | ||
|
|
d0dbbe32e5 | ||
|
|
6101314ce1 | ||
|
|
98ddfa7157 | ||
|
|
91bc6af2b7 |
No files matched your search
@@ -138,6 +138,7 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
uploads are interruptible (Cancel checked per MiB) and resumable (big files via SFTP `.part` append, small files
|
||||
streamed in tar batches; the agent counts files already in `incoming/`). Multi-select in the Library queues
|
||||
installs after asking every needed question up front. Failures end in one pop-up (Resume / Uninstall / log).
|
||||
- Library folders (GitHub discussion #351): `core/folders.py` (no Flet) owns setting `scan.roots` (remember/add/remove/status/rescan_roots; the origin-based fallback only while the setting was never set, so removing every folder doesn't guess them back), Settings → Library folders (`settings.library_folders`, status read via run_bg), CLI `frameport folders [--add/--remove/--json]` + `frameport rescan`. Scans relink moved games (`pipeline.ScanReport`: a library game found elsewhere whose own files are gone and the copy is the same build (Quest: source_sha256, else versionName; Rift: same program path + size from the fingerprint, matched by game id or, renamed, by program) → only paths change (`relink_quest`/`_relink_rift`), user fields/builds/installs stay; another version → Scan a folder… takes it via `replace_apk(found=)`/`replace_rift_game`, Rescan only reports it; the old copy still there → kept). `add_game` no longer overwrites a user recipe (refreshes `suggested` only). Linux apps aren't scanned. PC installs of a moved Rift game (Steam shortcut path) untested.
|
||||
- Other drives (GitHub #90, agent v63): anchors stay in ~/Applications/quest-frame, a game's files may live in
|
||||
`<mount>/FramePort/<pkg>` (`deployment.json` base). Agent `drives` (/proc/mounts, /run/media + Steam library
|
||||
drives; vfat/exfat/ntfs/read-only refused), prepare*/finalize_linux `dest` (unmounted = error, never a fallback;
|
||||
@@ -241,6 +242,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
@@ -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":
|
||||
|
||||
@@ -34,7 +34,11 @@ say "Letting FramePort log in"
|
||||
key=$(curl -fsS "$PC_URL/key?code=$PAIR_CODE")
|
||||
[[ "$key" == ssh-ed25519\ * ]] || { echo "Couldn't reach FramePort at $PC_URL. Is it still open?"; exit 1; }
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
|
||||
grep -qxF "$key" ~/.ssh/authorized_keys || echo "$key" >> ~/.ssh/authorized_keys
|
||||
# a file without a final newline would glue our key onto its last line, where sshd never matches it (GitHub #242)
|
||||
grep -qxF "$key" ~/.ssh/authorized_keys || {
|
||||
[ -n "$(tail -c1 ~/.ssh/authorized_keys)" ] && echo >> ~/.ssh/authorized_keys
|
||||
echo "$key" >> ~/.ssh/authorized_keys
|
||||
}
|
||||
|
||||
say "Setting up Lepton's containers"
|
||||
# rootless podman leaks one kernel keyring per container start; ~200 game launches would exhaust the quota
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package: org.smarttube.stable
|
||||
title: SmartTube
|
||||
status: issues
|
||||
notes: Android's home screen shows for a few seconds while the app starts (normal for flat Android apps; longest on the
|
||||
first start after an install). Froze once in the reporter's session.
|
||||
tested_version: '32.56'
|
||||
engine: Other
|
||||
xr: '?'
|
||||
as_is: true
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
verified:
|
||||
date: '2026-10-16'
|
||||
known_good_sha256: cc53f1aedac52ea0d4e77803acd580e32e7e5179adc96b55db7267b63a16c864
|
||||
app: 1.2.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 350
|
||||
source_hint: SmartTube_stable_arm64-v8a
|
||||
+32
@@ -26,6 +26,25 @@ Games/ ← scan this folder (Add games → Scan
|
||||
Not sure of the package name? It's in the OBB file names (`main.<version>.<package name>.obb`), and the game's page
|
||||
shows it under **Details**.
|
||||
|
||||
## I moved my games to another folder or drive. How do I update the library?
|
||||
|
||||
Scan the new folder (**Add games → Scan a folder…**, or **Settings → Library folders → Add folder…**). A game whose
|
||||
files are gone from their old place and that FramePort finds again in the new folder keeps its library entry: only
|
||||
where its files are changes. Its name, tags, artwork, your own recipe and what's installed on the Frame stay; you don't
|
||||
have to remove it from the library first. **Rescan folders** in the Library does the same for every library folder.
|
||||
|
||||
- **Removing a folder:** **Settings → Library folders** lists every folder FramePort scans, with how many library games
|
||||
come from it and whether it's there now (a folder on an unplugged drive shows as not found). The bin icon stops
|
||||
scanning a folder; its games stay in the library. `frameport folders [--add DIR] [--remove DIR]` does the same on the
|
||||
command line, `frameport rescan` rescans.
|
||||
- **Another version in the new folder** (a different APK or game build): **Scan a folder…** on that folder takes it, as
|
||||
**Update with a newer APK…** would (name, tags, art and your own recipe stay). **Rescan folders** leaves such games
|
||||
alone and says so; use the game's **Update with a newer APK…** / **…version…** to switch.
|
||||
- **Two copies** (the old folder is still there): the library keeps the copy it has. To use the other one, delete or
|
||||
rename the old copy and rescan.
|
||||
- PC VR games are recognised by their folder name, or a renamed folder by its program file. Linux apps aren't part of
|
||||
folder scans: add the moved app again with **Add a Linux app…** (it keeps its name, art and switches).
|
||||
|
||||
## Can I scan SideQuest backups or AXRB downloads directly?
|
||||
|
||||
Yes. Scan the folder as it is; FramePort finds the APK and its OBB files in both layouts:
|
||||
@@ -178,6 +197,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -180,6 +180,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Real VR Fishing | Quest | ⚠️ Works with issues | Seems to be almost 100% functional with meta permissions enabled. |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| SmartTube | Quest | ⚠️ Works with issues | Android's home screen shows for a few seconds while the app starts (normal for flat Android apps; longest on the first start after an install). |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
| The Light Brigade | Quest | ⚠️ Works with issues | Judder in weapons |
|
||||
|
||||
@@ -167,6 +167,7 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
|
||||
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
|
||||
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
|
||||
**Update on Frame**.
|
||||
- **Moved your games?** Scan the new folder (or **Rescan folders**): games found there whose old files are gone keep their library entry, name, artwork, tags and settings, and only their location changes. **Settings → Library folders** lists the folders FramePort scans; remove the old one there (the games stay). See the [FAQ](FAQ.md#i-moved-my-games-to-another-folder-or-drive-how-do-i-update-the-library).
|
||||
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
|
||||
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
|
||||
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
|
||||
@@ -220,6 +221,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
|
||||
@@ -258,6 +267,7 @@ option. The main ones:
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
|
||||
| `folders [--add <folder>] [--remove <folder>]` / `rescan` | list the library folders, add or remove one (its games stay); scan them again for new and moved games |
|
||||
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
|
||||
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
|
||||
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
|
||||
@@ -265,6 +275,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 |
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 22 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 23 |
|
||||
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
|
||||
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
|
||||
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
|
||||
@@ -645,7 +645,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
- `frame.hw_video_decode` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
|
||||
- **Catalog games (22):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 14 more.
|
||||
- **Catalog games (23):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 15 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -140,6 +141,7 @@ def main() -> int:
|
||||
("tools", lambda a: a.go("settings")),
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
|
||||
("settings-folders", lambda a: a.page.run_task(a.settings_view.show_section, "folders")),
|
||||
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
@@ -153,6 +155,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),
|
||||
|
||||
+174
-2
@@ -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")
|
||||
@@ -227,13 +230,78 @@ def tools_import_keys(dirs: list[Path] = typer.Argument(..., help="folders with
|
||||
# ------------------------------------------------------------------------------------------ library
|
||||
@app.command()
|
||||
def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs or PC VR game folders), or an APK")):
|
||||
"""Find games under PATH, analyze them and suggest patches."""
|
||||
"""Find games under PATH, analyze them and suggest patches. A folder is remembered for `frameport rescan`
|
||||
(`frameport folders` lists them). Games of the library found here after a move get their new place."""
|
||||
from .core import folders
|
||||
|
||||
if path.is_dir():
|
||||
folders.remember(path)
|
||||
_scan(path, only_new=False)
|
||||
|
||||
|
||||
def _scan(path: Path, only_new: bool) -> None:
|
||||
rep = printing_reporter(verbose=False)
|
||||
for g in pipeline.add_path(path, rep):
|
||||
report = pipeline.ScanReport()
|
||||
for g in pipeline.add_path(path, rep, only_new=only_new, report=report):
|
||||
r = g["recipe"]
|
||||
typer.echo(f"{g['package']:40} {g.get('title', '')[:34]:34} {r['status']:11} {r['source']}")
|
||||
for warning in pipeline.analysis_warnings(g):
|
||||
typer.echo(f" ! {warning}")
|
||||
labels = {"moved": "moved here (location updated)", "updated": "another version here: now used",
|
||||
"other_version": "another version here: left alone (frameport new-version <package> <path>)",
|
||||
"other_copy": "another copy here: the library keeps its own"}
|
||||
for kind, label in labels.items():
|
||||
for item in getattr(report, kind):
|
||||
typer.echo(f"{item['package']:40} {item['title'][:34]:34} {label}: {item['path']}")
|
||||
if report.moved:
|
||||
typer.echo(f"Updated the location of {len(report.moved)} game(s)")
|
||||
|
||||
|
||||
@app.command()
|
||||
def rescan():
|
||||
"""Scan the library folders again (`frameport folders`) for new games and for games that moved there."""
|
||||
from .core import folders
|
||||
|
||||
roots = folders.rescan_roots()
|
||||
if not roots:
|
||||
typer.echo("no folders to rescan yet: frameport scan <folder> or frameport folders --add <folder>")
|
||||
return
|
||||
for root in roots:
|
||||
typer.echo(f"== {root}")
|
||||
_scan(Path(root), only_new=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def folders(add: list[Path] = typer.Option([], "--add", help="a folder to scan from now on (repeatable)"),
|
||||
remove: list[Path] = typer.Option([], "--remove", help="stop scanning this folder; its games stay in "
|
||||
"the library (repeatable)"),
|
||||
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
|
||||
"""The library folders `frameport rescan` (and the GUI's Rescan folders) look in: status, add, remove."""
|
||||
import dataclasses
|
||||
import json
|
||||
|
||||
from .core import folders as lib_folders
|
||||
|
||||
for p in add:
|
||||
try:
|
||||
lib_folders.add(p)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from None
|
||||
typer.echo(f"added {p}")
|
||||
for p in remove:
|
||||
if lib_folders.remove(p):
|
||||
typer.echo(f"removed {p} (its games stay in the library)")
|
||||
else:
|
||||
typer.echo(f"{p} isn't a library folder", err=True)
|
||||
raise typer.Exit(1)
|
||||
status = lib_folders.status()
|
||||
if as_json:
|
||||
typer.echo(json.dumps([dataclasses.asdict(s) for s in status], indent=1))
|
||||
return
|
||||
if not status:
|
||||
typer.echo("no library folders yet (frameport scan <folder> or frameport folders --add <folder>)")
|
||||
for s in status:
|
||||
typer.echo(f"{'ok' if s.exists else 'MISSING':8} {s.games:4} game(s) {s.path}")
|
||||
|
||||
|
||||
@app.command("catalog-update")
|
||||
@@ -709,6 +777,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)."""
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Library folders (GitHub discussion #351): the folders "Rescan folders" scans again (library setting `scan.roots`).
|
||||
|
||||
Every "Scan a folder…" remembers its folder; Settings → Library folders and `frameport folders` list them with their
|
||||
status and let the user add or remove one. Removing a folder only stops it being scanned: the games already in the
|
||||
library stay. No Flet here (the CLI uses it too)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import library
|
||||
|
||||
SETTING = "scan.roots"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderStatus:
|
||||
path: str
|
||||
exists: bool
|
||||
games: int # library games whose files (APK, data or game folder) are in it
|
||||
|
||||
|
||||
def roots() -> list[str]:
|
||||
"""The remembered folders, in the order they were added (missing ones too)."""
|
||||
return [str(r) for r in (library.setting(SETTING) or [])]
|
||||
|
||||
|
||||
def _same(a: str, b: str) -> bool:
|
||||
if a == b:
|
||||
return True
|
||||
try:
|
||||
return Path(a).resolve() == Path(b).resolve()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def remember(path: str | Path) -> list[str]:
|
||||
"""Add a folder to the list (a scanned one): moved to the end if it's there already. Returns the new list."""
|
||||
path = str(path)
|
||||
return library.update_setting(SETTING, lambda cur: [r for r in (cur or []) if not _same(str(r), path)] + [path],
|
||||
[])
|
||||
|
||||
|
||||
def add(path: str | Path) -> list[str]:
|
||||
"""Settings/CLI "Add folder…": like remember, but only an existing folder."""
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_dir():
|
||||
raise ValueError(f"{p} isn't a folder")
|
||||
if any(_same(r, str(p)) for r in roots()):
|
||||
return roots()
|
||||
return remember(p)
|
||||
|
||||
|
||||
def remove(path: str | Path) -> bool:
|
||||
"""Stop scanning a folder (its games stay in the library). False if it wasn't in the list."""
|
||||
path = str(path)
|
||||
found = {"hit": False}
|
||||
|
||||
def drop(cur):
|
||||
keep = [r for r in (cur or []) if not _same(str(r), path)]
|
||||
found["hit"] = len(keep) != len(cur or [])
|
||||
return keep
|
||||
library.update_setting(SETTING, drop, [])
|
||||
return found["hit"]
|
||||
|
||||
|
||||
def game_paths(entry: dict) -> list[Path]:
|
||||
"""Where a library game's files are (APK, data folder, game folder)."""
|
||||
out = []
|
||||
for key in ("apk", "data_dir", "game_dir"):
|
||||
if entry.get(key):
|
||||
out.append(Path(entry[key]))
|
||||
return out
|
||||
|
||||
|
||||
def _inside(p: Path, folder: Path) -> bool:
|
||||
try:
|
||||
return p == folder or p.is_relative_to(folder)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def games_in(folder: str | Path, games: list[dict] | None = None) -> list[dict]:
|
||||
"""Library games whose files are in the folder (by path, no disk access)."""
|
||||
folder = Path(folder)
|
||||
games = library.games() if games is None else games
|
||||
return [g for g in games if g.get("kind") != "linux" and any(_inside(p, folder) for p in game_paths(g))]
|
||||
|
||||
|
||||
def status() -> list[FolderStatus]:
|
||||
"""Each remembered folder: whether it's there now and how many library games come from it. Touches the disk
|
||||
(exists): call it off the UI thread."""
|
||||
games = library.games()
|
||||
out = []
|
||||
for r in roots():
|
||||
try:
|
||||
exists = Path(r).is_dir()
|
||||
except OSError:
|
||||
exists = False
|
||||
out.append(FolderStatus(r, exists, len(games_in(r, games))))
|
||||
return out
|
||||
|
||||
|
||||
def rescan_roots() -> list[str]:
|
||||
"""Folders "Rescan folders" scans: the remembered ones that exist now. Before anything was remembered (libraries
|
||||
from older versions), the folders the library's games came from (one level up from each game folder). A folder
|
||||
inside another one is scanned with it."""
|
||||
saved = library.setting(SETTING)
|
||||
if saved is not None:
|
||||
found = [r for r in saved if Path(r).exists()]
|
||||
else:
|
||||
seen = set()
|
||||
for g in library.games():
|
||||
if g.get("kind") == "linux": # added one by one (its origin may be e.g. Downloads)
|
||||
continue
|
||||
origin = g.get("origin") or (str(Path(g["apk"]).parent) if g.get("apk") else None)
|
||||
if origin and Path(origin).parent.exists():
|
||||
seen.add(str(Path(origin).parent))
|
||||
found = sorted(seen)
|
||||
return [r for r in found if not any(o != r and Path(r).is_relative_to(o) for o in found)]
|
||||
@@ -358,6 +358,15 @@ def save_target(target: FrameTarget) -> None:
|
||||
FAST_LINKS = {"usb0": "USB cable", "wlanap": "the Frame's own Wi-Fi hotspot"}
|
||||
|
||||
|
||||
def install_key_command(pub: str) -> str:
|
||||
"""Shell command that adds `pub` to ~/.ssh/authorized_keys once. A file without a final newline would glue the key
|
||||
onto its last line, where sshd never matches it (GitHub #242: OpenSSH uploads refused FramePort's key)."""
|
||||
q, f = sh_quote(pub), "~/.ssh/authorized_keys"
|
||||
return (f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch {f} && "
|
||||
f"{{ grep -qxF {q} {f} || {{ [ -n \"$(tail -c1 {f})\" ] && echo >> {f}; echo {q} >> {f}; }}; }}"
|
||||
f" && chmod 600 {f}")
|
||||
|
||||
|
||||
def bundled_agent_version() -> int | None:
|
||||
"""AGENT_VERSION of the agent this app ships (it's uploaded to the Frame whenever it differs)."""
|
||||
try:
|
||||
@@ -675,10 +684,7 @@ class Frame:
|
||||
|
||||
def install_key(self) -> None:
|
||||
"""After a password login: add FramePort's public key to authorized_keys."""
|
||||
pub = app_public_key()
|
||||
self.run("mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && "
|
||||
f"grep -qxF {sh_quote(pub)} ~/.ssh/authorized_keys || echo {sh_quote(pub)} >> ~/.ssh/authorized_keys"
|
||||
" && chmod 600 ~/.ssh/authorized_keys")
|
||||
self.run(install_key_command(app_public_key()))
|
||||
|
||||
# ------------------------------------------------------------------ agent
|
||||
def ensure_agent(self) -> str:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
],
|
||||
" (connect the Frame first)": "",
|
||||
" (list cut off)": "",
|
||||
" (newest)": "",
|
||||
" (update: {latest})": "",
|
||||
" ({len} can't be installed there)": "",
|
||||
" + Revive": "",
|
||||
@@ -101,6 +102,7 @@
|
||||
"Add a tag": "",
|
||||
"Add a Windows program…": "",
|
||||
"Add an APK…": "",
|
||||
"Add folder…": "",
|
||||
"Add from a link…": "",
|
||||
"Add games": "",
|
||||
"Add this tag": "",
|
||||
@@ -193,7 +195,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).": "",
|
||||
@@ -237,12 +249,15 @@
|
||||
"Check now": "",
|
||||
"Check program": "",
|
||||
"Check that the Frame and this PC are on the same network.": "",
|
||||
"Checking folders…": "",
|
||||
"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 +331,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}": "",
|
||||
@@ -451,6 +467,7 @@
|
||||
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and the runtime clock's offset from the monotonic clock. No effect on the game.": "",
|
||||
"Every key goes through": "",
|
||||
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays black; this shows what the game itself draws. OpenGL ES games only.": "",
|
||||
"Every Scan a folder… adds its folder here. Rescan folders looks in them for new games, and for games you moved: a game whose files are gone from their old place is pointed at the same copy in its new place. Another version found there is only taken by Scan a folder…; Rescan leaves it for you to choose (Update with a newer version… on the game's page). Removing a folder only stops it being scanned: its games stay in the library.": "",
|
||||
"Every shot you take in the headset": "",
|
||||
"Every uploaded file is present with the right size.": "",
|
||||
"Every {n}": "",
|
||||
@@ -851,6 +868,7 @@
|
||||
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
|
||||
"Letters, Enter, Backspace, arrows, Esc and shortcuts reach the headset.": "",
|
||||
"Library": "",
|
||||
"Library folders": "",
|
||||
"Link": "",
|
||||
"Linux": "",
|
||||
"Linux app": "",
|
||||
@@ -952,11 +970,13 @@
|
||||
"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": "",
|
||||
"No folder at that path": "",
|
||||
"No folders to rescan yet — add games with Scan a folder…": "",
|
||||
"No folders yet. Add one here, or use Scan a folder… in the Library.": "",
|
||||
"No Frame connected: the game goes to your library now and installs when the Frame connects.": "",
|
||||
"No FramePort game is running. Start one, or switch the filter to see everything.": "",
|
||||
"No Frames found. New Frame? Use Start setup above. Otherwise turn on Developer Mode and keep the Frame on this network.": "",
|
||||
@@ -964,6 +984,7 @@
|
||||
"No keyboard? Open {page} in Chromium on the Frame, copy the line and paste it into Konsole. This PC shows there as \"{words}\".": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
"No new games in that folder": "",
|
||||
"No new games in {name}": "",
|
||||
"No other Frames remembered yet.": "",
|
||||
"No process matches.": "",
|
||||
@@ -979,6 +1000,7 @@
|
||||
"Not connected: {error}": "",
|
||||
"Not enough space on the Frame": "",
|
||||
"Not enough space on the Frame ({detail}). Uninstall games you don't play or free up space on the Steam Frame page.": "",
|
||||
"Not found (moved, deleted or on a drive that isn't connected)": "",
|
||||
"Not from a FramePort game": "",
|
||||
"Not installed": "",
|
||||
"Not installed yet (needs Developer Mode)": "",
|
||||
@@ -1021,6 +1043,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 +1062,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 +1186,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": "",
|
||||
@@ -1212,6 +1238,7 @@
|
||||
"Remembered": "",
|
||||
"Remove": "",
|
||||
"Remove converted copies now": "",
|
||||
"Remove folder": "",
|
||||
"Remove from library…": "",
|
||||
"Remove localized app names": "",
|
||||
"Remove tag": "",
|
||||
@@ -1254,13 +1281,19 @@
|
||||
"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}": "",
|
||||
"Report a problem…": "",
|
||||
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. Set 0 for games that require hand tracking (for example Silhouette).": "",
|
||||
"Rescan folders": "",
|
||||
"Rescanning {n} folder for new games": [
|
||||
"Rescan folders in the Library looks in these folders for new games and for games that moved there.": "",
|
||||
"Rescan folders won't look in {path} any more. The {n} game from it stays in your library (nothing is deleted).": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Rescanning {n} folder for new and moved games": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
@@ -1274,6 +1307,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 +1343,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 +1351,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.": "",
|
||||
@@ -1322,7 +1361,7 @@
|
||||
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
|
||||
"Scan a folder…": "",
|
||||
"Scan {name}": "",
|
||||
"Scans your game folders again and adds new games. Games already in the library keep their recipe, tags and art.": "",
|
||||
"Scans your game folders again and adds new games. A game you moved into one of them gets its new place; games already in the library keep their name, recipe, tags and art. Settings → Library folders lists the folders.": "",
|
||||
"Screenshot copied. Paste it anywhere.": "",
|
||||
"Screenshot taken": "",
|
||||
"Screenshots": "",
|
||||
@@ -1456,6 +1495,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": "",
|
||||
@@ -1472,6 +1512,8 @@
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
"Stop": "",
|
||||
"Stop objects popping in and out": "",
|
||||
"Stop scanning this folder": "",
|
||||
"Stop scanning this folder?": "",
|
||||
"Stop using this key (the file stays)": "",
|
||||
"Stopped: {why}": "",
|
||||
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
|
||||
@@ -1562,6 +1604,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 +1702,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?": "",
|
||||
@@ -1724,6 +1771,10 @@
|
||||
"Update tools": "",
|
||||
"Update with a newer APK…": "",
|
||||
"Update with a newer version…": "",
|
||||
"Updated the location of {n} game": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\", one after another.": "",
|
||||
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
|
||||
@@ -1762,6 +1813,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.": "",
|
||||
@@ -1919,6 +1971,22 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game has another version there (left alone: Update with a newer version… on its page)": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game in the library": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game is already in the library from another folder": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game now uses the other version found there": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game ran into a problem": [
|
||||
"",
|
||||
""
|
||||
@@ -1935,6 +2003,10 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} large file left out": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} line": [
|
||||
"",
|
||||
""
|
||||
@@ -1968,6 +2040,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 +2050,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 +2061,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": "",
|
||||
|
||||
+221
-18
@@ -28,15 +28,115 @@ from .sources import quest_dump, rift_dump
|
||||
from .targets.base import PC_LABEL, Target
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ScanReport:
|
||||
"""What a scan did with games already in the library that it found at another place (GitHub discussion #351).
|
||||
Each item: {"package", "title", "path": the copy found, "old": where the library had it}.
|
||||
moved: the library's copy is gone and the found one is the same build: the entry now points at it (title, tags,
|
||||
art, recipe, builds and installs stay). updated: the library's copy is gone and the found one is another version:
|
||||
taken like "Update with a newer APK…" (only by Scan a folder…, which the user pointed at that folder).
|
||||
other_version: the same on Rescan folders: left alone, the user decides. other_copy: the library's copy is still
|
||||
there: it stays."""
|
||||
moved: list[dict] = dataclasses.field(default_factory=list)
|
||||
updated: list[dict] = dataclasses.field(default_factory=list)
|
||||
other_version: list[dict] = dataclasses.field(default_factory=list)
|
||||
other_copy: list[dict] = dataclasses.field(default_factory=list)
|
||||
|
||||
def handled(self) -> set[str]:
|
||||
return {i["package"] for i in self.moved + self.updated + self.other_version + self.other_copy}
|
||||
|
||||
def note(self, kind: str, entry: dict, path, reporter: Reporter | None = None) -> None:
|
||||
item = {"package": entry["package"], "title": entry.get("title") or entry["package"], "path": str(path),
|
||||
"old": entry.get("apk") or entry.get("game_dir")}
|
||||
getattr(self, kind).append(item)
|
||||
if reporter:
|
||||
reporter.log({
|
||||
"moved": "{title}: moved, now uses {path}",
|
||||
"updated": "{title}: another version found at {path}; the library now uses it (title, tags, art and "
|
||||
"your own recipe stay)",
|
||||
"other_version": "{title}: another version found at {path}; left alone (to switch to it: \"Update "
|
||||
"with a newer APK…\" or \"…version…\" on the game's page, or Scan a folder… on "
|
||||
"that folder)",
|
||||
"other_copy": "{title}: another copy at {path} is left out (the library keeps {old})",
|
||||
}[kind].format(**item))
|
||||
|
||||
|
||||
def _same_path(a, b) -> bool:
|
||||
try:
|
||||
return Path(a).resolve() == Path(b).resolve()
|
||||
except OSError:
|
||||
return str(a) == str(b)
|
||||
|
||||
|
||||
def source_present(entry: dict) -> bool:
|
||||
"""The game's own files are where the library says (Quest/Android: APK + data; PC VR: the game folder)."""
|
||||
try:
|
||||
if is_rift(entry) or is_linux(entry):
|
||||
return bool(entry.get("game_dir")) and Path(entry["game_dir"]).is_dir()
|
||||
check_source(entry)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - missing, or on a drive that isn't connected
|
||||
return False
|
||||
|
||||
|
||||
def _missing_note(entry: dict, reporter: Reporter | None) -> None:
|
||||
if reporter:
|
||||
reporter.log(f"{entry.get('title') or entry['package']}: its files aren't at "
|
||||
f"{entry.get('apk') or entry.get('game_dir')} any more (moved, deleted, or on a drive that "
|
||||
"isn't connected)")
|
||||
|
||||
|
||||
def same_quest_build(entry: dict, apk: Path) -> bool:
|
||||
"""The APK is the build the library entry was made from: the same SHA-256 (recorded when it was added), else the
|
||||
same versionName."""
|
||||
if entry.get("source_sha256"):
|
||||
try:
|
||||
return builder.sha256(Path(apk)) == entry["source_sha256"]
|
||||
except OSError:
|
||||
return False
|
||||
ver = (entry.get("analysis") or {}).get("version")
|
||||
return bool(ver) and quest_dump.version_name(Path(apk)) == ver
|
||||
|
||||
|
||||
def relink_quest(package: str, src: SourceGame) -> dict | None:
|
||||
"""Point a Quest/Android entry at the same build in another place: only where its files are changes."""
|
||||
from .core.models import data_manifest
|
||||
|
||||
manifest = data_manifest(src.data_dir, src.data_files)
|
||||
fields = dict(name=src.name, apk=str(src.apk), data_dir=str(src.data_dir) if src.data_dir else None,
|
||||
data_files=src.data_files, data_bytes=sum(manifest.values()), data_manifest=manifest,
|
||||
origin=str(src.origin) if src.origin else None)
|
||||
return library.update_game(package, lambda g: g.update(fields))
|
||||
|
||||
|
||||
def _known_quest(src: SourceGame, old: dict, reporter: Reporter | None, allow_update: bool,
|
||||
report: ScanReport) -> None:
|
||||
"""A Quest/Android game of the library found at another place than the library's copy (see ScanReport)."""
|
||||
if source_present(old):
|
||||
report.note("other_copy", old, src.apk, reporter)
|
||||
return
|
||||
_missing_note(old, reporter)
|
||||
if same_quest_build(old, src.apk):
|
||||
relink_quest(old["package"], src)
|
||||
report.note("moved", old, src.apk, reporter)
|
||||
elif allow_update:
|
||||
replace_apk(old["package"], src.apk, reporter, found=src)
|
||||
report.note("updated", old, src.apk, reporter)
|
||||
else:
|
||||
report.note("other_version", old, src.apk, reporter)
|
||||
|
||||
|
||||
def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_rift: bool = False,
|
||||
art: bool = False, only_new: bool = False) -> list[dict]:
|
||||
art: bool = False, only_new: bool = False, report: ScanReport | None = None) -> list[dict]:
|
||||
"""Scan a file/folder (recursively), analyze every game found (Quest APKs and Rift game folders) and store it with
|
||||
a suggested recipe. on_added(entry) is called as each game lands in the library (the GUI streams cards in).
|
||||
force_rift: `path` is one Rift game folder (added even if no VR runtime is detected). art: fetch artwork too.
|
||||
only_new: skip Quest APKs already in the library (a rescan for games added since; Rift folders already skip
|
||||
unchanged games)."""
|
||||
unchanged games). A game of the library found at another place (moved, a second copy, another version) is put in
|
||||
`report` (ScanReport), not in the returned list."""
|
||||
path = Path(path)
|
||||
added = []
|
||||
report = report if report is not None else ScanReport()
|
||||
known_apks = {str(Path(g["apk"]).resolve()) for g in library.games() if g.get("apk")} if only_new else set()
|
||||
# entries made from AXRB's PC-patched copies (no OBBs; older FramePort scanned them) get the original download
|
||||
known_packages = {g["package"] for g in library.games()
|
||||
@@ -45,6 +145,8 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
def done(entry):
|
||||
if only_new and entry["package"] in known_packages: # already in the library (e.g. an unchanged Rift folder)
|
||||
return
|
||||
if entry["package"] in report.handled(): # moved, another copy or version: reported, not "added"
|
||||
return
|
||||
added.append(entry)
|
||||
if on_added:
|
||||
on_added(entry)
|
||||
@@ -54,12 +156,21 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
reporter.check_cancel()
|
||||
if only_new and str(Path(src.apk).resolve()) in known_apks:
|
||||
continue
|
||||
if only_new: # another copy of a game already in the library (e.g. a patched build): leave it alone
|
||||
try:
|
||||
pkg = quest_dump._package_of(src.apk)
|
||||
except Exception: # noqa: BLE001
|
||||
pkg = None
|
||||
old = library.game(pkg) if pkg else None
|
||||
if old and old.get("apk") and not is_rift(old) and not is_linux(old) \
|
||||
and not _same_path(old["apk"], src.apk) and not quest_dump._axrb_patched(Path(old["apk"])):
|
||||
try:
|
||||
if quest_dump._package_of(src.apk) in known_packages:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_known_quest(src, old, reporter, not only_new, report)
|
||||
except Exception as exc: # noqa: BLE001 - keep scanning
|
||||
if reporter:
|
||||
reporter.log(f"skipped {src.apk.name}: {exc}")
|
||||
continue
|
||||
if only_new and pkg in known_packages: # (e.g. an entry without an APK)
|
||||
continue
|
||||
try:
|
||||
entry = add_game(src, reporter)
|
||||
if art:
|
||||
@@ -87,7 +198,8 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
reporter.check_cancel()
|
||||
reporter.progress(i / max(len(folders), 1), f"{i + 1} / {len(folders)} · {folder.name}")
|
||||
try:
|
||||
entry = add_rift_game(folder, reporter, tree=trees.get(folder), force=force_rift, art=art)
|
||||
entry = add_rift_game(folder, reporter, tree=trees.get(folder), force=force_rift, art=art,
|
||||
report=report, allow_update=not only_new)
|
||||
if entry and art and not entry.get("details"):
|
||||
fetch_details(entry["package"], reporter)
|
||||
entry = library.game(entry["package"])
|
||||
@@ -96,6 +208,8 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
except Exception as exc:
|
||||
if reporter:
|
||||
reporter.log(f"skipped {folder.name}: {exc}")
|
||||
if reporter and report.moved:
|
||||
reporter.log(f"Updated the location of {len(report.moved)} game(s)")
|
||||
return added
|
||||
|
||||
|
||||
@@ -136,12 +250,70 @@ def _existing_rift(folder: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _rift_program(entry: dict) -> tuple[str, int] | None:
|
||||
"""(program path in the game folder, its size) from a PC VR entry's fingerprint."""
|
||||
fp = (((entry.get("analysis") or {}).get("extra") or {}).get("fingerprint")) or ""
|
||||
parts = fp.rsplit(":", 3)
|
||||
if len(parts) == 4 and parts[0] and parts[1].isdigit():
|
||||
return parts[0], int(parts[1])
|
||||
return None
|
||||
|
||||
|
||||
def _file_size(path: Path) -> int | None:
|
||||
try:
|
||||
return path.stat().st_size if path.is_file() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _same_rift_build(entry: dict, folder: Path) -> bool:
|
||||
"""The folder holds the build the entry was made from: its program at the same place with the same size."""
|
||||
prog = _rift_program(entry)
|
||||
if prog:
|
||||
return _file_size(folder / prog[0]) == prog[1]
|
||||
return bool(entry.get("exe")) and _file_size(folder / entry["exe"]) is not None
|
||||
|
||||
|
||||
def _moved_rift(folder: Path, tree) -> dict | None:
|
||||
"""The library's PC VR entry this folder is another copy of (it was found elsewhere before): the same game id,
|
||||
else an entry whose folder is gone and whose program (path and size) is in this folder."""
|
||||
from .analysis import rift
|
||||
|
||||
rifts = [g for g in library.games() if g.get("kind") == "rift" and g.get("game_dir")
|
||||
and not _same_path(g["game_dir"], folder)]
|
||||
same = library.game(rift.game_id(folder, getattr(tree, "manifest", None)))
|
||||
if same is not None and any(g["package"] == same["package"] for g in rifts):
|
||||
return same
|
||||
gone = [g for g in rifts if (p := _rift_program(g)) and _file_size(folder / p[0]) == p[1]
|
||||
and not source_present(g)]
|
||||
return gone[0] if len(gone) == 1 else None
|
||||
|
||||
|
||||
def _relink_rift(entry: dict, folder: Path) -> dict | None:
|
||||
"""Point a PC VR entry at the same build in another folder: only where its files are changes."""
|
||||
from .analysis import rift
|
||||
|
||||
new_fp = rift.fingerprint(folder, entry.get("exe") or "")
|
||||
|
||||
def change(g: dict) -> None:
|
||||
g.update(game_dir=str(folder), origin=str(folder.parent), name=folder.name)
|
||||
extra = (g.get("analysis") or {}).setdefault("extra", {}) if isinstance(g.get("analysis"), dict) else {}
|
||||
extra["folder"] = str(folder)
|
||||
old_fp = extra.get("fingerprint") or ""
|
||||
# (copies may get a new date; a fingerprint of an older analysis stays so the next scan analyzes it again)
|
||||
if new_fp and old_fp.rsplit(":", 1)[-1] == new_fp.rsplit(":", 1)[-1]:
|
||||
extra["fingerprint"] = new_fp
|
||||
return library.update_game(entry["package"], change)
|
||||
|
||||
|
||||
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
|
||||
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
|
||||
force: bool = False, art: bool = False, replace: dict | None = None,
|
||||
report: ScanReport | None = None, allow_update: bool = True) -> dict | None:
|
||||
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
|
||||
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
|
||||
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
|
||||
name."""
|
||||
name. A game of the library whose folder moved here gets this folder (report: ScanReport; allow_update: another
|
||||
version of it may replace it, see ScanReport)."""
|
||||
from .analysis import rift
|
||||
from .recommend import catalog
|
||||
|
||||
@@ -152,6 +324,27 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
|
||||
return None
|
||||
old = replace or _existing_rift(folder)
|
||||
if old is None and exe is None: # (set_exe passes the entry's own folder)
|
||||
tree = tree or rift.walk(folder)
|
||||
other = _moved_rift(folder, tree)
|
||||
if other is not None:
|
||||
report = report if report is not None else ScanReport()
|
||||
if source_present(other):
|
||||
report.note("other_copy", other, folder, reporter)
|
||||
return None
|
||||
_missing_note(other, reporter)
|
||||
if not _same_rift_build(other, folder):
|
||||
if not allow_update:
|
||||
report.note("other_version", other, folder, reporter)
|
||||
return None
|
||||
replace_rift_game(other["package"], folder, reporter)
|
||||
report.note("updated", other, folder, reporter)
|
||||
return library.game(other["package"])
|
||||
_relink_rift(other, folder)
|
||||
report.note("moved", other, folder, reporter)
|
||||
if art and not _has_art(other["package"]):
|
||||
_rift_art(library.game(other["package"]), reporter)
|
||||
return library.game(other["package"])
|
||||
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
|
||||
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
|
||||
if replace is None:
|
||||
@@ -638,13 +831,16 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
old = library.game(a.package) or {}
|
||||
# a name the user chose (Rename…, an install link) stays when the game is scanned again
|
||||
names = {"auto_title": recipe.title or a.label} if old.get("title_locked") else {"title": recipe.title or a.label}
|
||||
# a recipe the user edited stays when the game is scanned again (only the suggestion is refreshed, like Analyze
|
||||
# again); a derived one follows the new analysis
|
||||
own = {} if (old.get("recipe") or {}).get("source") == "user" else \
|
||||
{"recipe": library.recipe_to_dict(recipe), "status": recipe.status}
|
||||
return library.upsert_game(
|
||||
a.package, **names, name=src.name, apk=str(src.apk),
|
||||
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files,
|
||||
data_bytes=sum(manifest.values()), data_manifest=manifest, source_sha256=builder.sha256(src.apk),
|
||||
origin=str(src.origin),
|
||||
analysis=a.to_dict(), recipe=library.recipe_to_dict(recipe), suggested=library.recipe_to_dict(recipe),
|
||||
status=recipe.status,
|
||||
analysis=a.to_dict(), suggested=library.recipe_to_dict(recipe), **own,
|
||||
)
|
||||
|
||||
|
||||
@@ -661,16 +857,17 @@ class WrongPackage(ValueError):
|
||||
"""The picked APK is another app than the game it should update."""
|
||||
|
||||
|
||||
def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> dict:
|
||||
def replace_apk(package: str, apk: Path, reporter: Reporter | None = None, found: SourceGame | None = None) -> dict:
|
||||
"""A newer APK for a Quest/Android game already in the library (GitHub #161): the entry points at it from now on.
|
||||
Its title, art, tags and the user's own recipe stay (a suggested recipe follows the new analysis, like Analyze
|
||||
again). The game data stays where it was unless the new APK's folder brings its own. Returns {"entry", "older":
|
||||
the new versionCode is lower than the old one's, "old_version", "new_version"}."""
|
||||
again). The game data stays where it was unless the new APK's folder brings its own. found: the game as a scan
|
||||
found it (its data folder). Returns {"entry", "older": the new versionCode is lower than the old one's,
|
||||
"old_version", "new_version"}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or is_rift(entry) or is_linux(entry):
|
||||
raise ValueError("only Quest/Android games can get a newer APK")
|
||||
apk = Path(apk)
|
||||
src = quest_dump.from_path(apk)
|
||||
src = found or quest_dump.from_path(apk)
|
||||
new_pkg, new_vc = quest_dump._apk_ids(apk)
|
||||
if src is None or new_pkg is None:
|
||||
raise ValueError(f"{apk.name} isn't a readable APK")
|
||||
@@ -685,8 +882,14 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
|
||||
a = analyze(apk, data_bytes=src.data_bytes())
|
||||
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
|
||||
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
|
||||
library.upsert_game(package, apk=str(apk), data_dir=str(src.data_dir) if src.data_dir else None,
|
||||
data_files=src.data_files, data_bytes=src.data_bytes())
|
||||
fields = dict(apk=str(apk), data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files,
|
||||
data_bytes=src.data_bytes(), source_sha256=builder.sha256(apk))
|
||||
if found is not None: # found by a scan: where it was found
|
||||
from .core.models import data_manifest
|
||||
|
||||
fields.update(name=src.name, origin=str(src.origin) if src.origin else entry.get("origin"),
|
||||
data_manifest=data_manifest(src.data_dir, src.data_files))
|
||||
library.upsert_game(package, **fields)
|
||||
stored = _store_analysis(package, a)
|
||||
return {"entry": stored, "older": bool(old_vc and new_vc and new_vc < old_vc), "old_version": old_vc,
|
||||
"new_version": new_vc}
|
||||
|
||||
@@ -45,6 +45,16 @@ def _apk_ids(apk: Path) -> tuple[str | None, int | None]:
|
||||
return a.package, vc
|
||||
|
||||
|
||||
def version_name(apk: Path) -> str | None:
|
||||
"""The APK's versionName (None if it can't be read)."""
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
return APK(str(apk)).version_name or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _package_of(apk: Path) -> str | None:
|
||||
return _apk_ids(apk)[0]
|
||||
|
||||
|
||||
+86
-26
@@ -96,6 +96,24 @@ def reduce_motion_setting() -> bool:
|
||||
return bool(library.setting(REDUCE_MOTION, False))
|
||||
|
||||
|
||||
def scan_report_lines(report: pipeline.ScanReport) -> list[str]:
|
||||
"""A scan's summary of games already in the library that it found elsewhere (pipeline.ScanReport)."""
|
||||
out = []
|
||||
if report.moved:
|
||||
out.append(tr_n("Updated the location of {n} game", "Updated the location of {n} games", len(report.moved)))
|
||||
if report.updated:
|
||||
out.append(tr_n("{n} game now uses the other version found there",
|
||||
"{n} games now use the other versions found there", len(report.updated)))
|
||||
if report.other_version:
|
||||
out.append(tr_n("{n} game has another version there (left alone: Update with a newer version… on its page)",
|
||||
"{n} games have another version there (left alone: Update with a newer version… on their "
|
||||
"pages)", len(report.other_version)))
|
||||
if report.other_copy:
|
||||
out.append(tr_n("{n} game is already in the library from another folder",
|
||||
"{n} games are already in the library from other folders", len(report.other_copy)))
|
||||
return out
|
||||
|
||||
|
||||
SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets)
|
||||
|
||||
|
||||
@@ -1168,13 +1186,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 +1787,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 +1888,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:
|
||||
@@ -2235,36 +2296,29 @@ class FramePortApp:
|
||||
return self.submit(tr("Add {name}").format(name=name), run, None, "task")
|
||||
|
||||
def scan_roots(self) -> list[str]:
|
||||
"""Folders to rescan: the ones scanned before (remembered from now on), else the folders the library's games
|
||||
came from (one level up from each game folder)."""
|
||||
roots = [r for r in library.setting("scan.roots", []) if Path(r).exists()]
|
||||
if not roots:
|
||||
found = set()
|
||||
for g in library.load().get("games", {}).values():
|
||||
if g.get("kind") == "linux": # added one by one (its origin may be e.g. Downloads)
|
||||
continue
|
||||
origin = g.get("origin") or (str(Path(g["apk"]).parent) if g.get("apk") else None)
|
||||
if origin and Path(origin).parent.exists():
|
||||
found.add(str(Path(origin).parent))
|
||||
roots = sorted(found)
|
||||
# a folder inside another one is scanned with it (scanning it again would only repeat work)
|
||||
return [r for r in roots if not any(o != r and Path(r).is_relative_to(o) for o in roots)]
|
||||
"""Folders "Rescan folders" scans (core/folders.py; Settings → Library folders lists and edits them)."""
|
||||
from ..core import folders
|
||||
|
||||
return folders.rescan_roots()
|
||||
|
||||
def rescan(self, e=None) -> None:
|
||||
"""Scan the library's folders again for games added since (unchanged games aren't analyzed again)."""
|
||||
"""Scan the library's folders again for games added since (unchanged games aren't analyzed again) and for games
|
||||
whose files moved there (their entries are pointed at the new place)."""
|
||||
roots = self.scan_roots()
|
||||
if not roots:
|
||||
self.toast(tr("No folders to rescan yet — add games with Scan a folder…"))
|
||||
return
|
||||
for r in roots:
|
||||
self.scan(r, only_new=True)
|
||||
self.toast(tr_n("Rescanning {n} folder for new games", "Rescanning {n} folders for new games", len(roots)))
|
||||
self.toast(tr_n("Rescanning {n} folder for new and moved games",
|
||||
"Rescanning {n} folders for new and moved games", len(roots)))
|
||||
|
||||
def scan(self, path: str, single: bool = False, only_new: bool = False) -> Job:
|
||||
last = {"t": 0.0}
|
||||
if not single: # remembered for "Rescan folders"
|
||||
roots = [r for r in library.setting("scan.roots", []) if r != str(path)]
|
||||
library.set_setting("scan.roots", roots + [str(path)])
|
||||
if not single and Path(path).is_dir(): # remembered for "Rescan folders" (Settings → Library folders)
|
||||
from ..core import folders
|
||||
|
||||
folders.remember(path)
|
||||
|
||||
def added_one(entry: dict):
|
||||
from ..artwork import thumbs
|
||||
@@ -2282,17 +2336,23 @@ class FramePortApp:
|
||||
def run(job: Job):
|
||||
rep = job.reporter
|
||||
rep.stage(tr("Looking for games"))
|
||||
report = pipeline.ScanReport()
|
||||
added = pipeline.add_path(Path(path), rep, on_added=added_one, force_rift=single, art=True,
|
||||
only_new=only_new)
|
||||
only_new=only_new, report=report)
|
||||
if self.route[0] == "welcome" and added:
|
||||
library.set_setting("ui.welcome_done", True)
|
||||
self.route = ("library",)
|
||||
n = len(added)
|
||||
if report.handled():
|
||||
self.refresh_view() # moved games: their pages show the new place
|
||||
if only_new:
|
||||
return tr_n("{n} new game in {name}", "{n} new games in {name}", n,
|
||||
text = tr_n("{n} new game in {name}", "{n} new games in {name}", n,
|
||||
name=Path(path).name) if added else \
|
||||
tr("No new games in {name}").format(name=Path(path).name)
|
||||
return tr_n("Added {n} game", "Added {n} games", n) if added else tr("No games found in that folder")
|
||||
else:
|
||||
text = tr_n("Added {n} game", "Added {n} games", n) if added else \
|
||||
(tr("No new games in that folder") if report.handled() else tr("No games found in that folder"))
|
||||
return " · ".join([text] + scan_report_lines(report))
|
||||
return self.submit(tr("Scan {name}").format(name=Path(path).name), run, None, "scan")
|
||||
|
||||
# ------------------------------------------------------------------ executable choice / artwork
|
||||
|
||||
@@ -111,8 +111,14 @@ HELP: dict[str, str] = _Translated({
|
||||
"app_updates": "FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, "
|
||||
"then restarts FramePort; your games and settings stay.",
|
||||
"update_all": "Updates every game marked \"Update ready\", one after another.",
|
||||
"rescan": "Scans your game folders again and adds new games. Games already in the library keep their recipe, "
|
||||
"tags and art.",
|
||||
"rescan": "Scans your game folders again and adds new games. A game you moved into one of them gets its new "
|
||||
"place; games already in the library keep their name, recipe, tags and art. Settings → Library "
|
||||
"folders lists the folders.",
|
||||
"library_folders": "Every Scan a folder… adds its folder here. Rescan folders looks in them for new games, and "
|
||||
"for games you moved: a game whose files are gone from their old place is pointed at the same "
|
||||
"copy in its new place. Another version found there is only taken by Scan a folder…; Rescan "
|
||||
"leaves it for you to choose (Update with a newer version… on the game's page). Removing a "
|
||||
"folder only stops it being scanned: its games stay in the library.",
|
||||
"files": "Upload, download, rename and delete files on the Frame. Videos, Downloads and Documents are shared by "
|
||||
"every Quest game; each game also has its own storage.",
|
||||
"live_view": "Shows the headset's view with sound, whatever is running. It plays here where possible, else in "
|
||||
@@ -120,6 +126,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 "
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -23,12 +23,14 @@ TOOL_WHY = {"java": tr("Runs OVRPort and apksigner"), "overport": tr("Converts Q
|
||||
"apksigner": tr("Signs rebuilt games"), "revive": tr("Runs Oculus PC games on SteamVR")}
|
||||
# the page's sections in order (the index on the left lists them): Appearance near the top (it's the most used),
|
||||
# "Uninstall FramePort" last
|
||||
SECTIONS = ("appearance", "updates", "links", "installing", "tools", "pc", "data", "feedback", "about", "remove")
|
||||
SECTIONS = ("appearance", "updates", "links", "folders", "installing", "tools", "pc", "data", "feedback", "about",
|
||||
"remove")
|
||||
|
||||
|
||||
def section_titles() -> dict[str, str]:
|
||||
"""Index labels of SECTIONS (the sections' own headings may be longer)."""
|
||||
return {"appearance": tr("Appearance"), "updates": tr("Updates"), "links": tr("Install links"),
|
||||
"folders": tr("Library folders"),
|
||||
"installing": tr("Installing"), "tools": tr("Tools"), "pc": tr("This PC"), "data": tr("Data"),
|
||||
"feedback": tr("Problems and feedback"), "about": tr("About"), "remove": tr("Uninstall FramePort")}
|
||||
|
||||
@@ -135,6 +137,91 @@ class SettingsView:
|
||||
ft.Container(height=T.S2), self.ssh_keys()],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def library_folders(self) -> ft.Control:
|
||||
"""Settings → Library folders: the folders "Rescan folders" scans (core/folders.py, GitHub discussion #351).
|
||||
Status (is it there, how many games come from it) is read off the UI thread."""
|
||||
from ...core import folders
|
||||
|
||||
app = self.app
|
||||
rows = ft.Column(spacing=T.S2)
|
||||
|
||||
def row(st: folders.FolderStatus) -> ft.Control:
|
||||
games = tr_n("{n} game in the library", "{n} games in the library", st.games)
|
||||
if st.exists:
|
||||
icon, color, state = ft.Icons.FOLDER_OUTLINED, T.TEXT_2, games
|
||||
else:
|
||||
icon, color = ft.Icons.FOLDER_OFF_OUTLINED, T.WARN
|
||||
state = tr("Not found (moved, deleted or on a drive that isn't connected)") + " · " + games
|
||||
return ft.Row([
|
||||
C.as_icon(icon, T.px(18), color),
|
||||
ft.Column([C.body(Path(st.path).name or st.path, T.TEXT, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS),
|
||||
C.meta(st.path, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, tooltip=st.path),
|
||||
C.meta(state, T.WARN if not st.exists else None)],
|
||||
spacing=0, expand=True),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop scanning this folder"),
|
||||
on_click=lambda e, s=st: remove(s)),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
def fill(): # (run_bg: a folder on a sleeping network drive can take a while to answer)
|
||||
items = folders.status()
|
||||
rows.controls = [row(s) for s in items] or \
|
||||
[C.meta(tr("No folders yet. Add one here, or use Scan a folder… in the Library."))]
|
||||
C.update(rows)
|
||||
|
||||
def remove(st: folders.FolderStatus):
|
||||
def ok():
|
||||
folders.remove(st.path)
|
||||
app.run_bg(fill)
|
||||
C.confirm(app.page, tr("Stop scanning this folder?"),
|
||||
tr_n("Rescan folders won't look in {path} any more. The {n} game from it stays in your library "
|
||||
"(nothing is deleted).",
|
||||
"Rescan folders won't look in {path} any more. The {n} games from it stay in your library "
|
||||
"(nothing is deleted).", st.games, path=st.path),
|
||||
tr("Remove folder"), ok)
|
||||
|
||||
async def add(e):
|
||||
path = await C.pick_dir(app.page, dialog_title=tr("Folder with games (APKs or PC VR games)"))
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
folders.add(path)
|
||||
except ValueError as exc:
|
||||
app.toast(str(exc), error=True)
|
||||
return
|
||||
app.run_bg(fill)
|
||||
app.scan(path, only_new=True) # its games (new ones, and ones that moved there) come in now
|
||||
|
||||
rows.controls = [C.meta(tr("Checking folders…"))]
|
||||
app.run_bg(fill)
|
||||
return ft.Column([
|
||||
C.meta(tr("Rescan folders in the Library looks in these folders for new games and for games that "
|
||||
"moved there.")),
|
||||
rows,
|
||||
ft.Row([C.secondary(tr("Add folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, add),
|
||||
C.ghost(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan)],
|
||||
spacing=T.S2, run_spacing=T.S2, wrap=True),
|
||||
], spacing=T.S3, 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)."""
|
||||
@@ -496,6 +583,8 @@ class SettingsView:
|
||||
"appearance": C.section(tr("Appearance"), C.card(self.appearance(), padding=T.S4), help="ui_scale"),
|
||||
"updates": C.section(tr("Updates"), C.card(self.updates_card(), padding=T.S4), help="app_updates"),
|
||||
"links": C.section(tr("Install links"), C.card(self.links_card(), padding=T.S4), help="install_links"),
|
||||
"folders": C.section(tr("Library folders"), C.card(self.library_folders(), padding=T.S4),
|
||||
help="library_folders"),
|
||||
"installing": C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
"tools": C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort keeps its own copies; nothing is installed system-wide"),
|
||||
@@ -512,6 +601,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([
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Adding FramePort's key to ~/.ssh/authorized_keys never glues it onto a last line without a newline (GitHub #242):
|
||||
sshd ignores such a line, so OpenSSH uploads were refused while Paramiko got in with the user's own key."""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.paths import bootstrap_dir
|
||||
from frameport.frame.connection import install_key_command
|
||||
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFramePortTestKey frameport"
|
||||
OTHER = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQUserKey user@pc"
|
||||
pytestmark = pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash")
|
||||
|
||||
|
||||
def bootstrap_key_block() -> str:
|
||||
script = (bootstrap_dir() / "bootstrap.sh").read_text()
|
||||
start = script.index("mkdir -p ~/.ssh")
|
||||
return script[start:script.index("\n\n", start)]
|
||||
|
||||
|
||||
def run(tmp_path, command, before, key=KEY):
|
||||
ssh = tmp_path / ".ssh"
|
||||
if before is not None:
|
||||
ssh.mkdir(mode=0o700)
|
||||
(ssh / "authorized_keys").write_text(before)
|
||||
subprocess.run(["bash", "-c", command], env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin", "key": key},
|
||||
check=True)
|
||||
return (ssh / "authorized_keys").read_text()
|
||||
|
||||
|
||||
def commands():
|
||||
return [("install_key", install_key_command(KEY)), ("bootstrap", bootstrap_key_block())]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,command", commands())
|
||||
@pytest.mark.parametrize("before,after", [
|
||||
(None, KEY + "\n"), # no file yet
|
||||
("", KEY + "\n"), # empty file
|
||||
(OTHER + "\n", OTHER + "\n" + KEY + "\n"), # normal file
|
||||
(OTHER, OTHER + "\n" + KEY + "\n"), # last line without a newline: the #242 case
|
||||
(OTHER + "\n" + KEY + "\n", OTHER + "\n" + KEY + "\n"), # already there: unchanged
|
||||
])
|
||||
def test_key_is_added_on_its_own_line(tmp_path, name, command, before, after):
|
||||
assert run(tmp_path, command, before) == after
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,command", commands())
|
||||
def test_glued_key_gets_a_working_line(tmp_path, name, command):
|
||||
# an earlier install glued the key onto another line: sshd can't use it, so a clean line is added
|
||||
text = run(tmp_path, command, OTHER + KEY + "\n")
|
||||
assert KEY in text.splitlines()
|
||||
|
||||
|
||||
def test_file_mode(tmp_path):
|
||||
run(tmp_path, install_key_command(KEY), OTHER)
|
||||
assert oct((tmp_path / ".ssh" / "authorized_keys").stat().st_mode & 0o777) == "0o600"
|
||||
assert re.fullmatch(r"0o7\d\d", oct((tmp_path / ".ssh").stat().st_mode & 0o777))
|
||||
@@ -0,0 +1,261 @@
|
||||
"""GitHub discussion #351: the library folders list (Settings → Library folders, `frameport folders`) and scans that
|
||||
find games of the library at a new place (moved: relinked in place; another version; another copy)."""
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli, pipeline
|
||||
from frameport.artwork import fetch as artwork
|
||||
from frameport.core import folders, library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.patches import base
|
||||
from frameport.sources import quest_dump
|
||||
|
||||
PKG = "com.example.questgame"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ the folder list
|
||||
def test_folder_list_add_remove_status(tmp_path):
|
||||
a, b = tmp_path / "A", tmp_path / "B"
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
folders.remember(a)
|
||||
folders.remember(b)
|
||||
folders.remember(a) # scanned again: once, moved to the end
|
||||
assert folders.roots() == [str(b), str(a)]
|
||||
assert folders.add(b) == [str(b), str(a)] # already there: unchanged
|
||||
library.upsert_game("g1", apk=str(a / "x" / "g.apk"))
|
||||
library.upsert_game("g2", kind="rift", game_dir=str(a / "Game"))
|
||||
library.upsert_game("g3", apk=str(tmp_path / "elsewhere.apk"))
|
||||
shutil.rmtree(b)
|
||||
st = {s.path: s for s in folders.status()}
|
||||
assert (st[str(a)].exists, st[str(a)].games) == (True, 2)
|
||||
assert (st[str(b)].exists, st[str(b)].games) == (False, 0)
|
||||
assert folders.rescan_roots() == [str(a)] # a missing folder isn't scanned
|
||||
assert folders.remove(str(a)) is True and folders.remove(str(a)) is False
|
||||
assert library.game("g1") and library.game("g2") # its games stay
|
||||
try:
|
||||
folders.add(tmp_path / "nope")
|
||||
raise AssertionError("a missing folder can't be added")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_rescan_roots_fallback_only_before_anything_was_remembered(tmp_path):
|
||||
games = tmp_path / "Games"
|
||||
(games / "One").mkdir(parents=True)
|
||||
library.upsert_game("g1", apk=str(games / "One" / "g.apk"), origin=str(games / "One"))
|
||||
assert folders.rescan_roots() == [str(games)] # an older library: where its games came from
|
||||
folders.remember(games)
|
||||
folders.remember(games / "One") # inside another one: scanned with it
|
||||
assert folders.rescan_roots() == [str(games)]
|
||||
folders.remove(games)
|
||||
folders.remove(games / "One")
|
||||
assert folders.rescan_roots() == [] # the user removed them all: no guessing them back
|
||||
|
||||
|
||||
def test_cli_folders(tmp_path):
|
||||
d = tmp_path / "Games"
|
||||
d.mkdir()
|
||||
runner = CliRunner()
|
||||
res = runner.invoke(cli.app, ["folders", "--add", str(d)])
|
||||
assert res.exit_code == 0 and str(d) in res.output and "ok" in res.output
|
||||
assert folders.roots() == [str(d)]
|
||||
res = runner.invoke(cli.app, ["folders", "--json"])
|
||||
assert '"exists": true' in res.output
|
||||
res = runner.invoke(cli.app, ["folders", "--remove", str(d)])
|
||||
assert res.exit_code == 0 and folders.roots() == []
|
||||
assert runner.invoke(cli.app, ["folders", "--remove", str(d)]).exit_code == 1
|
||||
assert runner.invoke(cli.app, ["folders", "--add", str(tmp_path / "missing")]).exit_code != 0
|
||||
assert "no folders to rescan" in runner.invoke(cli.app, ["rescan"]).output
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Quest games that moved
|
||||
def _apk(path, manifest, extra=b""):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest)
|
||||
z.writestr("classes.dex", b"dex\n035\0" + extra)
|
||||
return path
|
||||
|
||||
|
||||
def _game(root, manifest, extra=b""):
|
||||
apk = _apk(root / "Quest Game" / "game.apk", manifest, extra)
|
||||
obb = root / "Quest Game" / PKG
|
||||
obb.mkdir()
|
||||
(obb / f"main.1.{PKG}.obb").write_bytes(b"o" * 10)
|
||||
return apk
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, manifest):
|
||||
base.load_all()
|
||||
monkeypatch.setattr(quest_dump, "_apk_ids", lambda p: (PKG, 1))
|
||||
monkeypatch.setattr(pipeline.rift_dump, "scan", lambda *a, **k: [])
|
||||
_game(tmp_path / "Old", manifest)
|
||||
[g] = pipeline.add_path(tmp_path / "Old")
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
r.source, r.status = "user", "works"
|
||||
r.patches["adapter.scale"] = {"value": 1.2}
|
||||
library.upsert_game(PKG, title="My Game", title_locked=True, tags=["mine"], recipe=library.recipe_to_dict(r),
|
||||
status="works", build={"ok": True, "sha256": "b"}, installs={"Frame": {"time": 1}})
|
||||
art = artwork.artwork_dir(PKG)
|
||||
(art / artwork.PICKED).write_text("custom")
|
||||
return g
|
||||
|
||||
|
||||
def _logs():
|
||||
logs = []
|
||||
rep = Reporter([lambda e: logs.append(e.message) if e.kind == "log" else None])
|
||||
return rep, logs
|
||||
|
||||
|
||||
def _kept_user_fields(g):
|
||||
assert (g["title"], g["title_locked"], g["tags"]) == ("My Game", True, ["mine"])
|
||||
assert g["recipe"]["source"] == "user" and g["recipe"]["patches"]["adapter.scale"] == {"value": 1.2}
|
||||
assert g["status"] == "works" and g["build"] == {"ok": True, "sha256": "b"} and g["installs"]
|
||||
assert (artwork.artwork_dir(PKG) / artwork.PICKED).is_file()
|
||||
|
||||
|
||||
def test_rescan_relinks_a_moved_quest_game(tmp_path, monkeypatch, quest_manifest):
|
||||
_setup(tmp_path, monkeypatch, quest_manifest)
|
||||
shutil.move(tmp_path / "Old", tmp_path / "New") # e.g. another drive
|
||||
rep, logs = _logs()
|
||||
report = pipeline.ScanReport()
|
||||
assert pipeline.add_path(tmp_path / "New", rep, only_new=True, report=report) == []
|
||||
g = library.game(PKG)
|
||||
assert [i["package"] for i in report.moved] == [PKG]
|
||||
new = tmp_path / "New" / "Quest Game"
|
||||
assert g["apk"] == str(new / "game.apk") and g["data_dir"] == str(new / PKG) and g["origin"] == str(new)
|
||||
assert g["data_manifest"] == {f"main.1.{PKG}.obb": 10}
|
||||
_kept_user_fields(g)
|
||||
assert any("aren't at" in m and "drive" in m for m in logs) # the old place is gone (maybe unplugged)
|
||||
assert any("Updated the location of 1 game" in m for m in logs)
|
||||
pipeline.check_source(g) # buildable again
|
||||
|
||||
|
||||
def test_full_scan_keeps_a_user_recipe_and_refreshes_a_derived_one(tmp_path, monkeypatch, quest_manifest):
|
||||
g0 = _setup(tmp_path, monkeypatch, quest_manifest)
|
||||
[g] = pipeline.add_path(tmp_path / "Old") # scanned again, same place
|
||||
_kept_user_fields(g)
|
||||
assert g["suggested"] == g0["suggested"]
|
||||
stale = dict(g0["recipe"], patches={}, source="heuristics")
|
||||
library.upsert_game(PKG, recipe=stale)
|
||||
[g] = pipeline.add_path(tmp_path / "Old")
|
||||
assert g["recipe"] == g["suggested"] # not the user's: follows the analysis
|
||||
|
||||
|
||||
def test_another_version_isnt_swapped_silently(tmp_path, monkeypatch, quest_manifest):
|
||||
_setup(tmp_path, monkeypatch, quest_manifest)
|
||||
shutil.rmtree(tmp_path / "Old")
|
||||
newer = _game(tmp_path / "New", quest_manifest, extra=b"v2") # other bytes: another build
|
||||
report = pipeline.ScanReport()
|
||||
pipeline.add_path(tmp_path / "New", only_new=True, report=report) # Rescan folders: the user decides
|
||||
assert [i["package"] for i in report.other_version] == [PKG] and not report.moved
|
||||
assert library.game(PKG)["apk"] == str(tmp_path / "Old" / "Quest Game" / "game.apk")
|
||||
report = pipeline.ScanReport()
|
||||
assert pipeline.add_path(tmp_path / "New", report=report) == [] # Scan a folder…: like Update with a newer APK…
|
||||
g = library.game(PKG)
|
||||
assert [i["package"] for i in report.updated] == [PKG]
|
||||
assert g["apk"] == str(newer) and g["source_sha256"] == pipeline.builder.sha256(newer)
|
||||
assert (g["title"], g["tags"], g["recipe"]["source"]) == ("My Game", ["mine"], "user")
|
||||
|
||||
|
||||
def test_a_second_copy_leaves_the_existing_one(tmp_path, monkeypatch, quest_manifest):
|
||||
_setup(tmp_path, monkeypatch, quest_manifest)
|
||||
_game(tmp_path / "Copy", quest_manifest)
|
||||
for only_new in (True, False):
|
||||
report = pipeline.ScanReport()
|
||||
assert pipeline.add_path(tmp_path / "Copy", only_new=only_new, report=report) == []
|
||||
assert [i["package"] for i in report.other_copy] == [PKG]
|
||||
g = library.game(PKG)
|
||||
assert g["apk"] == str(tmp_path / "Old" / "Quest Game" / "game.apk")
|
||||
_kept_user_fields(g)
|
||||
|
||||
|
||||
def test_same_build_without_a_checksum_uses_the_version_name(tmp_path, monkeypatch):
|
||||
apk = tmp_path / "x.apk"
|
||||
apk.write_bytes(b"x")
|
||||
monkeypatch.setattr(quest_dump, "version_name", lambda p: "1.2")
|
||||
assert pipeline.same_quest_build({"analysis": {"version": "1.2"}}, apk)
|
||||
assert not pipeline.same_quest_build({"analysis": {"version": "1.1"}}, apk)
|
||||
assert not pipeline.same_quest_build({"source_sha256": "0" * 64, "analysis": {"version": "1.2"}}, apk)
|
||||
|
||||
|
||||
def test_scan_summary_lines():
|
||||
from frameport.ui.app import scan_report_lines
|
||||
|
||||
report = pipeline.ScanReport()
|
||||
assert scan_report_lines(report) == []
|
||||
report.note("moved", {"package": "a", "title": "A"}, "/x")
|
||||
report.note("moved", {"package": "b", "title": "B"}, "/y")
|
||||
report.note("other_version", {"package": "c"}, "/z")
|
||||
lines = scan_report_lines(report)
|
||||
assert lines[0] == "Updated the location of 2 games" and "another version" in lines[1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC VR games that moved
|
||||
def _rift(tmp_path):
|
||||
from test_rift import unity_game
|
||||
|
||||
unity_game(tmp_path / "Old")
|
||||
[g] = pipeline.add_path(tmp_path / "Old")
|
||||
pkg = g["package"]
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
r.source = "user"
|
||||
r.patches["pcvr.no_crash_reporter"] = {}
|
||||
pipeline.set_recipe(pkg, r)
|
||||
library.upsert_game(pkg, title="My Space", title_locked=True, tags=["mine"], exe_confirmed=True)
|
||||
return pkg
|
||||
|
||||
|
||||
def _rift_kept(g):
|
||||
assert (g["title"], g["tags"], g["exe"]) == ("My Space", ["mine"], "Space Game.exe")
|
||||
assert g["recipe"]["source"] == "user" and "pcvr.no_crash_reporter" in g["recipe"]["patches"]
|
||||
|
||||
|
||||
def test_rescan_relinks_a_moved_rift_game(tmp_path):
|
||||
pkg = _rift(tmp_path)
|
||||
(tmp_path / "New").mkdir()
|
||||
shutil.move(tmp_path / "Old" / "Space Game", tmp_path / "New" / "Space Game")
|
||||
report = pipeline.ScanReport()
|
||||
assert pipeline.add_path(tmp_path / "New", only_new=True, report=report) == []
|
||||
g = library.game(pkg)
|
||||
new = tmp_path / "New" / "Space Game"
|
||||
assert [i["package"] for i in report.moved] == [pkg]
|
||||
assert g["game_dir"] == str(new) and g["analysis"]["extra"]["folder"] == str(new)
|
||||
_rift_kept(g)
|
||||
assert len([x for x in library.games() if x.get("kind") == "rift"]) == 1 # no second entry
|
||||
|
||||
|
||||
def test_a_renamed_rift_folder_is_found_by_its_program(tmp_path):
|
||||
pkg = _rift(tmp_path)
|
||||
(tmp_path / "New").mkdir()
|
||||
shutil.move(tmp_path / "Old" / "Space Game", tmp_path / "New" / "Space Game (repack)")
|
||||
report = pipeline.ScanReport()
|
||||
pipeline.add_path(tmp_path / "New", only_new=True, report=report)
|
||||
assert [i["package"] for i in report.moved] == [pkg]
|
||||
assert library.game(pkg)["game_dir"] == str(tmp_path / "New" / "Space Game (repack)")
|
||||
assert len([x for x in library.games() if x.get("kind") == "rift"]) == 1
|
||||
|
||||
|
||||
def test_rift_copy_and_version(tmp_path):
|
||||
pkg = _rift(tmp_path)
|
||||
shutil.copytree(tmp_path / "Old" / "Space Game", tmp_path / "Copy" / "Space Game")
|
||||
report = pipeline.ScanReport()
|
||||
pipeline.add_path(tmp_path / "Copy", report=report) # the library's folder is still there: it stays
|
||||
assert [i["package"] for i in report.other_copy] == [pkg]
|
||||
assert library.game(pkg)["game_dir"] == str(tmp_path / "Old" / "Space Game")
|
||||
shutil.rmtree(tmp_path / "Old")
|
||||
exe = tmp_path / "Copy" / "Space Game" / "Space Game.exe"
|
||||
exe.write_bytes(exe.read_bytes() + b"\0" * 512) # another build
|
||||
report = pipeline.ScanReport()
|
||||
pipeline.add_path(tmp_path / "Copy", only_new=True, report=report)
|
||||
assert [i["package"] for i in report.other_version] == [pkg]
|
||||
assert library.game(pkg)["game_dir"] == str(tmp_path / "Old" / "Space Game")
|
||||
report = pipeline.ScanReport()
|
||||
pipeline.add_path(tmp_path / "Copy", report=report)
|
||||
g = library.game(pkg)
|
||||
assert [i["package"] for i in report.updated] == [pkg] and g["source_id"]
|
||||
assert g["game_dir"] == str(tmp_path / "Copy" / "Space Game")
|
||||
_rift_kept(g)
|
||||
+19
-2
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user