Compare commits

..
Author SHA1 Message Date
spoopyghosty0 3434ce6c0e Catalog: Puzzling Places (issues), I Expect You To Die 2 (works)
Closes #359
Closes #358
2026-10-16 14:23:43 -04:00
spoopyghosty0 eedf6e4c06 Test: glslang checks the Doom3Quest shaders from #version on
Doom3Quest's shader sources start with a newline before #version (raw string literals). GL drivers accept that, but
glslangValidator's ES profile rejects it, so the optional validator step failed wherever glslang is installed.
The copy it checks now starts at #version; the rewriter output itself is unchanged.
2026-10-16 14:15:24 -04:00
spoopyghosty0 3890676dcb Save backups: agent v95 (rift-on-frame already uses 94); game version from the deployment's source_version 2026-10-16 11:47:09 -04:00
spoopyghosty0 d0dbbe32e5 Save backups for Quest games (discussion #352)
Agent v94: _backup_saves / _restore_saves stream a tar.gz of a game's internal
and external Android storage over the SSH exec channel (caches, IL2CPP and
shader caches, FrameBridge files, videos/OBBs and files over 64 MiB left out;
manifest with sha256 last). File work runs in podman unshare, holds the
launch lock and is refused while the game runs. Restore treats the archive as
untrusted (confined paths, no links/devices, size limits, hashes checked,
staged first), keeps the replaced saves as saves-before-restore.tar.gz (undo)
and puts them back if swapping fails.

PC: install/saves.py (save-backups/<pkg>/, keep 10 by default), game menu
section Saves (back up, restore..., open backups folder), Frame page
"Back up all saves", back up before deleting saves on uninstall, Settings ->
Data keep count, CLI frameport saves backup|list|restore|undo.
2026-10-16 11:47:07 -04:00
spoopyghosty0 6101314ce1 Library folders list + scans relink moved games (discussion #351)
Settings -> Library folders lists the folders Rescan scans (scan.roots: status,
games from each, remove, Add folder...), CLI frameport folders / rescan.
A scan that finds a library game elsewhere whose own files are gone and that
is the same build points the entry at the new place (title, tags, art, user
recipe, builds and installs stay); another version is taken only by Scan a
folder... (as Update with a newer APK/version), Rescan reports it; a second
copy leaves the existing one. A full scan no longer overwrites a user recipe.
2026-10-16 10:37:11 -04:00
spoopyghosty0 98ddfa7157 authorized_keys: never glue FramePort's key onto a last line without a newline (#242)
bootstrap.sh and Frame.install_key appended with echo >> after an exact-line grep; a file whose last key had no
newline got FramePort's key glued onto it, which sshd never matches, so OpenSSH uploads were refused while Paramiko
logged in with the user's own key. Both now add the missing newline first (one shared install_key_command).
2026-10-16 10:12:17 -04:00
spoopyghosty0 91bc6af2b7 Catalog: SmartTube (issues; tested 32.56 on the Frame)
Closes #350
2026-10-16 10:02:44 -04:00
spoopyghosty0 04faa0be65 Catalog: The Climb (works)
Closes #341
2026-10-16 09:39:49 -04:00
spoopyghosty0 1399d7c5a3 Catalog: Attack on Titan VR: Unbreakable works with its asset files (was Freedom, issues)
Closes #337
2026-10-16 09:36:24 -04:00
spoopyghosty0 fa5c5b32ce Catalog: Sclerosis (works)
Closes #339
2026-10-16 09:33:54 -04:00
spoopyghosty0 0228516a2a Catalog: 7 community recipes (Breathedge VR, Cities: VR, Lawn Mowing Simulator VR, Exploding Kittens VR, Lost Recipes, Neko Atsume Purrfect, Real VR Fishing)
Closes #347
Closes #345
Closes #344
Closes #343
Closes #340
Closes #338
Closes #333
2026-10-16 09:31:12 -04:00
spoopyghosty0 960da4469b Announcements: a one-time pop-up in the app
Source of truth: announcements/current.md on main (front matter: id, title,
date, optional image/button/expires; Markdown body), read from raw GitHub every
6 h like the catalog, so a push reaches users without a release. Ships
switched off (active: false).

Shown only to people who used FramePort before the announcement's date, never
on a first start; any close (X, click outside, Escape) dismisses that id for
good, and a new announcement = a new id. Centred over the content area, right
of the sidebar. announcements/README.md explains the file;
ui_smoke.py --announcement renders it.
2026-10-16 09:23:46 -04:00
spoopyghosty0 e9720f83d2 Catalog: Crisis Zone VR (works)
Closes #331
2026-10-16 00:14:17 -04:00
spoopyghosty0 729f77a9e3 Docs: update screenshots and videos 2026-10-16 00:08:46 -04:00
spoopyghosty0 4824b30e94 Catalog: Vermillion works (confirmed in the headset, #200) 2026-10-15 23:54:32 -04:00
spoopyghosty0 39e1762e53 Game menu: Update with a newer version… for Linux apps and PC VR games (#161)
Follow-up to "Update with a newer APK…": Linux apps take their new AppImage/archive or folder (a file or a folder
picker, like the app was added), PC VR games their new game folder. pipeline.replace_linux_app / replace_rift_game
re-run the add path (add_linux_app(replace=) / add_rift_game(replace=)): the entry keeps its package (so the Steam
shortcut), name, artwork, tags, switches and the user's recipe; a folder keeps the program chosen before if it's still
there, else FramePort picks again (and asks, for ambiguous PC VR games). A name that looks like another app's is
allowed with a heads-up.

The entry gets a new source_id, recorded in the deployment's recipe at install (no agent change):
components.source_replaced shows the Frame copy (and a PC install, pc_outdated) as outdated until then. Such an
install also re-sends same-size files changed after the last install (installer.changed_since), since uploads
otherwise skip files the Frame has with the same size. The old PC VR folder is left out of later scans
(replaced_dirs). CLI: frameport new-version <game> <path> (APKs too). Docs: INSTALL.md.
2026-10-15 20:10:50 -04:00
spoopyghosty0 3d260dccb1 OpenSSH uploads log in like the Frame connection; SSH key files in Settings (#242)
A Frame that only authorizes the user's own key refused OpenSSH, which
offered FramePort's keys alone (IdentitiesOnly), so every upload fell back
to Paramiko. The connection now records which login worked (Frame.auth):
after a login with this PC's own keys/agent OpenSSH also offers private
copies of ~/.ssh/id_* (OpenSSH skips its defaults once -i is given) and the
agent; a refused strict login is retried that way once. FramePort doesn't
add its key to the Frame for this.

New: Settings > Installing > SSH keys (library ssh.extra_keys, CLI
frameport frame ssh-keys): extra private key files for the connection and
OpenSSH, checked with Paramiko (passphrase keys: use the agent). OpenSSH
runs with -v and a refused login's error lists the keys offered and its
warnings. whoami's SID is read from bytes (non-ASCII user names).
Checked with Windows ssh.exe/sftp.exe against the Frame.
2026-10-15 19:58:58 -04:00
spoopyghosty0 26a34ddadb OpenSSH uploads: don't escape glob characters inside sftp's quotes (#324)
sftp escapes * ? [ inside double quotes itself, so FramePort's extra
backslash became part of the path: a folder named "... [ENG] ..." failed
every OpenSSH upload (on Linux too) and fell back to Paramiko. On Windows
the local path now goes with forward slashes: Win32-OpenSSH turned every
backslash, escapes included, into '/' ("C://Users", "/[ENG]").
Checked with Windows sftp.exe and Linux sftp against the Frame (put and
reput, sha256 match).
2026-10-15 19:58:57 -04:00
spoopyghosty0 939845d379 Catalog: Clockwork is The Last Clockwinder (Meta's store name; reported in #110) 2026-10-15 19:51:45 -04:00
spoopyghosty0 35eb12df08 CLAUDE.md: every triage sweeps for unanswered comments (closed issues, PRs, discussion replies) 2026-10-15 19:41:32 -04:00
spoopyghosty0 570a98b91a Triage: meta-entitlement for Quest games; catalog: Deadly Delivery unsupported (#322)
Deadly Delivery 1.0.30 renders (72 fps), then its lobby's Meta entitlement check
fails four times and it quits. The oculus-entitlement signature is PC VR only,
so the Quest log had no finding. New Quest signature meta-entitlement (error:
offline games may log it and play on; an online game's exit fails the test).
FramePort doesn't get around entitlement checks. docs/GAMES.md regenerated
(it was also behind on other catalog entries).
2026-10-15 19:34:51 -04:00
78 changed files with 5210 additions and 634 deletions

No files matched your search

+39 -3
View File
@@ -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;
@@ -223,6 +224,13 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
@@ -234,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(...)`.
@@ -267,6 +283,15 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
`working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by
hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
- Announcements (2026-10-15, announcements/README.md; app only, the owner dropped the site pop-up): source of
truth `announcements/current.md` on `main` (front matter id/title/date + optional image/image_alt/button+url/
expires/active, Markdown body; ships `active: false`). `announcements.py` (no Flet; raw GitHub via
`cache.cached_text`, 6 h) + `ui/announcer.py` (pop-up 20 s after start / every 6 h when no dialog is open and not
on welcome; `C.dialog` with the portal edge, image downloaded + Pillow-checked into `<data>/announcements/`, shown
by asset URL). Shown only if the user was around before `date` (setting `announce.since`, set once at the GUI's
first start: 0 when `USED_BEFORE` settings exist, else now), once: any close (X, outside click, Escape, button)
stores the id (`announce.dismissed`, last 50). New announcement = new id; same id = typo fix.
`FRAMEPORT_NO_ANNOUNCEMENTS`; `ui_smoke.py --announcement` renders it.
- Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version
(`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use
it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub
@@ -459,9 +484,6 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
XR_KHR_composition_layer_equirect2/cylinder, XR_FB_composition_layer_image_layout (flip emulated by Vulkan blit).
- `xrConvertTimespecTimeToTimeKHR`/`xrConvertTimeToTimespecTimeKHR` return FUNCTION_UNSUPPORTED → adapter emulates
(offset = predictedDisplayTime − period − CLOCK_MONOTONIC, sampled in xrWaitFrame).
- No XR_FB_display_refresh_rate (`xrGetDisplayRefreshRateFB` -7), yet OVRPort's dispatcher offers it and answered 0 Hz
→ FrameBridge always answers with the measured rate (1/predictedDisplayPeriod, else 72). PreyVR (#306, idTech 4:
`USERCMD_MSEC = 1000 / GetRefresh()`) had 0 ms tics: async thread spinning, `com_frameTime` stuck at 0, menu black.
- Guardian STAGE bounds report 1×1 m (scene emulation uses ≥1.5 m).
- GL goes through **Zink** (Mesa GL on Vulkan). Mesa GLSL is strict: `#pragma` before `#extension` fails, implicit
int/float conversions fail (enable `GL_EXT_shader_implicit_conversions`), num_views=2 shaders on single-view FBOs
@@ -907,6 +929,13 @@ hide_space_warp / vk_hide_fdm / no Valve layers; FNAF HW (#135) "freeze" = first
on Zink (threads busy in zink/disk cache), not a hang; VR4 (#199) reporter's patch.203 OBB truncated. Upload benchmark
(`scripts/upload_bench.py`, #242): home Wi-Fi caps every method at ~11 MB/s; paramiko uses ~3x OpenSSH's CPU; Windows
OpenSSH refuses FramePort's key file (ACL "bad permissions") until its permissions are tightened.
**OpenSSH uploads (2026-10-15, #324/#242):** sftp escapes `* ? [` inside double quotes itself (and undoes it for
put's destination), so `native_upload.quote` escapes only `\\`/`"`; Win32 sftp turns every local backslash into `/`
(escapes too) → local paths go as `C:/…`. Logins: `Frame.auth` = the attempt that worked (app_key / user_key = a key
file from Settings → Installing → SSH keys, library `ssh.extra_keys`, CLI `frame ssh-keys` / agent / password).
OpenSSH strict (FramePort + Settings keys, IdentitiesOnly) unless auth was "agent"; a refused strict login retries
relaxed (+ private copies of ~/.ssh/id_{rsa,dsa,ecdsa,ed25519}: OpenSSH skips its defaults once any -i is given, + the
agent); `-v` key lines go into the error. FramePort doesn't add its key to the Frame for this. Windows-tested.
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
@@ -1058,6 +1087,13 @@ GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `g
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
+590 -1
View File
@@ -8,6 +8,7 @@ Commands: info, prepare, finalize, shortcuts, shortcut_status, launch_test, stop
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr,
controller_models, list_screenshots, delete_screenshots (and more: see the cmd_* functions).
Streaming: python3 frameport_agent.py _keyboard (a virtual keyboard: JSON lines on stdin, see keyboard_session)
python3 frameport_agent.py _backup_saves|_restore_saves '<json>' (save backups: tar.gz on stdout/stdin)
Install layout (one Lepton container per game; same as the manual installs from 2026-09):
~/Applications/quest-frame/<pkg>/ anchor: launch.sh, deployment.json, artwork/ (always internal storage)
@@ -44,7 +45,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 91
AGENT_VERSION = 95
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -7906,6 +7907,588 @@ def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=Non
return 0
# ------------------------------------------------------------------------------------------ save backups (#352)
# Steam doesn't sync Quest games' saves: the PC app backs them up and restores them. The game's two storage roots (its
# internal /data/data/<pkg> folder and its /sdcard/Android/data/<pkg> folder) go into one tar.gz streamed over the SSH
# exec channel: `_backup_saves '<json>'` = archive on stdout, one result JSON line on stderr; `_restore_saves '<json>'`
# = archive on stdin, one result JSON line on stdout. Both refuse while the game runs and hold its launch lock. Files
# Lepton's containers own are read and written in podman's user namespace (`podman unshare`, where the steamos user
# owns every mapped id). Caches, shader/program binaries, IL2CPP copies, FrameBridge's own files, videos/OBBs and
# anything over SAVES_MAX_FILE stay out; the manifest (last member) lists files, sizes, sha256 and what was left out.
SAVES_MANIFEST = "frameport-saves.json"
SAVES_ROLLBACK = "saves-before-restore.tar.gz"
SAVES_FORMAT = 1
SAVES_MAX_FILE = 64 << 20 # larger files are left out of a backup (the manifest's "skipped", reason "size")
SAVES_MAX_TOTAL = 4 << 30 # a bigger backup is refused (that's game content, not saves)
SAVES_RESTORE_MAX_FILE = 1 << 30 # restore (untrusted input): one member at most this big ...
SAVES_RESTORE_MAX_TOTAL = 8 << 30 # ... all of them together at most this big
SAVES_RESTORE_MAX_MEMBERS = 200000
SAVES_MANIFEST_MAX = 32 << 20
# folder names (any depth, case-insensitive) that only hold what a game rebuilds by itself (Android's own backup
# leaves out cache/code_cache/no_backup too)
SAVES_SKIP_DIRS = {"cache", "code_cache", "code cache", "no_backup", "il2cpp", "unityshadercache", "gpucache",
"shadercache", "grshadercache", "dawncache", "dawngraphitecache", "dawnwebgpucache",
"programbinarycache", "vulkanprogrambinarycache", "pipelinecaches", "fp_vk_shaders", "fp_spirv",
"cachestorage", "scriptcache"}
SAVES_SKIP_UNDER_SAVED = {"logs", "crashes"} # Unreal's Saved/Logs and Saved/Crashes
SAVES_SKIP_EXT = (".obb", ".apk", ".foz", ".mp4", ".m4v", ".mkv", ".webm", ".mov")
SAVES_SKIP_PREFIX = ("fb_snap_", "framebridge") # FrameBridge's snapshots, config and state (FramePort writes them)
def saves_roots(base, pkg):
"""{"internal": .../internal/<pkg> (= /data/data/<pkg>), "external": .../external/Android/data/<pkg>}."""
data = os.path.join(base, "lepton-data")
return {"internal": os.path.join(data, "internal", pkg),
"external": os.path.join(data, "external", "Android", "data", pkg)}
def _saves_skip_dir(rel_parts, name):
low = name.lower()
if low in SAVES_SKIP_DIRS:
return True
return low in SAVES_SKIP_UNDER_SAVED and bool(rel_parts) and rel_parts[-1].lower() == "saved"
def _saves_skip_file(name):
low = name.lower()
return low.endswith(SAVES_SKIP_EXT) or low.startswith(SAVES_SKIP_PREFIX)
def collect_saves(roots, max_file=SAVES_MAX_FILE):
"""What a backup holds: {"dirs": [(arc, path, stat)], "files": [(arc, path, stat)], "skipped": [{path, size,
reason}]}, sorted. Symlinks (Lepton's links), devices and pipes are never followed or included."""
import stat as st_
out = {"dirs": [], "files": [], "skipped": []}
for key in sorted(roots):
root = roots[key]
try:
rst = os.lstat(root)
except OSError:
continue
if not st_.S_ISDIR(rst.st_mode):
out["skipped"].append({"path": key, "size": 0, "reason": "not a folder"})
continue
out["dirs"].append((key, root, rst))
for cur, dirs, files in os.walk(root, topdown=True, followlinks=False):
rel = os.path.relpath(cur, root)
parts = [] if rel == "." else rel.split(os.sep)
keep = []
for d in sorted(dirs):
p = os.path.join(cur, d)
arc = "/".join([key, *parts, d])
try:
s = os.lstat(p)
except OSError:
continue
if st_.S_ISLNK(s.st_mode):
out["skipped"].append({"path": arc, "size": 0, "reason": "link"})
elif _saves_skip_dir(parts, d):
out["skipped"].append({"path": arc + "/", "size": 0, "reason": "cache"})
else:
keep.append(d)
out["dirs"].append((arc, p, s))
dirs[:] = keep
for f in sorted(files):
p = os.path.join(cur, f)
arc = "/".join([key, *parts, f])
try:
s = os.lstat(p)
except OSError:
continue
if not st_.S_ISREG(s.st_mode):
out["skipped"].append({"path": arc, "size": 0,
"reason": "link" if st_.S_ISLNK(s.st_mode) else "special"})
elif _saves_skip_file(f):
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "not a save"})
elif s.st_size > max_file:
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "size"})
elif not os.access(p, os.R_OK):
out["skipped"].append({"path": arc, "size": s.st_size, "reason": "unreadable"})
else:
out["files"].append((arc, p, s))
return out
class _HashingReader:
"""A file read for the archive, hashed on the way (tarfile reads exactly the member's size)."""
def __init__(self, f):
self.f, self.h = f, hashlib.sha256()
def read(self, n=-1):
data = self.f.read(n)
self.h.update(data)
return data
def _saves_tarinfo(arc, s, is_dir):
import tarfile
ti = tarfile.TarInfo(arc)
ti.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
ti.mode = s.st_mode & 0o7777
ti.uid, ti.gid = s.st_uid, s.st_gid
ti.uname = ti.gname = ""
ti.mtime = int(s.st_mtime)
ti.size = 0 if is_dir else s.st_size
return ti
def _dep_version(dep):
"""The installed build's game version (deployments record the APK's version as source_version)."""
return str((dep or {}).get("version") or (dep or {}).get("source_version") or "")
def write_saves_tar(out, pkg, roots, dep=None, max_file=SAVES_MAX_FILE, max_total=SAVES_MAX_TOTAL):
"""Stream the game's saves as tar.gz to `out` (a binary file object). Returns the manifest (also the archive's
last member, frameport-saves.json)."""
import gzip
import io
import tarfile
found = collect_saves(roots, max_file)
total = sum(s.st_size for _a, _p, s in found["files"])
if total > max_total:
raise AgentError(f"{pkg}'s saves are {total / 2**30:.1f} GiB, more than the {max_total / 2**30:.0f} GiB a "
f"save backup takes (that's game content, not saves)")
manifest = {"format": SAVES_FORMAT, "package": pkg, "title": (dep or {}).get("title") or pkg,
"version": _dep_version(dep), "created": time.time(), "agent": AGENT_VERSION,
"roots": sorted(roots), "max_file": max_file, "files": [], "dirs": [], "skipped": found["skipped"]}
gz = gzip.GzipFile(fileobj=out, mode="wb", compresslevel=6)
with tarfile.open(fileobj=gz, mode="w|", format=tarfile.PAX_FORMAT) as tar:
for arc, _p, s in found["dirs"]:
tar.addfile(_saves_tarinfo(arc, s, True))
manifest["dirs"].append({"path": arc, "mode": s.st_mode & 0o7777, "uid": s.st_uid, "gid": s.st_gid})
for arc, p, s in found["files"]:
with open(p, "rb") as f:
reader = _HashingReader(f)
tar.addfile(_saves_tarinfo(arc, s, False), reader)
manifest["files"].append({"path": arc, "size": s.st_size, "sha256": reader.h.hexdigest(),
"mode": s.st_mode & 0o7777, "uid": s.st_uid, "gid": s.st_gid,
"mtime": int(s.st_mtime)})
raw = json.dumps(manifest, indent=1).encode()
ti = tarfile.TarInfo(SAVES_MANIFEST)
ti.size, ti.mtime, ti.mode = len(raw), int(manifest["created"]), 0o644
tar.addfile(ti, io.BytesIO(raw))
gz.close()
return manifest
def saves_summary(manifest):
return {"package": manifest.get("package"), "files": len(manifest.get("files") or []),
"bytes": sum(int(f.get("size") or 0) for f in manifest.get("files") or []),
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
"created": manifest.get("created"), "version": manifest.get("version")}
def _saves_member_name(name):
"""A member's path as parts, or None when it isn't confined to internal/ or external/ (absolute, '..', NUL,
backslashes, overlong)."""
if not isinstance(name, str) or not name or "\0" in name or "\\" in name or name.startswith("/"):
return None
parts = [p for p in name.split("/") if p not in ("", ".")]
if not parts or parts[0] not in ("internal", "external") or ".." in parts:
return None
if len(name) > 4096 or any(len(p) > 255 for p in parts):
return None
return parts
def stage_saves_archive(inp, staging, max_file=SAVES_RESTORE_MAX_FILE, max_total=SAVES_RESTORE_MAX_TOTAL,
max_members=SAVES_RESTORE_MAX_MEMBERS):
"""Read an untrusted save archive from `inp` into `staging` (flat numbered files, never the members' names).
Returns (entries {parts tuple: {kind, mode, mtime, staged, size, sha256}}, manifest). Refuses links, devices, paths
outside the two roots, duplicates, oversize members and files that don't match the manifest."""
import tarfile
entries, manifest, total, count = {}, None, 0, 0
try:
tar = tarfile.open(fileobj=inp, mode="r|gz")
except (tarfile.TarError, OSError, EOFError) as exc:
raise AgentError(f"not a save backup (unreadable archive: {exc})") from None
try:
for m in tar:
count += 1
if count > max_members:
raise AgentError("the backup has too many files")
if m.name == SAVES_MANIFEST:
if not m.isreg() or m.size > SAVES_MANIFEST_MAX or manifest is not None:
raise AgentError("the backup's manifest is invalid")
try:
manifest = json.loads(tar.extractfile(m).read().decode("utf-8"))
except ValueError:
raise AgentError("the backup's manifest is invalid") from None
continue
parts = _saves_member_name(m.name)
if parts is None:
raise AgentError(f"the backup has a file outside the game's save folders: {m.name[:200]!r}")
key = tuple(parts)
if key in entries:
raise AgentError(f"the backup has {m.name[:200]!r} twice")
if m.isdir():
entries[key] = {"kind": "dir", "mode": m.mode & 0o7777, "mtime": int(m.mtime or 0)}
continue
if not m.isreg():
raise AgentError(f"the backup has a link or special file: {m.name[:200]!r}")
if len(parts) < 2:
raise AgentError(f"the backup has a file in place of a save folder: {m.name[:200]!r}")
if m.size > max_file:
raise AgentError(f"{m.name[:200]!r} in the backup is too big ({m.size} bytes)")
total += m.size
if total > max_total:
raise AgentError("the backup is too big to restore")
staged = os.path.join(staging, f"{len(entries):08d}")
src = tar.extractfile(m)
h = hashlib.sha256()
with open(staged, "wb") as f:
left = m.size
while left:
chunk = src.read(min(left, 1 << 20))
if not chunk:
raise AgentError("the backup is cut off")
h.update(chunk)
f.write(chunk)
left -= len(chunk)
entries[key] = {"kind": "file", "mode": m.mode & 0o7777, "mtime": int(m.mtime or 0), "staged": staged,
"size": m.size, "sha256": h.hexdigest()}
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
raise AgentError(f"the backup is damaged or cut off ({exc})") from None
finally:
try:
tar.close()
except Exception: # noqa: BLE001
pass
if not isinstance(manifest, dict) or manifest.get("format") != SAVES_FORMAT:
raise AgentError("not a FramePort save backup (no manifest)")
listed = {}
for f in manifest.get("files") or []:
parts = _saves_member_name(str((f or {}).get("path", "")))
if parts is not None:
listed[tuple(parts)] = f
for key, e in entries.items():
if e["kind"] != "file":
continue
f = listed.get(key)
if not f or f.get("sha256") != e["sha256"] or f.get("size") != e["size"]:
raise AgentError(f"the backup is damaged: {'/'.join(key)[:200]} doesn't match its manifest")
return entries, manifest
def _saves_dest(roots, parts):
return os.path.join(roots[parts[0]], *parts[1:])
def check_saves_destinations(roots, entries):
"""No restored path may pass through or replace a link, or swap a file for a folder (Lepton keeps links in a
game's storage: writing through one would land outside it)."""
import stat as st_
for key, e in entries.items():
for i in range(1, len(key) + 1):
sub = key[:i]
path = _saves_dest(roots, sub)
try:
s = os.lstat(path)
except FileNotFoundError:
break # the rest gets created
if st_.S_ISLNK(s.st_mode):
raise AgentError(f"won't restore through the link {'/'.join(sub)}")
want_dir = i < len(key) or e["kind"] == "dir"
if want_dir and not st_.S_ISDIR(s.st_mode):
raise AgentError(f"{'/'.join(sub)} is a file on the Frame but a folder in the backup")
if not want_dir and not st_.S_ISREG(s.st_mode):
raise AgentError(f"{'/'.join(sub)} is a folder on the Frame but a file in the backup")
def _saves_parent_stat(path):
"""stat of path's nearest existing folder."""
p = path
while True:
try:
return os.stat(p)
except OSError:
parent = os.path.dirname(p)
if parent == p:
raise
p = parent
def _saves_chown(path, uid, gid):
try:
s = os.lstat(path)
if (s.st_uid, s.st_gid) != (uid, gid):
os.chown(path, uid, gid)
except OSError:
pass # (outside podman's namespace only our own ids can be set)
def _saves_mkdir(path, mode=None):
"""Create a missing folder (and its parents) owned like its parent folder; mode None = the parent's mode."""
if os.path.isdir(path):
return
parent = os.path.dirname(path)
_saves_mkdir(parent)
ps = os.stat(parent)
os.mkdir(path)
os.chmod(path, ((ps.st_mode if mode is None else mode) & 0o2777) | 0o700)
_saves_chown(path, ps.st_uid, ps.st_gid)
def apply_staged_saves(roots, entries, manifest):
"""Put staged files in place: existing files keep their owner and mode, new ones get their folder's owner and the
backup's mode (at least u+rw); each file is swapped in with one rename. Save files the backup doesn't have go
(the same rules as a backup decide what's a save; files too big for the backup stay). Returns counts."""
import stat as st_
restored = removed = 0
for key in sorted((k for k, e in entries.items() if e["kind"] == "dir"), key=len):
_saves_mkdir(_saves_dest(roots, key), entries[key]["mode"])
for root in roots.values():
_saves_mkdir(root)
for key, e in sorted(entries.items()):
if e["kind"] != "file":
continue
dest = _saves_dest(roots, key)
_saves_mkdir(os.path.dirname(dest))
try:
s = os.lstat(dest)
uid, gid, mode = s.st_uid, s.st_gid, st_.S_IMODE(s.st_mode)
except FileNotFoundError:
ps = _saves_parent_stat(os.path.dirname(dest))
uid, gid, mode = ps.st_uid, ps.st_gid, (e["mode"] & 0o777) | 0o600
staged = e["staged"]
os.chmod(staged, mode)
_saves_chown(staged, uid, gid)
if e.get("mtime"):
os.utime(staged, (e["mtime"], e["mtime"]))
os.replace(staged, dest)
restored += 1
keep = {"/".join(k) for k, e in entries.items() if e["kind"] == "file"}
max_file = manifest.get("max_file")
current = collect_saves(roots, max_file if isinstance(max_file, int) and max_file > 0 else SAVES_MAX_FILE)
for arc, path, _s in current["files"]:
if arc not in keep:
try:
os.remove(path)
removed += 1
except OSError:
pass
return {"restored": restored, "removed": removed}
def saves_context(args):
"""(pkg, dep, roots) of an installed Quest game (its roots are real folders, or missing)."""
pkg = check_pkg(args.get("package"))
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
if dep.get("kind") in ("pcvr", "linux"):
raise AgentError("save backups are for Quest (Android) games only")
base = dep["base"]
if not os.path.isdir(os.path.join(base, "lepton-app")) and not os.path.isdir(os.path.join(base, "lepton-data")):
raise AgentError(f"{pkg}'s files are missing at {base} (storage not mounted?)")
roots = saves_roots(base, pkg)
data = os.path.realpath(os.path.join(base, "lepton-data"))
for r in roots.values():
if os.path.islink(r) or (os.path.lexists(r) and not _inside(os.path.realpath(r), data)):
raise AgentError(f"{r} is a link: refusing")
return pkg, dep, roots
class _SavesLock:
"""The game's launch lock (launch.sh holds it while the game runs): a backup or restore never overlaps a play
session, and a Play meanwhile is ignored like a double launch."""
def __init__(self, dep):
self.path, self.fd = os.path.join(dep["base"], ".launch.lock"), None
def __enter__(self):
if fcntl is None:
return self
self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(self.fd)
self.fd = None
raise AgentError("the game is running") from None
return self
def __exit__(self, *exc):
if self.fd is not None:
os.close(self.fd)
self.fd = None
def saves_running(dep):
return bool(dep.get("appid")) and container_running(dep["appid"])
def _saves_in_namespace(op, args):
"""Run op again inside podman's user namespace (stdio inherited); None = run it in this process."""
owner = as_owner()
if not owner or os.environ.get("FRAMEPORT_SAVES_NS") == "1":
return None
env = dict(os.environ, FRAMEPORT_SAVES_NS="1")
return subprocess.run(owner + [sys.executable or "python3", os.path.abspath(__file__), "_saves_ns", op,
json.dumps(args)], env=env).returncode
def saves_backup_session(args, out, err):
"""`_backup_saves`: the archive on `out`, one JSON line (ok + summary, or error) on `err`; exit 0 = complete."""
def emit(obj):
err.write(json.dumps(obj) + "\n")
err.flush()
def here():
pkg, dep, roots = saves_context(args)
manifest = write_saves_tar(out, pkg, roots, dep, int(args.get("max_file") or SAVES_MAX_FILE),
int(args.get("max_total") or SAVES_MAX_TOTAL))
out.flush()
emit({"ok": True, "result": saves_summary(manifest)})
return 0
try:
return _saves_run("backup", args, here)
except AgentError as exc:
emit({"ok": False, "error": str(exc)})
return 1
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
def saves_restore_session(args, inp, out):
"""`_restore_saves`: the archive on `inp` (or the rollback copy with args.rollback), one JSON line on `out`."""
def emit(obj):
out.write(json.dumps(obj) + "\n")
out.flush()
def here():
emit({"ok": True, "result": restore_saves(args, inp)})
return 0
try:
return _saves_run("restore", args, here)
except AgentError as exc:
emit({"ok": False, "error": str(exc)})
return 1
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
def _saves_run(op, args, here):
"""Outside podman's namespace: check the game, take its launch lock, then run `op` in the namespace (stdio
inherited). Inside it (FRAMEPORT_SAVES_NS=1), or without podman (tests): run `here` in this process."""
if os.environ.get("FRAMEPORT_SAVES_NS") == "1":
return here()
_pkg, dep, _roots = saves_context(args)
if saves_running(dep):
raise AgentError("the game is running")
with _SavesLock(dep):
code = _saves_in_namespace(op, args)
return here() if code is None else code
def restore_saves(args, inp):
"""Stage (and check) the whole archive first, keep the current saves as <base>/saves-before-restore.tar.gz (one
copy), then swap the files in. A failure while swapping puts the rollback copy back. args.rollback restores that
copy instead of `inp` (the current saves become the new rollback copy: undo of the undo)."""
pkg, dep, roots = saves_context(args)
base = dep["base"]
rollback = os.path.join(base, SAVES_ROLLBACK)
staging = tempfile.mkdtemp(prefix=".saves-restore-", dir=base)
limits = (SAVES_RESTORE_MAX_FILE, SAVES_RESTORE_MAX_TOTAL, SAVES_RESTORE_MAX_MEMBERS)
try:
if args.get("rollback"):
if not os.path.isfile(rollback):
raise AgentError("there's no copy of the saves from before the last restore")
with open(rollback, "rb") as f:
entries, manifest = stage_saves_archive(f, staging, *limits)
else:
entries, manifest = stage_saves_archive(inp, staging, *limits)
if manifest.get("package") != pkg:
raise AgentError(f"this backup is of {manifest.get('package')}, not {pkg}")
check_saves_destinations(roots, entries)
tmp = rollback + ".tmp"
with open(tmp, "wb") as f:
write_saves_tar(f, pkg, roots, dep, SAVES_MAX_FILE, 1 << 62)
os.replace(tmp, rollback)
try:
counts = apply_staged_saves(roots, entries, manifest)
except Exception as exc: # noqa: BLE001
undo = os.path.join(staging, "undo")
os.mkdir(undo)
try:
with open(rollback, "rb") as f:
back, back_manifest = stage_saves_archive(f, undo)
apply_staged_saves(roots, back, back_manifest)
state = "the saves from before were put back"
except Exception as again: # noqa: BLE001
state = f"putting the saves from before back failed too ({again}); they're in {rollback}"
raise AgentError(f"restoring failed ({exc}); {state}") from exc
return {**counts, "package": pkg, "rollback": rollback, "rollback_size": os.path.getsize(rollback),
"backup_created": manifest.get("created"), "backup_version": manifest.get("version")}
finally:
shutil.rmtree(staging, ignore_errors=True)
def _saves_info(found):
return {"files": len(found["files"]), "bytes": sum(s.st_size for _a, _p, s in found["files"]),
"skipped": [s for s in found["skipped"] if s["reason"] in ("size", "unreadable")]}
def cmd_saves_info(args):
"""What a save backup of the game would hold now (files, bytes, large files left out), whether the game runs, and
the rollback copy of the last restore."""
pkg, dep, roots = saves_context(args)
max_file = int(args.get("max_file") or SAVES_MAX_FILE)
owner = as_owner()
if owner: # the app's files may belong to mapped ids: count them in podman's namespace
p = run(owner + [sys.executable or "python3", os.path.abspath(__file__), "_saves_ns", "info",
json.dumps(args)], env=dict(os.environ, FRAMEPORT_SAVES_NS="1"))
try:
info = json.loads(p.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
raise AgentError(f"couldn't read {pkg}'s saves: {p.stderr.strip()[-300:]}") from None
else:
info = _saves_info(collect_saves(roots, max_file))
rollback = os.path.join(dep["base"], SAVES_ROLLBACK)
info.update(package=pkg, running=saves_running(dep), version=_dep_version(dep),
rollback={"size": os.path.getsize(rollback), "time": os.path.getmtime(rollback)}
if os.path.isfile(rollback) else None)
return info
def cmd_saves_rollback_delete(args):
"""Remove the copy of the saves from before the last restore."""
_pkg, dep, _roots = saves_context(args)
path = os.path.join(dep["base"], SAVES_ROLLBACK)
if os.path.isfile(path):
os.remove(path)
return {"removed": True}
return {"removed": False}
def saves_ns_main(op, raw):
"""`_saves_ns <op> '<json>'`: the part of a save command that runs inside `podman unshare`."""
os.environ["FRAMEPORT_SAVES_NS"] = "1"
args = json.loads(raw)
if op == "backup":
return saves_backup_session(args, sys.stdout.buffer, sys.stderr)
if op == "restore":
return saves_restore_session(args, sys.stdin.buffer, sys.stdout)
if op == "info":
try:
_pkg, _dep, roots = saves_context(args)
print(json.dumps(_saves_info(collect_saves(roots, int(args.get("max_file") or SAVES_MAX_FILE)))))
return 0
except AgentError as exc:
print(str(exc), file=sys.stderr)
return 1
return 2
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
@@ -7918,6 +8501,12 @@ def main():
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_round":
print(json.dumps(overlay_autostart_round()), flush=True)
return
if len(sys.argv) >= 3 and sys.argv[1] == "_backup_saves":
return saves_backup_session(json.loads(sys.argv[2]), sys.stdout.buffer, sys.stderr)
if len(sys.argv) >= 3 and sys.argv[1] == "_restore_saves":
return saves_restore_session(json.loads(sys.argv[2]), sys.stdin.buffer, sys.stdout)
if len(sys.argv) >= 4 and sys.argv[1] == "_saves_ns":
return saves_ns_main(sys.argv[2], sys.argv[3])
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
return monitor_session(sys.stdin, sys.stdout)
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
+40
View File
@@ -0,0 +1,40 @@
# Announcements
`current.md` is the one announcement FramePort shows as a pop-up in the app. Edit it on `main`: the app reads it
from GitHub `main` (raw.githubusercontent.com) at most every 6 hours, so a push reaches everyone without a release.
## Who sees it
- **Only people who already used FramePort before the announcement's `date`.** A new install never shows one, and
nor does an announcement published before FramePort was installed.
- **Once.** Closing it in any way (the X, a click outside it, Escape, or its button) dismisses it for good. The app
remembers dismissals in its settings (`announce.dismissed`).
- **A new announcement = a new `id`.** Fixing a typo with the same `id` doesn't show it again to anyone who closed it.
A new `id` shows to everyone who used FramePort before its `date`, whether they closed the earlier one or not.
## The file
```markdown
---
active: true # false = nothing is shown (the fields are still checked)
id: meetup-2026-11 # letters, digits, . _ - — change it for every new announcement
title: Steam Frame meetup on Discord # required
date: 2026-11-02T18:00:00Z # required: when it was published (UTC; a bare date = midnight UTC)
image: announcements/meetup.png # optional: a file in this folder, or an https URL (PNG, JPEG, WebP, GIF; ≤ 5 MiB)
image_alt: The meetup poster # optional: what the picture shows (screen readers)
button: Join the meetup # optional: an action button, with url
url: https://example.com/meetup
expires: 2026-11-30 # optional: not shown after this day (UTC, inclusive)
---
The body, in Markdown: **bold**, *italic*, `code`, [links](https://frameport.app/), lists, headings.
```
The body is Markdown (no HTML). A `date` in the future works as a schedule: nobody sees it before then.
Images look best at about 2:1 (e.g. 1200×600). The pop-up is 600 px wide and shows between 140 and 320 px of the
picture's height.
`pytest tests/test_announcements.py` checks this file, so a broken announcement fails CI instead of silently showing
nothing.
To turn announcements off for testing: `FRAMEPORT_NO_ANNOUNCEMENTS=1`.
+16
View File
@@ -0,0 +1,16 @@
---
# The announcement FramePort shows once (announcements/README.md). Switched off: set active: true.
active: false
id: example-2026-10
title: Example announcement
date: 2026-10-15T12:00:00Z
# image: announcements/example.png
# image_alt: What the picture shows
# button: Read more
# url: https://frameport.app/changelog/
# expires: 2026-11-30
---
This is where the text goes, in **Markdown**: *emphasis*, `code`, [links](https://frameport.app/) and lists.
- Keep it short: one or two paragraphs.
- Change `id` for every new announcement.
+3 -3
View File
@@ -10,14 +10,14 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/libfp_vk.so
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
86261c4d7c4b78af5e182b81ae172f6d5fd5b12d8dc6194a876eee08b589d3ce ./arm64-v8a/libopenxr_loader_generic.so
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
1a3b250835798f46049f527a6b32f9d0b33a2a976006d08f5997bfccc215ee89 ./armeabi-v7a/libopenxr_loader_generic.so
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
package: com.CreaTeam.Sclerosis
title: Sclerosis
status: works
notes: Copy the "Amnesia The Dark Descent" folder from a Windows/Proton install to "FramePort/com.CreaTeam.Sclerosis/lepton-data/external".
Default hand positions are rather askew and should be amended via VR Controller Mappings in Steam itself. FOV
warping effects are EXTREMELY disorienting in VR, beware. Otherwise seems to work without issue.
tested_version: 1.10.7
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-16'
known_good_sha256: e17421ad9894f5e57fb9b1b8110544715166756ef924b5a86831f6f20e70a6d5
app: 1.2.0
overport_cli: 1.2.5
issue: 339
source_hint: Sclerosis_v1.10.7
@@ -0,0 +1,24 @@
package: com.Curve.LawnMowingSimulatorVR
title: Lawn Mowing Simulator VR
status: issues
notes: Left eye textures flicker only when mowing in specific areas.
tested_version: 1.0.50
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
pose_consistency: 1
hide_space_warp: 1
verified:
date: '2026-10-16'
known_good_sha256: 2653e9b1dfbd11d607b5dd17b9c07ab19d9e98e1004ef9b14cb52867b5f959dc
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 344
source_hint: com.Curve.LawnMowingSimulatorVR
@@ -0,0 +1,10 @@
package: com.FlatHeadStudio.DeadlyDelivery
title: Deadly Delivery
status: unsupported
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
tested_version: 1.0.30
engine: Unity
xr: OpenXR
verified:
date: '2026-10-15'
issue: 322
@@ -0,0 +1,22 @@
package: com.HypetrainDigitalLtd.Breathedge
title: Breathedge VR
status: works
notes: Loading fade is broken and only shows a small square but the game works!
tested_version: 1.0.7
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 4975a32fbf36b4d417142a944bf7d771c18a184dc0320b5937ac2c67bd013575
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 347
source_hint: Breathadge
@@ -0,0 +1,28 @@
package: com.Miragesoft.RealVrFishingQ
title: Real VR Fishing
status: issues
notes: Seems to be almost 100% functional with meta permissions enabled. In the new Mixed Reality mode, the room
scan doesn't seem to do anything and I can't seem to manually place anything either.
tested_version: '3.06096'
engine: Unity
xr: OpenXR
frame:
- frame.vivox_audio_route
- frame.unity_text_input
- frame.oculusos
- frame.meta_permissions
device:
- device.text_input_window
adapter:
haptic_fix: 1
scene_emul: 1
controller_models: 1
verified:
date: '2026-10-16'
known_good_sha256: 8b17dd26ef0486618ec006f9d948a60dabea4d7763bbe6061db07ee8aad27c76
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 91
issue: 333
source_hint: Real VR Fishing
@@ -0,0 +1,25 @@
package: com.RealitiesIO.puzzlingPlaces
title: Puzzling Places
status: issues
notes: Works fine, but pointers dont show well on main menu, easy to work around.
tested_version: '2.14'
engine: Unreal
xr: OpenXR
overport_extra:
- patch_disable_space_warp
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.unreal_skip_obb_check
- frame.unreal_ovrp_entrypoints
- frame.hw_video_decode
adapter:
equirect_emul: 1
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 8d9bb64b0af4c34269fffb64c5ef7515d6ba4715f5234f95517975b8e2b56c00
app: 1.2.0
overport_cli: 1.2.5
issue: 359
source_hint: Puzzling Places
@@ -0,0 +1,22 @@
package: com.SchellGames.LostRecipes
title: Lost Recipes
status: works
tested_version: 1.0.1+273175
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.unreal_skip_obb_check
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: f071a1deea3e3b6dce0c3a923127f540a48b88532106536c1838f496e9d3e6c9
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 340
source_hint: Lost Recipes
+12 -10
View File
@@ -1,22 +1,24 @@
package: com.UNIVRS.Freedom
title: Freedom
status: issues
notes: Volume can't be changed in the game.
title: 'Attack on Titan VR: Unbreakable'
status: works
notes: Keep the extra files that come with the APK and OBB next to the OBB (they hold the audio). Cutscenes are
detached from the head but still viewable.
tested_version: 1.0.2950
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.asset_files
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-10'
known_good_sha256: 24662272c4cbe5dc7c4f3c803bdb9dead9661ac3d59bf80cca2e260256acc4bc
app: 0.12.0
date: '2026-10-16'
known_good_sha256: 1b3ced1a24b3b708f66aba6aa879863b667c0e502ef153d8a8cfa03e529d21fd
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 132
source_hint: AOT VR
frame_build: '20261015.6141207'
agent: 91
issue: 337
source_hint: Aot
+1 -1
View File
@@ -1,5 +1,5 @@
package: com.asg.clockworkdev
title: Clockwork
title: The Last Clockwinder
status: works
tested_version: '1.29'
engine: Unity
+21
View File
@@ -0,0 +1,21 @@
package: com.crytek.climb
title: The Climb
status: works
tested_version: '666.0'
engine: CryEngine
xr: VrApi
frame:
- frame.vrapi_bridge
device_files:
user.cfg: 'r_vr_foveated = 0
'
verified:
date: '2026-10-16'
known_good_sha256: 3899967dd6d3ee0ce3409e94d543405c779e9c282ead29536abe93b3531ca285
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 341
source_hint: TheClimb
@@ -0,0 +1,22 @@
package: com.fasttravelgames.mascot
title: 'Cities: VR'
status: works
notes: Loading screen Cities VR Logo doesn't load right.
tested_version: 1.00.502
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 5537e4e9333a04eda8c1b2f96cdc9572c1bbed52a9d3af42269ba442ce9ba801
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 345
source_hint: CitiesSkylines
@@ -0,0 +1,22 @@
package: com.s3d.ExplodingKittensVR.TestRelease
title: Exploding Kittens VR
status: issues
notes: Requires online for most modes. Practice lobby does work though.
tested_version: 1.4.25779
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 4b0a2348a5553851dcec820835e02c93a1a64927a30533d554943a9b81b3baa0
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 343
source_hint: com.s3d.ExplodingKittensVR.TestRelease
+27
View File
@@ -0,0 +1,27 @@
package: com.schellgames.ieytd2
title: I Expect You To Die 2
status: works
notes: loaded up and played 5 minutes looks fine.
tested_version: 1.0.1.171177 (ReleaseCandidate_1.0.1_QuestStore)
engine: Unity
xr: VrApi
frame:
- frame.unity_no_msaa
- frame.unity_oculus_check
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
scale: 2.0
verified:
date: '2026-10-16'
known_good_sha256: fc626ec62e49e19210a188d90f94c370e956d0de729cbd04cbe8866430d77730
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 358
source_hint: Expect
+24
View File
@@ -0,0 +1,24 @@
package: jp.co.hit_point.vneko
title: Neko Atsume Purrfect
status: works
tested_version: 2.3.0
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
adapter:
controller_models: 1
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-16'
known_good_sha256: da0196efa495d6ea7b34be2cea5b279acf23489134bb1dd1acef7cf54aa57da6
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 338
source_hint: jp.co.hit_point.vneko
+18
View File
@@ -0,0 +1,18 @@
package: org.crisiszone.vr
title: Crisis Zone VR
status: works
notes: Played the beginning of the first mission without issues.
tested_version: 0.1.4
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
verified:
date: '2026-10-15'
known_good_sha256: 747d1e6613ae443ef03e509b509e4ec89039eec1aecf3689643db4057e53266a
app: 1.2.1.dev433
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 91
issue: 331
source_hint: CrisisZoneVR-v0.1.4-quest3
+18
View File
@@ -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
+6 -6
View File
@@ -1,9 +1,9 @@
package: xyz.TheAviary.Vermillion
title: Vermillion
status: issues
notes: Runs. The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in
front of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub
#200). Not yet confirmed in the headset with this patch.
status: works
notes: "The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in front
of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub #200).
Confirmed in the headset by Klownicle."
tested_version: 1.7.2
engine: Unity
xr: OpenXR
@@ -16,10 +16,10 @@ adapter:
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-11'
date: '2026-10-15'
app: 1.0.1
frame_build: '20261007.6125817'
agent: 79
issue: 200
updated: '2026-10-12'
updated: '2026-10-15'
source_hint: Vermillion - VR Painting
+12 -7
View File
@@ -11,6 +11,16 @@ signatures:
severity: error
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
suggest: [frame.asset_files_stub]
- id: meta-entitlement
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
severity: error
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
doesn't get around such checks.
suggest: []
- id: lua-lightuserdata-pointer
pattern: 'bad light userdata pointer'
severity: fatal
@@ -101,13 +111,8 @@ signatures:
suggest: [frame.adapter]
- id: cylinder-menu-layers-rejected
pattern: 'xrEndFrame failed -16\b'
severity: info
# PreyVR (GitHub #306): one rejected frame at the start of every session (its space isn't locatable yet); the black
# menu there had another cause (a 0 Hz refresh rate stopped the game clock), so this alone is no error
diagnosis: The runtime rejected a frame's layers once (XR_ERROR_LAYER_INVALID). At the start of a session that is
harmless. If it repeats every frame the picture stays black; FrameBridge shows a curved (cylinder) screen drawn
to each eye separately (for example the Doom3Quest / PreyVR menu) as one set of flat strips for both eyes to stay
within the runtime's layer limit, so rebuild and reinstall the game with the current FrameBridge.
severity: error
diagnosis: The game draws a curved (cylinder) screen to each eye separately (for example the Doom3Quest / PreyVR menu). The Frame's runtime has no cylinder layers, so FrameBridge shows each as flat quad strips; a per-eye pair makes too many quad layers for one frame and the runtime rejects the whole frame (XR_ERROR_LAYER_INVALID), leaving the menu black while the game runs. FrameBridge from this build shows the left eye's strips to both eyes to halve the count; rebuild and reinstall the game to try it.
suggest: [frame.adapter]
- id: missing-ovr-symbol
pattern: '(UnsatisfiedLinkError|cannot locate symbol) .*"?(ovr_[A-Za-z0-9_]+|ovr[A-Z][A-Za-z0-9]*_ToString)'
+32
View File
@@ -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
+26
View File
@@ -60,6 +60,32 @@
`launch.sh` at the new folder and only then deletes the old copy; on the same filesystem it renames instead.
Moving a game whose Lepton data holds absolute paths, or a PC VR prefix, is not yet verified on the device.
## Save backups (Quest games, agent v95, discussion #352)
- A Lepton game's saves are two folders of `<base>/lepton-data`: `internal/<pkg>` (= `/data/data/<pkg>`:
`shared_prefs/`, `files/`, `databases/`; Unreal's `files/UE4Game|UnrealGame/.../Saved` often lives here) and
`external/Android/data/<pkg>` (= `/sdcard/Android/data/<pkg>`: Unity's `files/`, Unreal's external `Saved`). On the
dev Frame (2026-10-16) every file in them belongs to `steamos` (Lepton's `keep-id:uid=0`: the container's root and
app = steamos), mostly mode 600/660 in 700/771/775/2771 folders; Lepton's links (`Android/obb/<pkg>`, `Movies`,
`Download`, `Documents`) sit outside these two folders.
- Agent `_backup_saves '<json>'` streams a tar.gz of both (members `internal/…`, `external/…`, PAX, numeric owners and
modes; last member `frameport-saves.json` = manifest with size + sha256 per file and what was left out) on the SSH
exec channel's stdout, the result JSON on stderr. Left out: folders named cache/code_cache/no_backup/il2cpp/
UnityShaderCache/(Vulkan)ProgramBinaryCache/GPUCache/… at any depth, Unreal `Saved/Logs|Crashes`, FrameBridge's
`fb_snap_*`/`framebridge*` files, `.obb/.apk/.foz/.mp4/.mkv/.webm/.mov`, symlinks and special files, and files over
64 MiB (big downloaded content; e.g. BattleTalent's `files/Video`). More than 4 GiB of saves is refused.
- `_restore_saves '<json>'` reads the archive from stdin and treats it as untrusted: only regular files and folders
under `internal/`/`external/` (no `..`, absolute paths, links, devices, duplicates), ≤1 GiB per file / 8 GiB in all,
each file's sha256 must match the manifest, the manifest's package must be the game. Everything is staged in
`<base>/.saves-restore-*` first, a path that would pass through a link on the Frame is refused, then the current
saves are written to `<base>/saves-before-restore.tar.gz` (one copy; `rollback: true` restores it and keeps the
replaced saves as the new copy) and each file is renamed into place. Existing files keep their owner and mode, new
ones get their folder's owner and the backup's mode (at least u+rw); save files the backup doesn't have are removed
(left-out files stay). A failure while swapping puts the rollback copy back.
- Both run their file work in `podman unshare` (the agent re-runs itself as `_saves_ns <op>` with stdio inherited) so
files owned by mapped ids are readable and keep their ids, take the game's launch lock (`<base>/.launch.lock`, a
Play meanwhile is ignored as a double launch) and refuse while its container runs. `saves_info` sizes a backup and
reports the rollback copy, `saves_rollback_delete` removes it.
## OpenXR runtime (as seen by games through overport's loader)
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
KHR_vulkan_enable(2), KHR_opengl_es_enable, KHR_composition_layer_depth, FB_display_refresh_rate, EXT_hand_tracking
+35 -5
View File
@@ -13,11 +13,14 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Arkio | Quest | ✅ Works | |
| Asgard's Wrath 2 | Quest | ✅ Works | |
| Assassin's Creed Nexus | Quest | ✅ Works | |
| Attack on Titan VR: Unbreakable | Quest | ✅ Works | |
| Audica | Quest | ✅ Works | |
| AugmentedEmpire | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
| Batman: Arkham Shadow | Quest | ✅ Works | |
| BattleGlide | Quest | ✅ Works | |
| Battlemarked | Quest | ✅ Works | |
| BattleSisters | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
| Beat Saber | Quest | ✅ Works | |
@@ -27,16 +30,19 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| BlazeRush | Quest | ✅ Works | |
| BodyCombat | Quest | ✅ Works | |
| BONELAB | Quest | ✅ Works | |
| Breathedge VR | Quest | ✅ Works | |
| Carve Snowboarding | Quest | ✅ Works | |
| Caves | Quest | ✅ Works | |
| Clockwork | Quest | ✅ Works | |
| Cities: VR | Quest | ✅ Works | |
| COMPOUND | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Cosmodread | Quest | ✅ Works | |
| Creed | Quest | ✅ Works | |
| Crisis Zone VR | Quest | ✅ Works | |
| cubism | Quest | ✅ Works | |
| Dance Central | Quest | ✅ Works | |
| Deep Cuts | Quest | ✅ Works | |
| Demeo | Quest | ✅ Works | |
| Demeter | Quest | ✅ Works | |
| Dinosaur Island | Quest | ✅ Works | |
| Doom3Quest | Quest | ✅ Works | |
@@ -46,10 +52,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Eleven Table Tennis | Quest | ✅ Works | |
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| Eye of the Temple | Quest | ✅ Works | |
| FrenzyVR | Quest | ✅ Works | |
| Fruit Ninja | Quest | ✅ Works | |
| Fruit Salon | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| Ghost Town | Quest | ✅ Works | |
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
| GORN2 | Quest | ✅ Works | |
| Gravity Lab | Quest | ✅ Works | |
@@ -57,29 +65,38 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| H.U.N.T | Quest | ✅ Works | |
| I Am Cat | Quest | ✅ Works | |
| I Am Monkey | Quest | ✅ Works | |
| I Expect You To Die 2 | Quest | ✅ Works | |
| IEYTD3 | Quest | ✅ Works | |
| In Death: Unchained | Quest | ✅ Works | |
| Into The Radius 2 | Quest | ✅ Works | |
| Job Simulator | Quest | ✅ Works | |
| Journey of the Gods | Quest | ✅ Works | |
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
| Kill It With Fire VR | Quest | ✅ Works | |
| Ladder | Quest | ✅ Works | |
| Lambda1VR | Quest | ✅ Works | |
| LEGO® Bricktales | Quest | ✅ Works | |
| Lost Recipes | Quest | ✅ Works | |
| Lucky's Tale | Quest | ✅ Works | |
| Maestro | Quest | ✅ Works | |
| Marvel's Deadpool VR | Quest | ✅ Works | |
| Marvel's Iron Man VR | Quest | ✅ Works | |
| Max Mustard | Quest | ✅ Works | |
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
| Metro Awakening | Quest | ✅ Works | |
| Mini Motor Racing X | Quest | ✅ Works | |
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
| Moss | Quest | ✅ Works | |
| Moss: Book II | Quest | ✅ Works | |
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
| Nano | Quest | ✅ Works | |
| Neko Atsume Purrfect | Quest | ✅ Works | |
| NEX Player | Quest | ✅ Works | |
| NFL PRO ERA II | Quest | ✅ Works | |
| NOPE CHALLENGE | Quest | ✅ Works | |
| Ocean Rift | Quest | ✅ Works | |
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
| palazzo_santacruz | Quest | ✅ Works | |
| Path of the Warrior | Quest | ✅ Works | |
| Pistol Whip | Quest | ✅ Works | |
@@ -97,9 +114,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Sclerosis | Quest | ✅ Works | |
| Shadow Point | Quest | ✅ Works | |
| Shoot The Zombirds VR | Quest | ✅ Works | |
| Shores of Loci | Quest | ✅ Works | |
| SilentSlayer | Quest | ✅ Works | |
| Smash Drums | Quest | ✅ Works | |
| Sniper Elite VR | Quest | ✅ Works | |
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
| Space Pirate Trainer Quest | Quest | ✅ Works | |
@@ -112,9 +132,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| SUPERHOT VR | Quest | ✅ Works | |
| SynthRiders | Quest | ✅ Works | |
| Taskmaster | Quest | ✅ Works | |
| Tea For God | Quest | ✅ Works | |
| TetrisEffect | Quest | ✅ Works | |
| The Boys VR | Quest | ✅ Works | |
| The Climb | Quest | ✅ Works | |
| The Climb 2 | Quest | ✅ Works | |
| The Last Clockwinder | Quest | ✅ Works | |
| The Room VR | Quest | ✅ Works | |
| The Tale of Onogoro | Quest | ✅ Works | |
| Time Crisis II VR (Test) | Quest | ✅ Works | |
@@ -128,6 +151,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Vader Immortal: Episode II | Quest | ✅ Works | |
| Vader Immortal: Episode III | Quest | ✅ Works | |
| Vampire Survivors | Quest | ✅ Works | |
| Vendetta Forever | Quest | ✅ Works | |
| Vermillion | Quest | ✅ Works | |
| VR HOT Quest | Quest | ✅ Works | |
| VR4 | Quest | ✅ Works | |
| Walkabout Mini Golf | Quest | ✅ Works | |
@@ -141,26 +166,31 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
| Exploding Kittens VR | Quest | ⚠️ Works with issues | Requires online for most modes. |
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
| Lawn Mowing Simulator VR | Quest | ⚠️ Works with issues | Left eye textures flicker only when mowing in specific areas. |
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
| Puzzling Places | Quest | ⚠️ Works with issues | Works fine, but pointers dont show well on main menu, easy to work around. |
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
| 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 |
| The Thrill Of The Fight | Quest | ⚠️ Works with issues | Right eye doesn't draw the opponent or the referee. |
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
+16
View File
@@ -167,6 +167,11 @@ 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
PC**).
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
dev build…**. The next release then arrives as a normal update.
@@ -216,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
@@ -254,12 +267,15 @@ 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) |
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
| `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 |
+52 -52
View File
@@ -27,7 +27,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Copy OVRPlugin if VrApi is present](#patch_copy_ovrplugin_vrapi) | `patch_copy_ovrplugin_vrapi` | Patches from OVRPort | default |
| [Disable controller tracking offset](#patch_disable_controller_offset) | `patch_disable_controller_offset` | Patches from OVRPort | |
| [Disable Meta XR Audio telemetry](#patch_disable_meta_xr_audio_telemetry) | `patch_disable_meta_xr_audio_telemetry` | Patches from OVRPort | |
| [Disable application space warp if used](#patch_disable_space_warp) | `patch_disable_space_warp` | Patches from OVRPort | 3 |
| [Disable application space warp if used](#patch_disable_space_warp) | `patch_disable_space_warp` | Patches from OVRPort | 4 |
| [Fix minimal Android SDK](#patch_fix_min_android_sdk) | `patch_fix_min_android_sdk` | Patches from OVRPort | default |
| [Fix UE4 crash with Unity stub](#patch_fix_unreal_crash) | `patch_fix_unreal_crash` | Patches from OVRPort | default |
| [Force enable passthrough](#patch_force_passthrough) | `patch_force_passthrough` | Patches from OVRPort | 3 |
@@ -39,7 +39,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 38 |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 42 |
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
@@ -47,7 +47,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 1 |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 2 |
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 5 |
@@ -56,58 +56,58 @@ 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 | 19 |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 24 |
| [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 |
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 3 |
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 17 |
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
| [Trace Meta platform requests (diagnostics)](#frame-ovr_trace) | `frame.ovr_trace` | Steam Frame patches | |
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 4 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 1 |
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 23 |
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 24 |
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 1 |
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 9 |
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 10 |
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 12 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 102 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 13 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 29 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 108 |
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
| [Unreal: match multiview depth to the eye size](#frame-unreal_multiview_depth) | `frame.unreal_multiview_depth` | Steam Frame patches | 1 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 3 |
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 5 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 7 |
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 1 |
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 3 |
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
| [OpenGL ES: shader fixes (Vulkan layer under Zink)](#frame-zink_shader_fix) | `frame.zink_shader_fix` | Steam Frame patches | 1 |
| [Pointer start](#adapter-aim_forward) | `adapter.aim_forward` | Game settings | |
| [Pointer angle](#adapter-aim_pitch) | `adapter.aim_pitch` | Game settings | |
| [Pointer turn](#adapter-aim_yaw) | `adapter.aim_yaw` | Game settings | |
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 4 |
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 5 |
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 6 |
| [360° detail](#adapter-equirect_face) | `adapter.equirect_face` | Game settings | |
| [360° picture orientation](#adapter-equirect_flip) | `adapter.equirect_flip` | Game settings | |
| [360° video frame rate limit](#adapter-equirect_fps) | `adapter.equirect_fps` | Game settings | |
@@ -127,9 +127,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 81 |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 90 |
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
| [Extra diagnostics (log)](#adapter-layer_debug) | `adapter.layer_debug` | Game settings | |
| [Drop broken layers](#adapter-layer_fix) | `adapter.layer_fix` | Game settings | |
@@ -137,7 +137,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unity frame loop: wait for skipped frames](#adapter-ovrp_begin_gate) | `adapter.ovrp_begin_gate` | Game settings | 1 |
| [Steadier hands (older Unity)](#adapter-ovrp_hold_physics) | `adapter.ovrp_hold_physics` | Game settings | 1 |
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 1 |
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 14 |
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
@@ -146,9 +146,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 5 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 9 |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 11 |
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
@@ -170,12 +170,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [OpenGL ES shader fixes](#adapter-zink_shader_fix) | `adapter.zink_shader_fix` | Game settings | 1 |
| [Keep the game's saved resolution in step](#device-config_sync) | `device.config_sync` | Files and settings on the Frame | 1 |
| [Show as VR or as a flat window](#device-display_mode) | `device.display_mode` | Files and settings on the Frame | |
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 2 |
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 3 |
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 3 |
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 105 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 111 |
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
@@ -284,7 +284,7 @@ Turns off Meta's frame-rate trick, which causes glitches or hangs outside Quest
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Suggested (on):** Very heavy game (… GiB) using OVRPlugin: application space warp causes artifacts/hangs off-Quest.
- **Offered (off):** Enable if the game shows warping artifacts or hangs (uses OVRPlugin space warp).
- **Catalog games (3):** Asgard's Wrath 2, Batman: Arkham Shadow, Marvel's Deadpool VR.
- **Catalog games (4):** Asgard's Wrath 2, Batman: Arkham Shadow, Marvel's Deadpool VR, Puzzling Places.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
> For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes (for example Asgard's Wrath 2, Batman: Arkham Shadow).
@@ -420,7 +420,7 @@ Stops some Unreal games from closing themselves right after starting.
- `patch_remove_unreal_force_quit`
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
- **Catalog games (38):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 30 more.
- **Catalog games (42):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 34 more.
- **Suggested by launch-test signatures:**
- `force-quit` (error): The game quit itself right after starting.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
@@ -511,7 +511,7 @@ FramePort's layer between the game and the Steam Frame: fills in what the Frame
- **Suggested (on):** Required for every OpenXR/overport build on the Frame.
- **Suggested by launch-test signatures:**
- `cube-swapchain-refused` (fatal): The game asked for a cube-map swapchain (for example an OVROverlay skybox or loading cube), which the Frame's runtime doesn't have.
- `cylinder-menu-layers-rejected` (info): The runtime rejected a frame's layers once (XR_ERROR_LAYER_INVALID).
- `cylinder-menu-layers-rejected` (error): The game draws a curved (cylinder) screen to each eye separately (for example the Doom3Quest / PreyVR menu).
- `graphics-requirements-missing` (fatal): The game can't start VR on the Frame; rebuild it with the current FramePort.
- `swapchain-rect-invalid` (fatal): The runtime rejects the game's frames, so it stops drawing (for example stuck at a "Waiting" box).
- `time-conversion` (warning): The Frame's runtime lacks timespec time conversion; the current adapter build emulates it.
@@ -526,7 +526,7 @@ Lets the game find extra content it keeps next to its data (for example Star War
- `frame.asset_files`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The game asks Meta's platform for asset files and its data has … content file(s) (*.pak) that OVRPort's loader doesn't report.
- **Catalog games (1):** Star Wars: Tales from the Galaxy's Edge.
- **Catalog games (2):** Attack on Titan VR: Unbreakable, Star Wars: Tales from the Galaxy's Edge.
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
> Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.
@@ -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 (19):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 11 more.
- **Catalog games (24):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 16 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.
@@ -706,7 +706,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
- `frame.meta_permissions`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
- **Catalog games (2):** Arkio, Demeter.
- **Catalog games (3):** Arkio, Demeter, Real VR Fishing.
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
@@ -749,7 +749,7 @@ Provides stand-ins for Quest system calls some games make at start.
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
- **Offered (off):** … references com.oculus.os.AnalyticsEvent; usually harmless (for example Batman: Arkham Shadow and LEGO Bricktales run without it). Enable if the game aborts with ClassNotFoundException.
- **Catalog games (15):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 7 more.
- **Catalog games (17):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, Neko Atsume Purrfect, NOPE CHALLENGE and 9 more.
- **Suggested by launch-test signatures:**
- `oculus-os-class` (fatal): The game looks up a Quest-only system class and aborts.
- **Source:** [patches/frame/telemetry.py](../src/frameport/patches/frame/telemetry.py)
@@ -838,7 +838,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
- `frame.sdl_clipboard` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
- **Catalog games (4):** Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Catalog games (5):** Crisis Zone VR, Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Suggested by launch-test signatures:**
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
@@ -904,7 +904,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
- `frame.unity_gl_shim`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
- **Catalog games (24):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 16 more.
- **Suggested by launch-test signatures:**
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
@@ -931,7 +931,7 @@ Turns off a smoothing setting that crashes some older Unity games on the Frame.
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Legacy VrApi Unity game on GLES with MSAA (… quality levels): multisampled render-to-texture hangs the Frame's GL driver (for example Sniper Elite VR).
- **Offered (off):** GLES Unity game with MSAA on (… quality levels); enable if it hangs the GPU ('zink: DEVICE LOST').
- **Catalog games (9):** Audica, BattleSisters, Eye of the Temple, IEYTD3, Jurassic World Aftermath Collection, Mini Motor Racing X, Sniper Elite VR, SUPERHOT VR and 1 more.
- **Catalog games (10):** Audica, BattleSisters, Eye of the Temple, I Expect You To Die 2, IEYTD3, Jurassic World Aftermath Collection, Mini Motor Racing X, Sniper Elite VR and 2 more.
- **Suggested by launch-test signatures:**
- `zink-device-lost` (fatal): The GPU hung, so the game froze or closed.
- **Source:** [patches/frame/unity_no_msaa.py](../src/frameport/patches/frame/unity_no_msaa.py)
@@ -959,7 +959,7 @@ Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D
- `frame.unity_oculus_check` · revision 5
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity with built-in Oculus support: it checks for Meta's system apps before starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).
- **Catalog games (12):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, Jurassic World Aftermath Collection, Mini Motor Racing X and 4 more.
- **Catalog games (13):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, I Expect You To Die 2, Jurassic World Aftermath Collection and 5 more.
- **Suggested by launch-test signatures:**
- `unity-vr-device-none` (fatal): Unity didn't start VR, so the game runs as a hidden 2D app.
- **Source:** [patches/frame/unity_oculus_check.py](../src/frameport/patches/frame/unity_oculus_check.py)
@@ -973,7 +973,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
- `frame.unity_runtime_msaa_off` · revision 2
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
- **Catalog games (29):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 21 more.
- **Suggested by launch-test signatures:**
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
@@ -989,7 +989,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
- `frame.unity_text_input` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
- **Catalog games (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
- **Catalog games (108):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 100 more.
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -&gt; false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -&gt; true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
@@ -1056,7 +1056,7 @@ Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR)
- `frame.unreal_ovrp_entrypoints`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal looks up OVRPlugin function(s) OVRPort's OVRPlugin lacks: ……. Without them Unreal starts no VR.
- **Catalog games (2):** Ghostbusters: Rise of the Ghost Lord, Star Wars Pinball VR.
- **Catalog games (3):** Ghostbusters: Rise of the Ghost Lord, Puzzling Places, Star Wars Pinball VR.
- **Source:** [patches/frame/unreal_ovrp.py](../src/frameport/patches/frame/unreal_ovrp.py)
> Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.
@@ -1092,7 +1092,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
- `frame.unreal_skip_obb_check` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
- **Catalog games (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
- **Catalog games (7):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, Puzzling Places, Ragnarock.
- **Suggested by launch-test signatures:**
- `unreal-obb-check-stuck` (fatal): The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never starts VR (for example Contractors, GitHub #159).
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
@@ -1158,7 +1158,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
- `frame.vivox_audio_route` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
- **Catalog games (1):** Dungeons of Eternity.
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
- **Suggested by launch-test signatures:**
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
@@ -1189,7 +1189,7 @@ Lets games built on Meta's oldest VR interface run on the Frame.
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The engine calls libvrapi.so directly; OVRPort can't translate it.
- **Conflicts with:** [`frame.vrapi_stub`](#frame-vrapi_stub)
- **Catalog games (3):** BlazeRush, Path of the Warrior, The Climb 2.
- **Catalog games (4):** BlazeRush, Path of the Warrior, The Climb, The Climb 2.
- **Suggested by launch-test signatures:**
- `direct-vrapi` (warning): The game uses Meta's VrApi directly and needs the VrApi bridge.
- `vrapi-frame-rejected` (fatal): The VrApi bridge rejected the game's frame options, so no picture reaches the headset even though frame pacing can appear in the log.
@@ -1295,7 +1295,7 @@ For games that show the headset's own controllers. Takes effect after a reinstal
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Show Steam Frame controllers".
- **Catalog games (4):** Ocean Rift, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
- **Catalog games (6):** Neko Atsume Purrfect, Ocean Rift, Real VR Fishing, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
- **Suggested by launch-test signatures:**
- `render-model-requested` (info): The game shows Quest controllers (or none) because the Frame doesn't provide controller models.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1340,7 +1340,7 @@ Video players' virtual theaters and 360° videos would otherwise stay black.
- **Options:**
- Value: int, default `0`.
- Game settings → 360° pictures and video → "Show 360° backgrounds and videos".
- **Catalog games (5):** 4XVR Video Player, Into The Radius 2, Pinball FX VR, Riven, Tea For God.
- **Catalog games (6):** 4XVR Video Player, Into The Radius 2, Pinball FX VR, Puzzling Places, Riven, Tea For God.
- **Suggested by launch-test signatures:**
- `equirect-layers-dropped` (warning): The game's 360° pictures (for example a video player's theater or 360° videos) are missing because the Frame's runtime can't show equirect layers.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1621,7 +1621,7 @@ Logs details when flat panels stay black. No effect on the game.
- Game settings → Troubleshooting → "Flat panel diagnostics (log)" (advanced).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log). 2 (frameport settings) also checks every draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game clock.
> Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).
### <a id="adapter-haptic_fix"></a>Fix freezes when controllers vibrate
@@ -1633,7 +1633,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
- **Catalog games (81):** AgeOfJoy, Ancient_Dungeon, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber, Caves and 73 more.
- **Catalog games (90):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 82 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
@@ -1662,7 +1662,7 @@ For games whose picture flickers or smears (for example Into The Radius 2).
- **Options:**
- Value: int, default `0`.
- Game settings → Troubleshooting → "Turn off space warp" (advanced).
- **Catalog games (4):** Into The Radius 2, Metro Awakening, Pinball FX VR, The Boys VR.
- **Catalog games (5):** Into The Radius 2, Lawn Mowing Simulator VR, Metro Awakening, Pinball FX VR, The Boys VR.
- **Suggested by launch-test signatures:**
- `space-warp-used` (info): The game uses Meta's space warp (it renders half of its frames plus motion vectors, and the runtime makes up the rest).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1781,7 +1781,7 @@ For games that judder or shimmer while you hold your head still (for example I A
- **Options:**
- Value: int, default `0`.
- Game settings → Picture → "Steadier head tracking" (advanced).
- **Catalog games (1):** I Am Cat.
- **Catalog games (2):** I Am Cat, Lawn Mowing Simulator VR.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> 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).
@@ -1911,7 +1911,7 @@ Higher looks sharper but needs more power; lower runs smoother. 1.0× is the gam
- **Options:**
- Value: float, default `1.0` (always written).
- Game settings → Picture → "Sharpness": slider 0.5–2 in steps of 0.1.
- **Catalog games (4):** 4XVR Video Player, BlazeRush, cubism, Walkabout Mini Golf.
- **Catalog games (5):** 4XVR Video Player, BlazeRush, cubism, I Expect You To Die 2, Walkabout Mini Golf.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.
@@ -1941,7 +1941,7 @@ Mixed-reality games that build their level from your room get a simple room with
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (9):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Ocean Rift, Smash Drums and 1 more.
- **Catalog games (11):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, Ocean Rift and 3 more.
- **Suggested by launch-test signatures:**
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -2272,7 +2272,7 @@ Puts settings files next to the game on the Frame, for example to turn off an un
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns it off (… = 0; The Climb / The Climb 2).
- **Options:** `files`: text, default none — path relative to files/ -&gt; content
- **Catalog games (2):** SUPERHOT VR, The Climb 2.
- **Catalog games (3):** SUPERHOT VR, The Climb, The Climb 2.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Writes config files into the game's Android/data/&lt;package&gt;/files/ on the Frame, to turn off engine features the Frame doesn't support (for example a CryEngine user.cfg with r_variable_rate_shading = 0, as The Climb 2 needs).
@@ -2335,7 +2335,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
- `device.text_input_window`
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
- **Catalog games (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
- **Catalog games (111):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 103 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
+3 -1
View File
@@ -24,6 +24,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
@@ -62,6 +63,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
@@ -168,7 +171,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
| Only some draws visible (for example controllers) + `glGetError 0x502` | multiview shaders used on single-view FBOs | GL shim `gl_hide_multiview=1` (default) |
| Own GLES engine (OpenXR, not VrApi): eye view fine, but HUD / menu / PDA panels black (for example Doom3Quest, GitHub #77; no error in the log) | every shader declares `layout(num_views=2) in;`, also the ones drawn into the engine's single-layer offscreen framebuffers (2D textures/renderbuffers); OVR_multiview makes such draws INVALID_OPERATION and Mesa drops them (`draw_validate.c`), Quest's driver doesn't | `frame.gl_multiview_fbo` (opt-in, experimental: `libfpglmv.so` interposes GLES and draws those cases with a single-view twin of the program; logs `GLMV: twin built: program N -> M`, per-5-s `single-view draws` counters; `gl_mv_debug=1` adds glGetError checks; triage `gl-multiview-twin-failed`). Alternative: the game's own fix (two-layer multiview pool, Xandrix's patch in #77) |
| Team Beef idTech 4 port (PreyVR, GitHub #306): main menu black except the mouse cursor, loading screen fine, 72 fps, no GL error; one game thread at 100 % CPU | the game clock runs 1000 / refresh ms per tic (`USERCMD_MSEC`, from xrGetDisplayRefreshRateFB). The Frame's runtime lacks XR_FB_display_refresh_rate and OVRPort's dispatcher answered 0 Hz: 0 ms tics, the async thread spins (millions of tics/s) and `com_frameTime` stays 0, so the menu GUI stays at its first animation step (everything hidden) | FrameBridge (default, no setting) answers xrGetDisplayRefreshRateFB with the measured rate (1 / predictedDisplayPeriod, else 72 Hz); log `refresh rate: runtime reported 0.0 Hz (result -7), reporting 72.0 Hz`. Found with `gl_mv_debug=2` (`frameport settings <pkg> gl_mv_debug=2`, needs `frame.gl_multiview_fbo`): only 2 draws per frame (backdrop + cursor) and `trace clocks: com_frameTime=0` |
| Upside-down image in a GL bridge game | GL images start at the bottom row | fixed in the bridge (swap angleUp/Down for GL chains) |
| UI panels upside down (AC Nexus) | XrCompositionLayerImageLayoutFB VERTICAL_FLIP unsupported | adapter `flip_emul=1` (default); rotating quads makes them vanish |
| Passthrough black (BAM) | XR_FB_passthrough missing | adapter `passthrough_emul=1` (default) + `patch_force_passthrough` for MR-only games |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 99 KiB

-5
View File
@@ -15,8 +15,6 @@
// * drops requested instance extensions the runtime lacks (e.g. XR_FB_passthrough);
// * retries rejected swapchains with 1 sample / a supported format (swapchain_fix, overport #71);
// * drops composition layers that reference swapchains that failed to create (layer_fix);
// * answers xrGetDisplayRefreshRateFB with the measured display rate when the runtime lacks it or reports 0 Hz
// (session_fixes.c);
// * serves cube swapchains the runtime refuses as GL/Vulkan cube maps and drops their layers (cube_standin, cube_standin.c);
// * optionally requests mutable-format swapchain images (mutable_fix, off by default);
// * optionally swaps left/right images of stereo projection layers (swap_eyes, off by default);
@@ -2179,9 +2177,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
if (layer_debug || refresh_rate > 0) {
HOOK_AS(xrRequestDisplayRefreshRateFB, hook_request_refresh_rate)
}
// The Frame's runtime has no XR_FB_display_refresh_rate, but OVRPort's dispatcher offers it to the game and answers
// 0 Hz without it: the measured rate instead (the runtime's own answer when it has one).
if (instance != XR_NULL_HANDLE) HOOK_AS(xrGetDisplayRefreshRateFB, hook_get_refresh_rate)
if (surface_emul) HOOK_AS(xrCreateSwapchainAndroidSurfaceKHR, hook_xrCreateSwapchainAndroidSurfaceKHR)
if (eye_debug || release_wait) HOOK_AS(xrReleaseSwapchainImage, eye_hook_xrReleaseSwapchainImage)
if (layer_debug || cube_standin) { // counters + cube stand-ins; every other wait/release hook above takes precedence
-15
View File
@@ -1,15 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Display refresh rate for xrGetDisplayRefreshRateFB when the runtime doesn't give one (session_fixes.c). Pure, so the
// host test (tests/fixtures/src/refresh_rate_test.c) can check it.
#pragma once
#include <stdint.h>
// The rate the frame timing shows (1 / predictedDisplayPeriod of the last xrWaitFrame, whole Hz), else the
// refresh_rate setting, else 72 Hz (the Frame's rate for Android apps).
static inline float fb_refresh_rate(int64_t display_period_ns, float setting) {
if (display_period_ns > 0) {
double hz = 1e9 / (double)display_period_ns;
if (hz >= 30.0 && hz <= 240.0) return (float)(int)(hz + 0.5);
}
return setting > 0 ? setting : 72.0f;
}
-21
View File
@@ -9,7 +9,6 @@
// * aim_pitch / aim_yaw / aim_forward: correct the aim pose of controllers (pointer rays) by a fixed offset;
// * refresh_rate: display refresh rate requested at session start (and instead of the app's own requests).
#include "layer_math.h"
#include "refresh_rate.h"
static void emul_on_begin_session(const XrSessionBeginInfo *info); // layer_emul_gl.c
@@ -553,26 +552,6 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_request_refresh_rate(XrSession sessio
return XR_FAILED(result) && refresh_rate > 0 ? XR_SUCCESS : result;
}
// Always on: the Frame's runtime has no XR_FB_display_refresh_rate; OVRPort's dispatcher still offers the extension and
// its xrGetDisplayRefreshRateFB then answers 0 Hz. idTech 4 VR ports (Team Beef's Doom3Quest engine: PreyVR, GitHub
// #306) run their game clock at 1000 / refresh ms per tic: with 0 Hz the tic length is 0, the async thread spins and
// the frame/GUI time never advances (main menu stuck at its first animation step: black, only the cursor). A rate the
// runtime doesn't give is replaced by the one the frame timing shows (refresh_rate.h).
static XRAPI_ATTR XrResult XRAPI_CALL hook_get_refresh_rate(XrSession session, float *rate) {
if (!rate) return XR_ERROR_VALIDATION_FAILURE;
PFN_xrGetDisplayRefreshRateFB fn = (PFN_xrGetDisplayRefreshRateFB)lookup(active_instance, "xrGetDisplayRefreshRateFB");
XrResult result = fn ? fn(session, rate) : XR_ERROR_FUNCTION_UNSUPPORTED;
if ((XR_SUCCEEDED(result) && *rate > 0.0f) || result == XR_ERROR_HANDLE_INVALID) return result;
float runtime = XR_SUCCEEDED(result) ? *rate : 0.0f;
*rate = fb_refresh_rate((int64_t)last_display_period, refresh_rate);
static int logs;
if (logs < 4) {
++logs;
LOG("refresh rate: runtime reported %.1f Hz (result %d), reporting %.1f Hz", runtime, result, *rate);
}
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrBeginSession(XrSession session, const XrSessionBeginInfo *info) {
PFN_xrBeginSession fn = (PFN_xrBeginSession)lookup(active_instance, "xrBeginSession");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
+6 -30
View File
@@ -1,6 +1,6 @@
// FramePort FrameBridge: eye-image snapshots for headless diagnostics (setting snapshot=<seconds>, GLES games only).
// Every <seconds>, at xrEndFrame, the left-eye image the game just submitted (projection layer, view 0: its swapchain
// image and array layer; without a projection layer the first quad/cylinder layer's) is read back on the game's own GL context and written at a quarter of its size as
// image and array layer) is read back on the game's own GL context and written at a quarter of its size as
// /sdcard/Android/data/<package>/files/fb_snap_<n>.ppm (n = 0-7, the oldest is overwritten). Nobody wears the headset
// in a launch test, so the headset view stays black: this shows what the game itself draws (e.g. which screen it is
// stuck on). The game's framebuffer, pack-buffer and pack-alignment bindings are restored afterwards.
@@ -118,27 +118,18 @@ static void snap_end_frame(const XrFrameEndInfo *info) {
int64_t t = (int64_t)now.tv_sec * 1000000000ll + now.tv_nsec;
if (t < next_ns) return;
next_ns = t + (int64_t)snapshot * 1000000000ll;
// The projection layer's left eye; a frame without one (e.g. a menu shown only as a quad or cylinder layer) gives
// its first quad/cylinder layer's image instead.
const XrSwapchainSubImage *sub = NULL;
int type = 0;
for (uint32_t i = 0; i < info->layerCount && !sub; ++i)
const XrCompositionLayerProjection *proj = NULL;
for (uint32_t i = 0; i < info->layerCount && !proj; ++i)
if (info->layers[i] && info->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION &&
((const XrCompositionLayerProjection *)info->layers[i])->viewCount)
sub = &((const XrCompositionLayerProjection *)info->layers[i])->views[0].subImage, type = info->layers[i]->type;
for (uint32_t i = 0; i < info->layerCount && !sub; ++i) {
const XrCompositionLayerBaseHeader *layer = info->layers[i];
if (layer && layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD)
sub = &((const XrCompositionLayerQuad *)layer)->subImage, type = layer->type;
else if (layer && layer->type == XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR)
sub = &((const XrCompositionLayerCylinderKHR *)layer)->subImage, type = layer->type;
}
if (!sub || !snap_load_gl()) return;
proj = (const XrCompositionLayerProjection *)info->layers[i];
if (!proj || !snap_load_gl()) return;
if (!s_eglGetCurrentContext()) {
static int logged;
if (!logged++) LOG("snapshot: no GL context current in xrEndFrame, no snapshot");
return;
}
const XrSwapchainSubImage *sub = &proj->views[0].subImage;
pthread_mutex_lock(&snap_lock);
int i = snap_find(sub->swapchain, 0);
GLuint tex = 0;
@@ -176,21 +167,6 @@ static void snap_end_frame(const XrFrameEndInfo *info) {
s_glDeleteFramebuffers(1, &fb);
while (s_glGetError() != GL_NO_ERROR) {} // our calls must not leave errors for the game to find
if (rgba) {
// brightness of the submitted area: tells a black image from a drawn one without fetching the file
uint64_t sum = 0, n = 0;
unsigned max = 0;
const XrRect2Di *r = &sub->imageRect;
for (int32_t y = r->offset.y; y < r->offset.y + r->extent.height; y += 4)
for (int32_t x = r->offset.x; x < r->offset.x + r->extent.width; x += 4) {
if (x < 0 || y < 0 || (uint32_t)x >= w || (uint32_t)y >= h) continue;
const unsigned char *p = rgba + ((size_t)y * w + (size_t)x) * 4;
unsigned v = p[0] > p[1] ? (p[0] > p[2] ? p[0] : p[2]) : (p[1] > p[2] ? p[1] : p[2]);
sum += v, ++n;
if (v > max) max = v;
}
LOG("snapshot: layer type=%d image %u array=%u rect=%d,%d %dx%d of %ux%u: mean %.1f max %u",
type, tex, sub->imageArrayIndex, r->offset.x, r->offset.y, r->extent.width, r->extent.height, w, h,
n ? (double)sum / (double)n : 0.0, max);
snap_write(rgba, w, h, taken++ % 8);
free(rgba);
}
+6 -277
View File
@@ -16,8 +16,7 @@
// block bindings and default-block uniform values (copied before every such draw), then the original is bound again.
//
// Logcat tag GLMV. Setting gl_mv_debug=1 (libframe_settings.so / framebridge.conf / FRAMEBRIDGE_CONFIG, as the GL
// shim): glGetError after every twin draw, and per-5-s counters. gl_mv_debug=2 (trace) also checks every draw and
// logs the state of the first draws of each 5 s window, failed texture uploads/copies and shader compiles.
// shim): glGetError after every twin draw, and per-5-s counters.
#define _GNU_SOURCE
#include <EGL/egl.h>
#include <GLES3/gl32.h>
@@ -112,27 +111,7 @@
X(void, UniformMatrix2x4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
X(void, UniformMatrix4x2fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
X(void, UniformMatrix3x4fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
X(void, UniformMatrix4x3fv, (GLint, GLsizei, GLboolean, const GLfloat *)) \
TRACE_LIST(X)
// gl_mv_debug=2 (trace): the draw state and texture/shader calls behind black output
#define TRACE_LIST(X) \
X(GLboolean, IsEnabled, (GLenum)) \
X(void, GetBooleanv, (GLenum, GLboolean *)) \
X(GLenum, CheckFramebufferStatus, (GLenum)) \
X(void, GetTexLevelParameteriv, (GLenum, GLint, GLenum, GLint *)) \
X(void, GetTexParameteriv, (GLenum, GLenum, GLint *)) \
X(void, Clear, (GLbitfield)) \
X(void, Viewport, (GLint, GLint, GLsizei, GLsizei)) \
X(void, TexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void *)) \
X(void, TexSubImage2D, (GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, const void *)) \
X(void, CompressedTexImage2D, (GLenum, GLint, GLenum, GLsizei, GLsizei, GLint, GLsizei, const void *)) \
X(void, TexStorage2D, (GLenum, GLsizei, GLenum, GLsizei, GLsizei)) \
X(void, CopyTexImage2D, (GLenum, GLint, GLenum, GLint, GLint, GLsizei, GLsizei, GLint)) \
X(void, CopyTexSubImage2D, (GLenum, GLint, GLint, GLint, GLint, GLint, GLsizei, GLsizei)) \
X(void, GenerateMipmap, (GLenum)) \
X(void, BindTexture, (GLenum, GLuint)) \
X(void, TexParameteri, (GLenum, GLenum, GLint))
X(void, UniformMatrix4x3fv, (GLint, GLsizei, GLboolean, const GLfloat *))
#define DECL(ret, name, args) static ret(*r_##name) args;
REAL_LIST(DECL)
@@ -536,147 +515,6 @@ static void stats(void) {
twin_draws = mv_draws = uniform_sets = 0;
}
// ---------------------------------------------------------------- trace (gl_mv_debug=2, diagnostics)
// Every draw is checked with glGetError (this also consumes errors the game would read), counted per draw
// framebuffer, and the first draws of every 5 s window are logged with the state that decides whether they write
// anything: framebuffer status and views, program, viewport/scissor, colour/depth/stencil/blend state and the texture
// on the active unit. Texture uploads, copies and shader compiles that fail are logged too.
#define TRACE_DETAIL 12 // detailed draws per window
#define TRACE_WINDOWS 40 // windows with detail
#define NTFB 16
struct tfb { GLint fb; unsigned long draws, errors, clears; };
static int trace_detail, trace_windows;
static unsigned long trace_draws, trace_errors, trace_clears;
static GLenum trace_first_error;
static struct tfb tfb[NTFB];
static struct timespec trace_last;
static pthread_mutex_t trace_lock = PTHREAD_MUTEX_INITIALIZER;
static struct tfb *tfb_slot(GLint fb) {
for (int i = 0; i < NTFB; ++i)
if (tfb[i].fb == fb && (tfb[i].draws || tfb[i].clears)) return &tfb[i];
for (int i = 0; i < NTFB; ++i)
if (!tfb[i].draws && !tfb[i].clears) { tfb[i].fb = fb; return &tfb[i]; }
return NULL;
}
static void trace_window(void) { // under trace_lock
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
if (!trace_last.tv_sec) trace_last = now;
if (now.tv_sec - trace_last.tv_sec < 5) return;
trace_last = now;
char buf[700];
int n = 0;
for (int i = 0; i < NTFB && n < (int)sizeof(buf) - 60; ++i)
if (tfb[i].draws || tfb[i].clears)
n += snprintf(buf + n, sizeof(buf) - n, " fb%d:d%lu/e%lu/c%lu", tfb[i].fb, tfb[i].draws, tfb[i].errors,
tfb[i].clears);
LOG("trace 5s: %lu draws, %lu with a GL error (first 0x%x), %lu clears;%s", trace_draws, trace_errors,
trace_first_error, trace_clears, n ? buf : " none");
{ // idTech 4 engines (Doom3Quest, PreyVR) export their clocks; GUI animations run on com_frameTime, so one that
// stands still leaves menus at their first step (PreyVR, GitHub #306: 0 Hz refresh rate -> 0 ms per tic)
static int *frame_time, *tic_number, *frame_number, looked;
if (!looked) {
looked = 1;
frame_time = dlsym(RTLD_DEFAULT, "com_frameTime");
tic_number = dlsym(RTLD_DEFAULT, "com_ticNumber");
frame_number = dlsym(RTLD_DEFAULT, "com_frameNumber");
}
if (frame_time && tic_number && frame_number)
LOG("trace clocks: com_frameTime=%d com_ticNumber=%d com_frameNumber=%d", *frame_time, *tic_number,
*frame_number);
}
memset(tfb, 0, sizeof(tfb));
trace_draws = trace_errors = trace_clears = 0;
trace_first_error = 0;
if (trace_windows < TRACE_WINDOWS) {
++trace_windows;
trace_detail = 0;
}
}
static void trace_state(const char *what, GLenum mode, GLsizei count, GLenum err) {
GLint fb = 0, prog = 0, vp[4] = {0}, sc[4] = {0}, unit = 0, tex = 0, dfunc = 0, bsrc = 0, bdst = 0, bsrca = 0,
bdsta = 0, cullmode = 0;
GLboolean cmask[4] = {0}, dmask = 0;
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
r_GetIntegerv(GL_CURRENT_PROGRAM, &prog);
r_GetIntegerv(GL_VIEWPORT, vp);
r_GetIntegerv(GL_SCISSOR_BOX, sc);
r_GetIntegerv(GL_ACTIVE_TEXTURE, &unit);
r_GetIntegerv(GL_DEPTH_FUNC, &dfunc);
r_GetIntegerv(GL_BLEND_SRC_RGB, &bsrc);
r_GetIntegerv(GL_BLEND_DST_RGB, &bdst);
r_GetIntegerv(GL_BLEND_SRC_ALPHA, &bsrca);
r_GetIntegerv(GL_BLEND_DST_ALPHA, &bdsta);
r_GetIntegerv(GL_CULL_FACE_MODE, &cullmode);
r_GetBooleanv(GL_COLOR_WRITEMASK, cmask);
r_GetBooleanv(GL_DEPTH_WRITEMASK, &dmask);
GLenum status = r_CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER);
GLint linked = 0;
if (prog) r_GetProgramiv((GLuint)prog, GL_LINK_STATUS, &linked);
struct prog *p = prog ? find_prog((GLuint)prog, 0) : NULL;
r_GetIntegerv(GL_TEXTURE_BINDING_2D, &tex); // (on the active unit)
GLint tw = 0, th = 0, tfmt = 0, minf = 0, maxl = 0, imm = 0;
if (tex) {
r_GetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &tw);
r_GetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &th);
r_GetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_INTERNAL_FORMAT, &tfmt);
r_GetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, &minf);
r_GetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, &maxl);
r_GetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_IMMUTABLE_LEVELS, &imm);
}
r_GetError(); // (the queries above must not count as the game's)
LOG("trace %s(mode=0x%x n=%d) err=0x%x fb=%d status=0x%x views=%d prog=%d linked=%d mv=%d vp=%d,%d %dx%d "
"scissor=%d %d,%d %dx%d cmask=%d%d%d%d blend=%d 0x%x/0x%x,0x%x/0x%x depth=%d f=0x%x w=%d stencil=%d cull=%d "
"0x%x unit=0x%x tex2d=%d %dx%d fmt=0x%x min=0x%x maxl=%d imm=%d",
what, mode, count, err, fb, status, draw_fb_views(), prog, linked, p ? p->multiview : -1, vp[0], vp[1], vp[2],
vp[3], r_IsEnabled(GL_SCISSOR_TEST), sc[0], sc[1], sc[2], sc[3], cmask[0], cmask[1], cmask[2], cmask[3],
r_IsEnabled(GL_BLEND), bsrc, bdst, bsrca, bdsta, r_IsEnabled(GL_DEPTH_TEST), dfunc, dmask,
r_IsEnabled(GL_STENCIL_TEST), r_IsEnabled(GL_CULL_FACE), cullmode, unit, tex, tw, th, tfmt, minf, maxl, imm);
}
static void trace_before(void) {
if (debug >= 2) r_GetError();
}
static void trace_after(const char *what, GLenum mode, GLsizei count) {
if (debug < 2) return;
GLenum err = r_GetError();
GLint fb = 0;
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
int detail;
pthread_mutex_lock(&trace_lock);
++trace_draws;
if (err) {
++trace_errors;
if (!trace_first_error) trace_first_error = err;
}
struct tfb *e = tfb_slot(fb);
if (e) {
++e->draws;
if (err) ++e->errors;
}
detail = trace_windows < TRACE_WINDOWS && trace_detail < TRACE_DETAIL;
if (detail) ++trace_detail;
trace_window();
pthread_mutex_unlock(&trace_lock);
if (detail) trace_state(what, mode, count, err);
}
static int trace_logs;
#define TRACE_CALL(fmt, ...) \
do { \
if (debug >= 2) { \
GLenum e_ = r_GetError(); \
if ((e_ || trace_logs < 40) && trace_logs < 400) { \
++trace_logs; \
LOG("trace " fmt " err=0x%x", __VA_ARGS__, e_); \
} \
} \
} while (0)
// Before a draw: bind the twin when the program is multiview and the framebuffer has no views. 1 = twin bound.
static int begin_draw(void) {
struct prog *p = cur_prog;
@@ -720,14 +558,6 @@ EXPORT void glLinkProgram(GLuint program) {
INIT();
note_program(program);
r_LinkProgram(program);
if (debug >= 2) {
GLint ok = 0;
r_GetProgramiv(program, GL_LINK_STATUS, &ok);
char log[700] = {0};
r_GetProgramInfoLog(program, sizeof(log) - 1, NULL, log);
struct prog *p = find_prog(program, 0);
LOG("trace glLinkProgram(%u) ok=%d multiview=%d log: %s", program, ok, p ? p->multiview : -1, log);
}
}
EXPORT void glDeleteProgram(GLuint program) {
@@ -796,143 +626,43 @@ static void w_ftmsmv(GLenum target, GLenum attachment, GLuint texture, GLint lev
#define DRAW(name, call) \
INIT(); \
if (!r_##name) return; \
trace_before(); \
if (begin_draw()) { \
r_##name call; \
end_draw("gl" #name); \
} else { \
r_##name call; \
} \
trace_after("gl" #name, call_mode, call_count);
}
EXPORT void glDrawArrays(GLenum mode, GLint first, GLsizei count) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawArrays, (mode, first, count))
}
EXPORT void glDrawArrays(GLenum mode, GLint first, GLsizei count) { DRAW(DrawArrays, (mode, first, count)) }
EXPORT void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void *indices) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElements, (mode, count, type, indices))
}
EXPORT void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instances) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawArraysInstanced, (mode, first, count, instances))
}
EXPORT void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void *indices, GLsizei instances) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElementsInstanced, (mode, count, type, indices, instances))
}
EXPORT void glDrawRangeElements(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
const void *indices) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawRangeElements, (mode, start, end, count, type, indices))
}
EXPORT void glDrawArraysIndirect(GLenum mode, const void *indirect) {
GLenum call_mode = mode; GLsizei call_count = -1;
DRAW(DrawArraysIndirect, (mode, indirect))
}
EXPORT void glDrawArraysIndirect(GLenum mode, const void *indirect) { DRAW(DrawArraysIndirect, (mode, indirect)) }
EXPORT void glDrawElementsIndirect(GLenum mode, GLenum type, const void *indirect) {
GLenum call_mode = mode; GLsizei call_count = -1;
DRAW(DrawElementsIndirect, (mode, type, indirect))
}
EXPORT void glDrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices, GLint base) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElementsBaseVertex, (mode, count, type, indices, base))
}
EXPORT void glDrawRangeElementsBaseVertex(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type,
const void *indices, GLint base) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawRangeElementsBaseVertex, (mode, start, end, count, type, indices, base))
}
EXPORT void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices,
GLsizei instances, GLint base) {
GLenum call_mode = mode; GLsizei call_count = count;
DRAW(DrawElementsInstancedBaseVertex, (mode, count, type, indices, instances, base))
}
// ---------------------------------------------------------------- traced calls (pass-through unless gl_mv_debug=2)
EXPORT void glClear(GLbitfield mask) {
INIT();
if (debug >= 2) r_GetError();
r_Clear(mask);
if (debug >= 2) {
GLenum err = r_GetError();
GLint fb = 0;
r_GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fb);
pthread_mutex_lock(&trace_lock);
++trace_clears;
struct tfb *e = tfb_slot(fb);
if (e) ++e->clears;
pthread_mutex_unlock(&trace_lock);
if (err) LOG("trace glClear(0x%x) fb=%d err=0x%x", mask, fb, err);
}
}
EXPORT void glTexImage2D(GLenum t, GLint l, GLint ifmt, GLsizei w, GLsizei h, GLint b, GLenum f, GLenum ty,
const void *px) {
INIT();
if (debug >= 2) r_GetError();
r_TexImage2D(t, l, ifmt, w, h, b, f, ty, px);
GLint tex = 0;
if (debug >= 2) r_GetIntegerv(GL_TEXTURE_BINDING_2D, &tex);
TRACE_CALL("glTexImage2D(target=0x%x tex=%d level=%d ifmt=0x%x %dx%d fmt=0x%x type=0x%x data=%d)", t, tex, l, ifmt,
w, h, f, ty, px != NULL);
}
EXPORT void glTexSubImage2D(GLenum t, GLint l, GLint x, GLint y, GLsizei w, GLsizei h, GLenum f, GLenum ty,
const void *px) {
INIT();
if (debug >= 2) r_GetError();
r_TexSubImage2D(t, l, x, y, w, h, f, ty, px);
TRACE_CALL("glTexSubImage2D(target=0x%x level=%d %d,%d %dx%d fmt=0x%x type=0x%x)", t, l, x, y, w, h, f, ty);
}
EXPORT void glCompressedTexImage2D(GLenum t, GLint l, GLenum ifmt, GLsizei w, GLsizei h, GLint b, GLsizei size,
const void *data) {
INIT();
if (debug >= 2) r_GetError();
r_CompressedTexImage2D(t, l, ifmt, w, h, b, size, data);
TRACE_CALL("glCompressedTexImage2D(target=0x%x level=%d ifmt=0x%x %dx%d size=%d)", t, l, ifmt, w, h, size);
}
EXPORT void glTexStorage2D(GLenum t, GLsizei levels, GLenum ifmt, GLsizei w, GLsizei h) {
INIT();
if (debug >= 2) r_GetError();
r_TexStorage2D(t, levels, ifmt, w, h);
TRACE_CALL("glTexStorage2D(target=0x%x levels=%d ifmt=0x%x %dx%d)", t, levels, ifmt, w, h);
}
EXPORT void glCopyTexImage2D(GLenum t, GLint l, GLenum ifmt, GLint x, GLint y, GLsizei w, GLsizei h, GLint b) {
INIT();
if (debug >= 2) r_GetError();
r_CopyTexImage2D(t, l, ifmt, x, y, w, h, b);
GLint rfb = 0;
if (debug >= 2) r_GetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &rfb);
TRACE_CALL("glCopyTexImage2D(target=0x%x level=%d ifmt=0x%x %d,%d %dx%d read fb=%d)", t, l, ifmt, x, y, w, h, rfb);
}
EXPORT void glCopyTexSubImage2D(GLenum t, GLint l, GLint xo, GLint yo, GLint x, GLint y, GLsizei w, GLsizei h) {
INIT();
if (debug >= 2) r_GetError();
r_CopyTexSubImage2D(t, l, xo, yo, x, y, w, h);
GLint rfb = 0;
if (debug >= 2) r_GetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &rfb);
TRACE_CALL("glCopyTexSubImage2D(target=0x%x level=%d %d,%d from %d,%d %dx%d read fb=%d)", t, l, xo, yo, x, y, w, h,
rfb);
}
EXPORT void glGenerateMipmap(GLenum t) {
INIT();
if (debug >= 2) r_GetError();
r_GenerateMipmap(t);
TRACE_CALL("glGenerateMipmap(target=0x%x)", t);
}
EXPORT void glCompileShader(GLuint sh) {
INIT();
r_CompileShader(sh);
if (debug < 2) return;
GLint ok = 0;
r_GetShaderiv(sh, GL_COMPILE_STATUS, &ok);
char log[700] = {0};
r_GetShaderInfoLog(sh, sizeof(log) - 1, NULL, log);
if (!ok || (log[0] && trace_logs < 400)) {
++trace_logs;
LOG("trace glCompileShader(%u) ok=%d log: %s", sh, ok, log);
}
}
// A context made current on this thread (engines with a render thread move theirs between threads, e.g. Doom3Quest's
// GLimp_ActivateContext): what this thread knew about the bound program and framebuffer may be another context's.
EXPORT EGLBoolean eglMakeCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) {
@@ -968,8 +698,7 @@ EXPORT __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *na
OWN(glFramebufferTexture) OWN(glDrawBuffers) OWN(glDrawArrays) OWN(glDrawElements) OWN(glDrawArraysInstanced)
OWN(glDrawElementsInstanced) OWN(glDrawRangeElements) OWN(glDrawArraysIndirect) OWN(glDrawElementsIndirect)
OWN(glDrawElementsBaseVertex) OWN(glDrawRangeElementsBaseVertex) OWN(glDrawElementsInstancedBaseVertex)
OWN(eglMakeCurrent) OWN(glClear) OWN(glTexImage2D) OWN(glTexSubImage2D) OWN(glCompressedTexImage2D)
OWN(glTexStorage2D) OWN(glCopyTexImage2D) OWN(glCopyTexSubImage2D) OWN(glGenerateMipmap) OWN(glCompileShader)
OWN(eglMakeCurrent)
#undef OWN
return fn;
}
+25
View File
@@ -135,6 +135,9 @@ class FakeFS:
return {"shots": shots, "total": len(shots), "games": list(games.values())}
if command == "take_screenshot": # the Screenshots tab's button: a new shot of the running game
return self.take_screenshot()
if command == "saves_info": # Restore saves…: what the Frame has now, and the copy from the last restore
return {"package": args.get("package"), "files": 14, "bytes": 2_310_000, "skipped": [],
"running": False, "version": "1.0", "rollback": {"size": 812_000, "time": time.time() - 86400}}
raise RuntimeError(f"fake Frame: {command} not available")
@property
@@ -598,6 +601,28 @@ def scan_job(app, folder: str, entries: list[dict], pace: float = 1.0):
return app.submit(f"Scan {folder.rstrip('/').rsplit('/', 1)[-1]}", run, None, "scan")
def fake_save_backups(pkg: str) -> None:
"""Two pretend save backups of `pkg` on this PC (yesterday's and last week's), for the Restore saves… dialog."""
import io
import json
import tarfile
from frameport.install import saves
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
for days, n, size in ((1, 14, 2_300_000), (7, 11, 1_900_000)):
created = time.time() - days * 86400
path = d / time.strftime("%Y%m%d-%H%M%S.tar.gz", time.localtime(created))
manifest = {"format": 1, "package": pkg, "created": created, "version": "1.0",
"files": [{"path": f"external/files/save{i}.dat", "size": size // n} for i in range(n)]}
raw = json.dumps(manifest).encode()
with tarfile.open(path, "w:gz") as tar:
ti = tarfile.TarInfo(saves.MANIFEST)
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
def attach_fake_frame(app: FramePortApp, monitor_session=None) -> None:
"""Connect `app` to a FakeTarget (the shared monitor stream, used by the Monitor tab and the Frame card, too)."""
app.monitor_hub.session_factory = monitor_session or FakeMonitorSession
+32 -2
View File
@@ -27,6 +27,7 @@ from showcase.fakes import ( # noqa: E402,F401
add_fake_linux_apps,
attach_fake_frame,
fake_live_stream,
fake_save_backups,
install_fakes,
open_monitor_details,
open_type_tab,
@@ -101,6 +102,8 @@ def main() -> int:
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI: "
"changelog dialog, what's new bar + dialog, Settings changelog)")
ap.add_argument("--announcement", action="store_true", help="show the announcement pop-up (with a picture, "
"then text only); nothing is fetched")
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
@@ -137,7 +140,9 @@ def main() -> int:
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
("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-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)))
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
@@ -150,7 +155,11 @@ 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))))
# last: the right-click menu stays open over whatever comes next
# 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),
a.library_view.open_menu(game))))
if args.docs:
@@ -278,6 +287,7 @@ def main() -> int:
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(LINUX_APPIMAGE)))]
if args.links:
@@ -418,6 +428,26 @@ def main() -> int:
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
if args.announcement:
import shutil
from frameport import announcements
from frameport.core.paths import user_data_dir
text = ("---\nid: smoke\ntitle: FramePort 1.3 brings the Monitor to every Frame\ndate: 2026-10-14T12:00:00Z\n"
"---\n"
"The **Monitor** tab now shows fps, battery and temperatures for any game the Frame runs, including "
"Steam's own. Open it from the sidebar's Frame card.\n\n"
"- Install links from FrameDrop buttons open in FramePort\n"
"- Uploads over the USB cable are about **3× faster**\n\n"
"Questions? Ask in [Discussions](https://example.invalid).")
news = announcements.parse(text)
pic = user_data_dir() / "announcements" / "smoke.png"
pic.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(Path(__file__).resolve().parents[1] / "docs" / "images" / "library.png", pic)
steps = [("announcement-image", lambda a: (a.navigate(0), a.announcer.show(news, pic))),
("announcement-text", lambda a: (a.page.pop_dialog(), time.sleep(1.5), a.announcer.show(
announcements.parse(text.replace("id: smoke", "id: smoke2")))))]
if args.update:
from frameport import __version__, updates
+218
View File
@@ -0,0 +1,218 @@
"""Announcements: one short message from the maintainer, shown once as a pop-up in the app.
Source of truth: `announcements/current.md` on GitHub `main` (front matter + Markdown, announcements/README.md), read
through raw.githubusercontent like the catalog (cached, every 6 h), so a push reaches users without a release.
Who sees it: only people who were already using FramePort before the announcement's `date` (a first start never
shows one, and nor does one published before FramePort was installed). Closing it in any way (the X, a click outside,
Escape, its button) dismisses that `id` for good; a new announcement = a new id. No Flet here.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import logging
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
import yaml
from . import REPO_URL
from .core import cache, library
from .core.paths import user_data_dir
_log = logging.getLogger("frameport.announcements")
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
PATH = "announcements/current.md"
RAW = f"https://raw.githubusercontent.com/{REPO}/main/"
URL = RAW + PATH
CHECK_EVERY = 6 * 3600
KEEP_DISMISSED = 50 # ids remembered (oldest dropped)
IMAGE_MAX = 5 * 2**20
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
# settings only an earlier GUI run writes: one of them when announcements first run = an existing user (not
# recipes.app_version: library.load() writes that on the very first load)
USED_BEFORE = ("update.last_seen_version", "update.last_check", "ui.welcome_done", "ui.window")
@dataclass(frozen=True)
class Announcement:
id: str
title: str
body: str # Markdown
date: float # unix time it was published
image: str | None = None # https URL (repo paths are resolved to raw GitHub)
image_alt: str = ""
button: str | None = None # optional action: label + url
url: str | None = None
expires: float | None = None
class AnnouncementError(ValueError):
pass
def _when(value, end_of_day: bool = False) -> float:
"""A front-matter date (YAML date/datetime or ISO text) as unix time; a bare date is UTC midnight (or the end of
that day for `expires`). Naive times are UTC."""
if isinstance(value, str):
text = value.strip().replace("Z", "+00:00")
try:
value = dt.datetime.fromisoformat(text) if "T" in text or " " in text else dt.date.fromisoformat(text)
except ValueError as exc:
raise AnnouncementError(f"not a date: {value!r}") from exc
if isinstance(value, dt.datetime):
return (value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)).timestamp()
if isinstance(value, dt.date):
day = dt.datetime(value.year, value.month, value.day, tzinfo=dt.timezone.utc)
return (day + dt.timedelta(days=1 if end_of_day else 0)).timestamp()
raise AnnouncementError(f"not a date: {value!r}")
def _link(value) -> str | None:
"""An image/button target: https URLs as they are, a repo path (announcements/x.png) → its raw GitHub URL."""
if value in (None, ""):
return None
text = str(value).strip()
if text.startswith("https://"):
return text
if "://" in text or text.startswith(("/", "..")) or ".." in text.split("/"):
raise AnnouncementError(f"use an https URL or a path in the repo: {text!r}")
return RAW + text
def parse(text: str | None) -> Announcement | None:
"""The file's announcement; None when there is none (empty file, `active: false`). Raises AnnouncementError for a
malformed one (the app then shows nothing; tests/test_announcements.py checks the committed file)."""
if not text or not text.strip():
return None
m = re.match(r"\A?---\s*\n(.*?)\n---\s*(?:\n|\Z)(.*)\Z", text, re.S)
if not m:
raise AnnouncementError("missing the --- front matter")
try:
meta = yaml.safe_load(m.group(1)) or {}
except yaml.YAMLError as exc:
raise AnnouncementError(f"front matter: {exc}") from exc
if not isinstance(meta, dict):
raise AnnouncementError("front matter must be key: value lines")
active = meta.get("active", True)
if active is False and not meta.get("id"):
return None
if not isinstance(active, bool):
raise AnnouncementError("active: true or false")
unknown = set(meta) - {"id", "title", "date", "image", "image_alt", "button", "url", "expires", "active"}
if unknown:
raise AnnouncementError(f"unknown fields: {', '.join(sorted(unknown))}")
ident, title = str(meta.get("id") or "").strip(), str(meta.get("title") or "").strip()
if not ID_RE.match(ident):
raise AnnouncementError("id: letters, digits, '.', '_' or '-' (change it to show a new announcement)")
if not title:
raise AnnouncementError("title is required")
if "date" not in meta:
raise AnnouncementError("date is required (only people using FramePort before it see the announcement)")
button, url = meta.get("button"), meta.get("url")
if bool(button) != bool(url):
raise AnnouncementError("button and url go together")
if not active: # switched off, but still checked (tests catch mistakes before it's switched on)
_check_dates(meta)
return None
return Announcement(id=ident, title=title, body=m.group(2).strip(), date=_when(meta["date"]),
image=_link(meta.get("image")), image_alt=str(meta.get("image_alt") or ""),
button=str(button).strip() if button else None, url=_link(url),
expires=_when(meta["expires"], end_of_day=True) if meta.get("expires") else None)
def _check_dates(meta: dict) -> None:
_when(meta["date"])
if meta.get("expires"):
_when(meta["expires"], end_of_day=True)
_link(meta.get("image"))
_link(meta.get("url"))
def disabled() -> bool:
return bool(os.environ.get("FRAMEPORT_NO_ANNOUNCEMENTS"))
def fetch(force: bool = False) -> Announcement | None:
"""The current announcement (cached 6 h; offline → the last copy; never raises)."""
try:
return parse(cache.cached_text("announcement.md", URL, max_age=0 if force else CHECK_EVERY, fallback=""))
except Exception as exc: # noqa: BLE001
_log.warning("announcement skipped: %s", exc)
return None
# ------------------------------------------------------------------------------------------ who sees it
def since() -> float:
"""When this user started using FramePort, as far as announcements are concerned (setting announce.since). Set
once, the first time it's asked: 0 for someone who used FramePort before announcements existed, else now (a first
start). Call it early at start, before anything records this start in the settings."""
value = library.setting("announce.since")
if isinstance(value, (int, float)):
return float(value)
value = 0.0 if any(library.setting(k) is not None for k in USED_BEFORE) or library.games() else time.time()
library.set_setting("announce.since", value)
return value
def dismissed() -> list[str]:
value = library.setting("announce.dismissed")
return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
def dismiss(ident: str) -> None:
"""Never show this announcement again (any way of closing it)."""
library.update_setting("announce.dismissed",
lambda cur: ([v for v in (cur or []) if v != ident] + [ident])[-KEEP_DISMISSED:], [])
def due(a: Announcement | None, now: float | None = None, user_since: float | None = None) -> bool:
"""Whether to show `a` now: published, not expired, not dismissed, and published after this
user started using FramePort."""
if a is None:
return False
now = time.time() if now is None else now
if a.expires is not None and now >= a.expires:
return False
if a.date > now: # scheduled for later
return False
if a.id in dismissed():
return False
return a.date > (since() if user_since is None else user_since)
def image_file(a: Announcement) -> Path | None:
"""The announcement's image, downloaded once into <data>/announcements/ and checked (Pillow); None without one or
when it can't be loaded (the pop-up then shows no image). Shown by asset URL like artwork, never as bytes."""
if not a.image:
return None
folder = user_data_dir() / "announcements"
stem = hashlib.sha256(a.image.encode()).hexdigest()[:16]
for done in folder.glob(f"{stem}.*"):
if done.suffix != ".part":
return done
try:
from PIL import Image
tmp = folder / f"{stem}.part"
cache.download(a.image, tmp)
if tmp.stat().st_size > IMAGE_MAX:
raise AnnouncementError("image larger than 5 MiB")
with Image.open(tmp) as im:
im.verify()
fmt = (im.format or "").lower()
ext = {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "gif": ".gif"}.get(fmt)
if not ext:
raise AnnouncementError(f"image format {fmt or '?'} (use PNG, JPEG, WebP or GIF)")
out = folder / f"{stem}{ext}"
tmp.replace(out)
return out
except Exception as exc: # noqa: BLE001
_log.warning("announcement image skipped: %s", exc)
(folder / f"{stem}.part").unlink(missing_ok=True)
return None
+223 -2
View File
@@ -22,6 +22,7 @@ Errors are one line on stderr; FRAMEPORT_DEBUG=1 shows the traceback.
frameport diag report <pkg> # zip + prefilled GitHub problem report
frameport share-recipe <pkg> --status works # prefilled GitHub issue submitting a working recipe
frameport open-link "<install link>" # FrameDrop button / frameport:// link: download, add, install
frameport saves backup <pkg>|--all # Quest games' saves from the Frame to this PC (list, restore, undo)
"""
from __future__ import annotations
@@ -50,7 +51,9 @@ tools_app = typer.Typer(help="Manage the portable toolchain.")
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
pc_app = typer.Typer(help="This PC as a target for PC VR games (Steam and SteamVR; Revive for Oculus games).")
diag_app = typer.Typer(help="Diagnostics bundles and problem reports.")
saves_app = typer.Typer(help="Back up Quest games' saves from the Frame to this PC and restore them.")
app.add_typer(tools_app, name="tools")
app.add_typer(saves_app, name="saves")
app.add_typer(frame_app, name="frame")
app.add_typer(pc_app, name="pc")
app.add_typer(diag_app, name="diag")
@@ -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")
@@ -398,6 +466,30 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
@app.command("new-version")
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
it again (frameport install) to update it on the Frame."""
[pkg] = _pkgs(package, False)
try:
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
except pipeline.WrongPackage as exc:
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
g = res["entry"]
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
if res.get("older"):
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
if res.get("different"):
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
f"frameport recipe {pkg} --exe <program>", err=True)
@app.command()
def patches():
"""List every available patch."""
@@ -685,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)."""
@@ -706,6 +902,31 @@ def frame_trust_key(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
typer.echo(f"connected: {info.get('hostname') or t.host} (new key saved)")
@frame_app.command("ssh-keys")
def frame_ssh_keys(add: list[str] = typer.Option([], "--add", help="a private key file to log in with too"),
remove: list[str] = typer.Option([], "--remove", help="stop using this key file (it stays)")):
"""Private SSH key files FramePort logs in with besides its own key (Settings → Installing → SSH keys): for a
Frame that accepts only your own key. The Frame connection and OpenSSH uploads both use them. Lists them."""
from .frame.connection import KeyFileError, add_extra_key, extra_key_status, remove_extra_key
failed = False
for path in remove:
typer.echo(f"removed {path}" if remove_extra_key(path) else f"not in the list: {path}")
for path in add:
try:
typer.echo(f"added {add_extra_key(path)}")
except KeyFileError as exc:
typer.echo(f"can't use {path}: {exc}", err=True)
failed = True
status = extra_key_status()
for path, problem in status:
typer.echo(f"{path}" + (f" (not usable: {problem})" if problem else ""))
if not status:
typer.echo("no extra keys: FramePort's own key and your SSH agent's keys only")
if failed:
raise typer.Exit(1)
@frame_app.command("usb-check")
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
+120
View File
@@ -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)]
+110 -8
View File
@@ -1,8 +1,8 @@
"""SSH connection to a Steam Frame and the remote agent protocol.
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
password if given.
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then key files the user added in
Settings (EXTRA_KEYS_SETTING), then the user's SSH agent/keys, then a password if given.
"""
from __future__ import annotations
@@ -163,6 +163,96 @@ def app_keys() -> list[paramiko.PKey]:
return keys
EXTRA_KEYS_SETTING = "ssh.extra_keys" # library setting: private key files the user added (Settings → Installing)
class KeyFileError(ValueError):
"""A key file the user named can't be used (missing, not a private key, protected with a passphrase)."""
def load_key_file(path) -> paramiko.PKey:
"""A private key FramePort can log in with, or KeyFileError with a sentence saying why not."""
p = Path(path).expanduser()
if not p.is_file():
raise KeyFileError(f"{p}: file not found")
try:
return paramiko.PKey.from_path(p)
except (paramiko.SSHException, ValueError, OSError, UnicodeDecodeError, TypeError) as exc:
# paramiko says so with PasswordRequiredException (PEM) or a plain SSHException (OpenSSH format)
if isinstance(exc, paramiko.PasswordRequiredException) or "password" in str(exc).lower():
raise KeyFileError(f"{p.name} is protected with a passphrase, which FramePort can't ask for: add the "
"key to your SSH agent instead (FramePort also logs in with the agent's keys)") from None
raise KeyFileError(f"{p.name} isn't a private SSH key FramePort can read "
f"(OpenSSH or PEM format; a .pub file is the public half): {exc}") from None
def extra_key_paths() -> list[Path]:
"""The key files from Settings, as stored (each may be missing or unreadable by now: see extra_keys)."""
try:
from ..core import library
stored = library.peek_setting(EXTRA_KEYS_SETTING, [])
except Exception: # noqa: BLE001 - no library yet
return []
if isinstance(stored, str):
stored = [stored]
paths: list[Path] = []
for value in stored if isinstance(stored, list) else []:
if isinstance(value, str) and value.strip():
p = Path(value.strip()).expanduser()
if p not in paths:
paths.append(p)
return paths
def add_extra_key(path) -> Path:
"""Check a key file (KeyFileError if FramePort can't use it) and add it to Settings. Returns the stored path."""
p = Path(path).expanduser()
if p.name.endswith(".pub") and p.with_name(p.name[:-4]).is_file():
raise KeyFileError(f"{p.name} is the public half: pick {p.name[:-4]} (the file without .pub) instead")
load_key_file(p)
p = p.resolve()
from ..core import library
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
if Path(v).expanduser() != p] + [str(p)], [])
return p
def remove_extra_key(path) -> bool:
"""Take a key file out of Settings (the file itself stays). False if it wasn't there."""
from ..core import library
p = Path(path).expanduser()
before = extra_key_paths()
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
if Path(v).expanduser() != p], [])
return p in before
def extra_key_status() -> list[tuple[Path, str]]:
"""Each key file from Settings with "" when usable, else why not (for Settings and the CLI)."""
status = []
for path in extra_key_paths():
try:
load_key_file(path)
status.append((path, ""))
except KeyFileError as exc:
status.append((path, str(exc)))
return status
def extra_keys() -> list[tuple[Path, paramiko.PKey]]:
"""The usable keys from Settings; unusable ones are logged and skipped (a login still tries the others)."""
keys = []
for path in extra_key_paths():
try:
keys.append((path, load_key_file(path)))
except KeyFileError as exc:
log.warning("SSH key from Settings skipped: %s", exc)
return keys
@dataclass
class FrameTarget:
host: str
@@ -268,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:
@@ -409,6 +508,10 @@ class Frame:
_video_codec_digest: str = "" # "<manifest digest>:<on|off>" handled on this connection (incl. a failed install)
_video_codec_lock: threading.Lock = field(default_factory=threading.Lock)
home: str = ""
# how the last login worked: "app_key" (FramePort's own key), "user_key" (a key file from Settings),
# "agent" (this PC's SSH agent / ~/.ssh keys) or "password"; OpenSSH uploads follow it (native_upload)
auth: str = ""
openssh_relaxed: bool | None = None # native_upload: True once OpenSSH needed this PC's own keys/agent
# ------------------------------------------------------------------ connect
def connect(self, timeout: float = 10) -> Frame:
@@ -459,7 +562,8 @@ class Frame:
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
banner_timeout=timeout, auth_timeout=timeout)
errors = []
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
attempts = ([("app_key", k) for k in app_keys()] + [("user_key", k) for _, k in extra_keys()]
+ [("agent", None), ("password", None)])
for attempt, pkey in attempts:
if attempt == "password" and not self.password:
continue
@@ -467,7 +571,7 @@ class Frame:
client.load_host_keys(str(known))
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
try:
if attempt == "app_key":
if attempt in ("app_key", "user_key"):
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
elif attempt == "agent":
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
@@ -492,6 +596,7 @@ class Frame:
raise
else:
raise FrameNotPaired(bool(self.password), errors)
self.auth = attempt
client.save_host_keys(str(known))
transport = client.get_transport()
transport.set_keepalive(15)
@@ -579,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:
+156 -29
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import contextlib
import csv
import io
import os
import posixpath
import re
import shutil
import signal
import subprocess
@@ -13,19 +12,30 @@ import tarfile
import tempfile
import threading
import time
from pathlib import Path
from pathlib import Path, PureWindowsPath
from .connection import sh_quote, ssh_dir
from .connection import extra_keys, sh_quote, ssh_dir
def quote(value) -> str:
def quote(value, windows=False) -> str:
"""One sftp batch argument, taken literally.
Inside double quotes sftp escapes glob characters (* ? [) itself and undoes that for put's destination, so
they must NOT be escaped again: an extra backslash became part of the name (GitHub #324: "[ENG]" in a folder
name made every OpenSSH upload fail, on Linux too). Only backslash and the quote are escaped. ``windows``: a
local Windows path goes with forward slashes; Win32-OpenSSH's sftp turns every backslash of a local path into
'/', escapes included ("C:\\\\Users" → "C://Users").
"""
value = str(value)
if any(c in value for c in "\r\n\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
for char in "*?[":
escaped = escaped.replace(char, "\\" + char)
return '"' + escaped + '"'
if windows:
value = PureWindowsPath(value).as_posix()
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def quote_local(path) -> str:
return quote(path, windows=os.name == "nt")
def size_of(sftp, path):
@@ -35,27 +45,74 @@ def size_of(sftp, path):
return 0
def key_files(relaxed=False) -> list[Path]:
"""The private keys OpenSSH offers: FramePort's own (<data>/ssh) and the usable key files from Settings;
``relaxed`` also this PC's default keys (default_key_files)."""
keys = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
for path in [path for path, _ in extra_keys()] + (default_key_files() if relaxed else []):
if path not in keys:
keys.append(path)
return keys
def default_key_files() -> list[Path]:
"""This PC's own keys that Paramiko's "agent" login tries (look_for_keys: ~/.ssh/id_rsa, id_dsa, id_ecdsa,
id_ed25519; on Windows ~/ssh too), usable without a passphrase. OpenSSH needs them named: it skips its default
keys as soon as one -i is given, and Windows OpenSSH refuses a key file others can read (a private copy can't
be refused)."""
from .connection import KeyFileError, load_key_file
folders = [Path.home() / ".ssh"] + ([Path.home() / "ssh"] if os.name == "nt" else [])
found = []
for folder in folders:
for name in ("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"):
path = folder / name
if not path.is_file():
continue
try:
load_key_file(path)
except KeyFileError:
continue # passphrase-protected or not a key: only the agent can use it
found.append(path)
return found
def current_sid() -> str:
"""This Windows account's SID. whoami prints in the console code page: read bytes, so a user name with
accents can't break decoding (only the ASCII SID is used)."""
out = subprocess.check_output(["whoami", "/user", "/fo", "csv", "/nh"], **hidden())
match = re.search(rb"S-1-[0-9]+(?:-[0-9]+)+", out)
if not match:
raise OSError("cannot find this Windows account's SID for the key copies' permissions")
return match.group().decode()
@contextlib.contextmanager
def credentials(frame, port_flag="-P"):
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
if not key_paths:
def credentials(frame, port_flag="-P", relaxed=False):
"""OpenSSH arguments that log in like the Frame connection and trust only the host key it pinned.
Keys are offered as private temporary copies (that avoids modifying pairing keys, including their Windows
ACLs; Windows OpenSSH refuses key files others can read). Strict (default): FramePort's keys and the key
files from Settings only (IdentitiesOnly). ``relaxed``: also this PC's default ~/.ssh keys and SSH agent, the
way Paramiko's "agent" login does (GitHub #242: a Frame that only authorizes the user's own key). Verbose
(-v), so a refused login can say which keys were offered (failure()).
"""
key_paths = key_files(relaxed)
if not key_paths and not relaxed:
raise OSError("no FramePort key available for OpenSSH")
key = frame.client.get_transport().get_remote_server_key()
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
directory = Path(directory)
sid = None
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
sid = row[-1]
sid = current_sid()
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL, **hidden())
else:
directory.chmod(0o700)
copies = []
for path in key_paths:
copy = directory / path.name
for n, path in enumerate(key_paths):
copy = directory / f"key{n}" # own names: a key from Settings may be called id_ed25519 too
shutil.copyfile(path, copy)
if sid:
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
@@ -66,13 +123,72 @@ def credentials(frame, port_flag="-P"):
known = directory / "known_hosts"
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
yield ["-F", os.devnull, "-v", *copies, "-o", "BatchMode=yes",
*([] if relaxed else ["-o", "IdentitiesOnly=yes"]),
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
port_flag, str(frame.target.port)]
class OpenSSHAuthFailed(OSError):
"""OpenSSH reached the Frame but the login was refused (the keys it offered aren't authorized there)."""
AUTH_REFUSED = ("Permission denied (", "Too many authentication failures", "No more authentication methods")
# debug lines (-v) worth keeping: which keys were found, offered, refused or unreadable
AUTH_DEBUG = ("identity file", "Offering public key", "Server accepts key", "Will attempt key",
"Authentications that can continue", "Authenticated to", "Load key", "no such identity",
"Skipping", "agent", "Next authentication method")
def failure(what: str, code, stderr: bytes) -> OSError:
"""The error for a failed OpenSSH run: OpenSSH's own messages (all non-debug lines: warnings such as
"UNPROTECTED PRIVATE KEY FILE" included) plus the -v lines about keys; OpenSSHAuthFailed for a refused login."""
text = stderr.decode(errors="replace").replace("\r\n", "\n")
lines = [line for line in text.splitlines() if line.strip() and (
not line.startswith("debug") or any(k in line for k in AUTH_DEBUG))]
detail = "\n".join(lines)
if len(detail) > 4000:
detail = "…" + detail[-4000:]
refused = code == 255 and any(marker in text for marker in AUTH_REFUSED)
return (OpenSSHAuthFailed if refused else OSError)(f"{what} failed ({code}): {detail}")
def login_modes(frame) -> list[bool]:
"""How OpenSSH logs in, in order (False = FramePort's keys only, True = also this PC's keys and agent).
A Frame connection that logged in with this PC's own keys/agent (Paramiko's "agent" attempt) goes straight to
relaxed: FramePort's key isn't authorized there. Else strict first, relaxed once more after a refused login.
A mode that worked is kept for the rest of the connection. FramePort doesn't add its key to the Frame for
this: it only does that after a password login, where the user typed the Frame's password for it."""
known = getattr(frame, "openssh_relaxed", None)
if known is not None:
return [bool(known)]
if getattr(frame, "auth", "") == "agent":
return [True]
return [False, True]
def with_login_modes(frame, reporter, run):
"""run(relaxed) in each login mode until one isn't refused (see login_modes)."""
modes = login_modes(frame)
if modes == [True] and getattr(frame, "openssh_relaxed", None) is None:
reporter.log("the Frame connection logged in with this PC's own SSH keys or agent: OpenSSH uses them too")
for n, relaxed in enumerate(modes):
try:
result = run(relaxed)
except OpenSSHAuthFailed as exc:
if n == len(modes) - 1:
raise
reporter.log(f"OpenSSH login with FramePort's SSH keys was refused; trying this PC's own SSH keys and "
f"agent too. {exc}")
continue
with contextlib.suppress(AttributeError):
frame.openssh_relaxed = relaxed
return result
def hidden():
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
@@ -99,6 +215,11 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
Only successful batches are promoted, after every process has exited successfully. All control operations
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
"""
return with_login_modes(frame, reporter, lambda relaxed: _upload(
frame, items, remote_root, reporter, total, sent, workers, relaxed))
def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
executable = shutil.which("sftp")
if not executable:
raise OSError("system OpenSSH SFTP is unavailable")
@@ -111,7 +232,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
sizes, offsets, progress = {}, {}, {}
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
remote = posixpath.join(remote_root, rel)
quote(path)
quote_local(path)
quote(remote)
offset = size_of(frame.sftp, remote + ".part")
if offset > size:
@@ -127,7 +248,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
speed = Speed(total - sent - sum(offsets.values()))
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
setup_started = time.monotonic()
with credentials(frame) as args, contextlib.ExitStack() as stack:
with credentials(frame, relaxed=relaxed) as args, contextlib.ExitStack() as stack:
monitor = stack.enter_context(frame.client.open_sftp())
monitor.get_channel().settimeout(2)
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
@@ -139,7 +260,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
for path, rel, _ in group:
remote = posixpath.join(remote_root, rel) + ".part"
command = "reput" if offsets[rel] else "put"
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
batch.write(f"{command} {quote_local(path)} {quote(remote)}\n".encode())
batch.seek(0)
errors = stack.enter_context(tempfile.TemporaryFile())
process = subprocess.Popen([executable, *args, "-b", "-",
@@ -152,8 +273,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
for process, errors in processes:
if process.poll() not in (None, 0):
errors.seek(0)
detail = errors.read().decode(errors="replace")[-1500:]
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
raise failure("OpenSSH SFTP", process.returncode, errors.read())
for rel, size in sizes.items():
reporter.check_cancel()
progress[rel] = max(progress[rel], min(size, size_of(
@@ -187,11 +307,16 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
def put_tar(frame, files, remote_root, reporter, progress):
"""Stream a tar batch over paired OpenSSH; supervise writes so Cancel also stops a blocked pipe."""
return with_login_modes(frame, reporter,
lambda relaxed: _put_tar(frame, files, remote_root, reporter, progress, relaxed))
def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
executable = shutil.which("ssh")
if not executable:
raise OSError("system OpenSSH SSH is unavailable")
reporter.check_cancel()
with credentials(frame, port_flag="-p") as args, tempfile.TemporaryFile() as errors:
with credentials(frame, port_flag="-p", relaxed=relaxed) as args, tempfile.TemporaryFile() as errors:
command = f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}"
process = subprocess.Popen([executable, *args, f"{frame.target.user}@{frame.target.host}", command],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errors,
@@ -222,12 +347,14 @@ def put_tar(frame, files, remote_root, reporter, progress):
if not writer.is_alive() and process.poll() is None:
time.sleep(0.1)
reporter.check_cancel()
if failures and process.poll() is None: # a refused login shows as a broken pipe first
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=2)
if process.poll() not in (None, 0):
errors.seek(0)
raise failure("OpenSSH tar", process.returncode, errors.read())
if failures:
raise failures[0]
if process.poll() != 0:
errors.seek(0)
raise OSError(f"OpenSSH tar failed ({process.returncode}): "
+ errors.read().decode(errors="replace")[-1500:])
finally:
stop_process(process)
writer.join()
+31 -4
View File
@@ -220,6 +220,28 @@ class PcvrPlan:
art_lookup: str | None = None # package whose store art to use
dest: str | None = None
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
def changed_since(path: Path, since: float | None) -> bool:
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
if since is None:
return False
try:
st = path.stat()
except OSError:
return True
return max(st.st_mtime, st.st_ctime) > since
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
if not source_id:
return recipe
return {**(recipe or {}), "source_id": source_id}
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
@@ -259,7 +281,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
sources["helpers"] = helpers
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
if prep["existing"].get(t, {}).get(rel) != size]
if prep["existing"].get(t, {}).get(rel) != size
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
need = sum(manifests[t][rel] for t, rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -290,7 +313,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
overlay=plan.overlay,
)
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
@@ -325,6 +349,8 @@ class LinuxPlan:
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: files changed after this are sent again
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
@@ -339,7 +365,8 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
else:
manifest = local_data_manifest(plan.root)
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
or changed_since(plan.root / rel, plan.changed_since)]
need = sum(manifest[rel] for rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -358,7 +385,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
executables=executables, tags=_tags(plan.package), dest=plan.dest,
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
timeout=900)
recipe=_recipe_record(None, plan.source_id), timeout=900)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
+301
View File
@@ -0,0 +1,301 @@
"""Save backups of Quest games (GitHub discussion #352: Steam doesn't sync them).
The Frame agent streams a game's saves as one tar.gz over an SSH exec channel (`_backup_saves`: its internal
/data/data/<pkg> folder and its /sdcard/Android/data/<pkg> folder, caches and big content left out; the archive's
last member `frameport-saves.json` lists every file with size and sha256). Backups live on this PC in
<user data>/save-backups/<pkg>/<YYYYmmdd-HHMMSS>.tar.gz with a <same>.json sidecar (the manifest without the file
list); the newest `keep_count()` per game are kept (setting `saves.keep`). Restoring streams one back
(`_restore_saves`): the agent checks the whole archive first (paths, links, sizes, hashes), keeps the Frame's current
saves as <base>/saves-before-restore.tar.gz (one copy: `undo_restore`), then swaps the files in. Both refuse while the
game runs. No Flet here: the GUI and the CLI share it.
"""
from __future__ import annotations
import json
import re
import tarfile
import time
from dataclasses import dataclass
from pathlib import Path
from ..core import library
from ..core.events import Reporter
from ..core.paths import user_data_dir
KEEP_DEFAULT = 10
KEEP_CHOICES = (3, 5, 10, 20, 50)
MANIFEST = "frameport-saves.json"
_NAME = re.compile(r"^\d{8}-\d{6}(-\d+)?\.tar\.gz$")
_PKG = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
CHUNK = 1 << 16
EXEC_TIMEOUT = 600 # s without data from the Frame (a dead link must not hang a job)
class SavesError(RuntimeError):
"""The Frame refused or failed (its own sentence, e.g. "the game is running")."""
@dataclass
class Backup:
package: str
path: Path
created: float
size: int # the archive on this PC
files: int = 0
bytes: int = 0 # the save files' size (uncompressed)
version: str = ""
title: str = ""
skipped: int = 0 # large files left out
@property
def sidecar(self) -> Path:
return sidecar_of(self.path)
def root_dir() -> Path:
return user_data_dir() / "save-backups"
def backups_dir(pkg: str) -> Path:
if not _PKG.match(pkg):
raise ValueError(f"bad package name {pkg!r}")
return root_dir() / pkg
def sidecar_of(archive: Path) -> Path:
return archive.with_name(archive.name[: -len(".tar.gz")] + ".json")
def keep_count() -> int:
try:
n = int(library.setting("saves.keep", KEEP_DEFAULT))
except (TypeError, ValueError):
n = KEEP_DEFAULT
return max(1, n)
def read_manifest(archive: Path) -> dict:
"""The manifest inside a backup (reads the whole archive: also checks it's complete). ValueError if it isn't a
FramePort save backup."""
try:
with tarfile.open(archive, "r:gz") as tar:
found = None
for m in tar:
if m.name == MANIFEST and m.isreg():
found = json.loads(tar.extractfile(m).read().decode("utf-8"))
except (OSError, tarfile.TarError, EOFError, ValueError) as exc:
raise ValueError(f"{archive.name} isn't a readable save backup ({exc})") from None
if not isinstance(found, dict) or found.get("format") != 1 or not found.get("package"):
raise ValueError(f"{archive.name} isn't a FramePort save backup")
return found
def _sidecar_data(manifest: dict, archive: Path) -> dict:
files = manifest.get("files") or []
return {"package": manifest.get("package"), "created": manifest.get("created"),
"version": manifest.get("version") or "", "title": manifest.get("title") or "",
"files": len(files), "bytes": sum(int(f.get("size") or 0) for f in files),
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
"archive": archive.name, "agent": manifest.get("agent")}
def _from_sidecar(archive: Path, data: dict) -> Backup:
return Backup(package=str(data.get("package") or archive.parent.name), path=archive,
created=float(data.get("created") or archive.stat().st_mtime), size=archive.stat().st_size,
files=int(data.get("files") or 0), bytes=int(data.get("bytes") or 0),
version=str(data.get("version") or ""), title=str(data.get("title") or ""),
skipped=len(data.get("skipped") or []))
def list_backups(pkg: str) -> list[Backup]:
"""This game's backups on this PC, newest first (a missing/broken sidecar is rebuilt from the archive)."""
d = backups_dir(pkg)
out = []
for archive in sorted(d.glob("*.tar.gz")) if d.is_dir() else []:
if not _NAME.match(archive.name):
continue
try:
data = json.loads(sidecar_of(archive).read_text(encoding="utf-8"))
except (OSError, ValueError):
try:
data = _sidecar_data(read_manifest(archive), archive)
sidecar_of(archive).write_text(json.dumps(data, indent=1), encoding="utf-8")
except (OSError, ValueError):
continue
try:
out.append(_from_sidecar(archive, data))
except OSError:
continue
return sorted(out, key=lambda b: b.created, reverse=True)
def has_backups(pkg: str) -> bool:
try:
d = backups_dir(pkg)
except ValueError:
return False
return d.is_dir() and any(_NAME.match(p.name) for p in d.glob("*.tar.gz"))
def prune(pkg: str, keep: int | None = None, protect: Path | None = None) -> list[Path]:
"""Delete all but the newest `keep` backups of a game (never `protect`). Returns the deleted archives."""
keep = keep_count() if keep is None else max(1, keep)
gone = []
for b in list_backups(pkg)[keep:]:
if protect is not None and b.path == protect:
continue
for p in (b.path, b.sidecar):
p.unlink(missing_ok=True)
gone.append(b.path)
return gone
def delete(backup: Backup) -> None:
backup.path.unlink(missing_ok=True)
backup.sidecar.unlink(missing_ok=True)
# ------------------------------------------------------------------------------------------ the Frame side
def _quote(s: str) -> str:
return "'" + s.replace("'", "'\"'\"'") + "'"
def _exec(frame, command: str, args: dict):
remote = frame.ensure_agent()
return frame.client.exec_command(f"python3 {_quote(remote)} {command} {_quote(json.dumps(args))}",
timeout=EXEC_TIMEOUT)
def _last_json(text: str) -> dict | None:
for line in reversed(text.splitlines()):
line = line.strip()
if line.startswith("{"):
try:
return json.loads(line)
except ValueError:
return None
return None
def info(frame, pkg: str) -> dict:
"""{files, bytes, skipped, running, version, rollback: {size, time} | None} of the game's saves on the Frame."""
return frame.agent("saves_info", package=pkg)
def _new_name(d: Path) -> Path:
stamp = time.strftime("%Y%m%d-%H%M%S")
path, n = d / f"{stamp}.tar.gz", 1
while path.exists() or path.with_name(path.name + ".part").exists():
path, n = d / f"{stamp}-{n}.tar.gz", n + 1
return path
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None = None) -> Backup:
"""Back up the game's saves from the Frame into a new archive on this PC, then keep the newest `keep`."""
rep = reporter or Reporter()
rep.stage("saves", "Backing up saves")
try:
expected = int(info(frame, pkg).get("bytes") or 0)
except Exception: # noqa: BLE001 - only for the progress bar
expected = 0
d = backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
dest = _new_name(d)
part = dest.with_name(dest.name + ".part")
stdin, stdout, stderr = _exec(frame, "_backup_saves", {"package": pkg})
received = 0
try:
stdin.channel.shutdown_write()
with open(part, "wb") as f:
while True:
rep.check_cancel()
chunk = stdout.read(CHUNK)
if not chunk:
break
f.write(chunk)
received += len(chunk)
if expected: # compressed: an estimate, never "done" before the end
rep.progress(min(0.95, received / expected), f"{received / 2**20:.1f} MiB")
code = stdout.channel.recv_exit_status()
reply = _last_json(stderr.read().decode("utf-8", "replace")) or {}
if code or not reply.get("ok"):
raise SavesError(reply.get("error") or f"the Frame couldn't back up the saves (exit {code})")
manifest = read_manifest(part)
if manifest.get("package") != pkg:
raise SavesError(f"the Frame sent saves of {manifest.get('package')}, not {pkg}")
part.replace(dest)
except BaseException:
try:
stdout.channel.close()
except Exception: # noqa: BLE001
pass
part.unlink(missing_ok=True)
raise
data = _sidecar_data(manifest, dest)
sidecar_of(dest).write_text(json.dumps(data, indent=1), encoding="utf-8")
result = _from_sidecar(dest, data)
prune(pkg, keep, protect=dest)
rep.progress(1.0, "")
rep.log(f"saved {result.files} file(s), {result.bytes / 2**20:.1f} MiB of saves, to {dest}"
+ (f"; {result.skipped} large file(s) left out" if result.skipped else ""))
return result
def restore(frame, pkg: str, archive: Path, reporter: Reporter | None = None) -> dict:
"""Send a backup to the Frame; its current saves are kept there as a rollback copy (`undo_restore`)."""
rep = reporter or Reporter()
archive = Path(archive)
manifest = read_manifest(archive) # a damaged or foreign file fails here, before the Frame is touched
if manifest.get("package") != pkg:
raise SavesError(f"this backup is of {manifest.get('package')}, not {pkg}")
rep.stage("saves", "Restoring saves")
size = archive.stat().st_size
stdin, stdout, stderr = _exec(frame, "_restore_saves", {"package": pkg})
sent = 0
try:
with open(archive, "rb") as f:
while True:
rep.check_cancel()
chunk = f.read(CHUNK)
if not chunk:
break
try:
stdin.write(chunk)
except OSError:
break # the Frame stopped reading (refused): its reply says why
sent += len(chunk)
rep.progress(min(0.95, sent / max(1, size)), f"{sent / 2**20:.1f} MiB")
try:
stdin.channel.shutdown_write()
except OSError:
pass
except BaseException:
try:
stdout.channel.close() # a cancelled restore: the agent sees a cut-off archive and changes nothing
except Exception: # noqa: BLE001
pass
raise
out = stdout.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
reply = _last_json(out) or {}
if not reply.get("ok"):
err = stderr.read().decode("utf-8", "replace").strip()
raise SavesError(reply.get("error") or f"the Frame couldn't restore the saves (exit {code}) {err[-300:]}")
rep.progress(1.0, "")
return reply["result"]
def undo_restore(frame, pkg: str) -> dict:
"""Put back the saves the last restore replaced (the Frame's rollback copy; the replaced ones become the new
rollback copy)."""
stdin, stdout, stderr = _exec(frame, "_restore_saves", {"package": pkg, "rollback": True})
try:
stdin.channel.shutdown_write()
except OSError:
pass
reply = _last_json(stdout.read().decode("utf-8", "replace")) or {}
stdout.channel.recv_exit_status()
if not reply.get("ok"):
raise SavesError(reply.get("error") or "the Frame couldn't put the saves back")
return reply["result"]
+95 -3
View File
@@ -5,6 +5,7 @@
],
" (connect the Frame first)": "",
" (list cut off)": "",
" (newest)": "",
" (update: {latest})": "",
" ({len} can't be installed there)": "",
" + Revive": "",
@@ -94,12 +95,14 @@
"AC Nexus: no AppSW at 72 Hz": "",
"AC Nexus: no AppSW at 90 Hz": "",
"Activity": "",
"Add a key file…": "",
"Add a Linux app folder…": "",
"Add a Linux app…": "",
"Add a PC game folder…": "",
"Add a tag": "",
"Add a Windows program…": "",
"Add an APK…": "",
"Add folder…": "",
"Add from a link…": "",
"Add games": "",
"Add this tag": "",
@@ -192,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).": "",
@@ -236,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.": "",
@@ -315,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}": "",
@@ -377,7 +394,7 @@
"dev build": "",
"Dev builds are early, less tested versions and may have bugs. You still get the next release as a normal update (you have {version}).": "",
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log). 2 (frameport settings) also checks every draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game clock.": "",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).": "",
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
"Disable application space warp if used": "",
@@ -450,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}": "",
@@ -557,6 +575,7 @@
"Frame OpenXR compatibility layer": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
"FramePort also logs in with {name} from the next connection.": "",
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
"FramePort changelog": "",
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
@@ -568,6 +587,7 @@
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
"FramePort keeps its own copies; nothing is installed system-wide": "",
"FramePort live view": "",
"FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH agent instead.": "",
"FramePort looked at your last session of {n} game and has something to try.": [
"",
""
@@ -664,6 +684,7 @@
"Hardware video decoding (H.264, HEVC, VP9)": "",
"Hardware video decoding on the Frame": "",
"Heads-up: that APK is an older version than the one before.": "",
"Heads-up: {name} looks like a different app than {title}.": "",
"headset found": "",
"Hi there!": "",
"Hide Android's navigation bar": "",
@@ -847,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": "",
@@ -941,16 +963,20 @@
"New recipe applied. Click Update on Frame to install it.": "",
"Newer APK for {title}": "",
"Newer APK of {title}": "",
"Newer version for {title}": "",
"Newer version of {title}": "",
"Next": "",
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
"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.": "",
@@ -958,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.": "",
@@ -973,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)": "",
@@ -1015,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.": "",
@@ -1026,17 +1055,20 @@
"On your Frame": "",
"on: {names}": "",
"One PC game folder": "",
"Only FramePort's own key, and your SSH agent's keys.": "",
"Only if 360° pictures show wrongly.": "",
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
"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}": "",
@@ -1138,6 +1170,7 @@
"Pretty smooth": "",
"Prevents a start-up crash in some Unreal games.": "",
"Previous": "",
"Private SSH key file (not the .pub file)": "",
"Problems and feedback": "",
"Processes": "",
"Processor": "",
@@ -1153,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": "",
@@ -1173,6 +1207,7 @@
"Reading the file list from the Frame…": "",
"Reading the install link": "",
"Reading the new APK": "",
"Reading the new version": "",
"ready": "",
"Ready": "",
"Ready to play": "",
@@ -1203,6 +1238,7 @@
"Remembered": "",
"Remove": "",
"Remove converted copies now": "",
"Remove folder": "",
"Remove from library…": "",
"Remove localized app names": "",
"Remove tag": "",
@@ -1245,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": [
"",
""
],
@@ -1265,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": "",
@@ -1297,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.": "",
@@ -1304,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.": "",
@@ -1313,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": "",
@@ -1412,6 +1460,7 @@
"Sound on": "",
"Source": "",
"square, 256 × 256": "",
"SSH keys": "",
"Stable (default)": "",
"Stand in for cube layers": "",
"Standard for every game": "",
@@ -1446,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": "",
@@ -1462,6 +1512,9 @@
"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).": "",
"Stops a crash a few seconds after start in some Unreal games with anti-aliasing (for example Star Wars Pinball VR).": "",
@@ -1513,6 +1566,7 @@
"Test Proton on the Frame": "",
"Text and layout size": "",
"That APK is a different app, not {title}.": "",
"That key can't be used: {error}": "",
"That theme file can't be used: {error}": "",
"That's me!": "",
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
@@ -1550,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.": "",
@@ -1647,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?": "",
@@ -1711,6 +1770,11 @@
"Update the app only?": "",
"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.": "",
@@ -1749,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.": "",
@@ -1906,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": [
"",
""
@@ -1922,6 +2003,10 @@
"",
""
],
"{n} large file left out": [
"",
""
],
"{n} line": [
"",
""
@@ -1955,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}": "",
@@ -1964,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}": "",
@@ -1971,8 +2058,13 @@
"{title}: name updated in Steam": "",
"{title}: newer APK added": "",
"{title}: newer APK added. Press Update on Frame to install it.": "",
"{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": "",
+1 -3
View File
@@ -215,9 +215,7 @@ SETTINGS = [
"findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK."),
("gl_mv_debug", "int", 0, "Multiview interposer: diagnostics",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview "
"shader and logs counters every 5 seconds (tag GLMV in launch.log). 2 (frameport settings) also checks every "
"draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game "
"clock."),
"shader and logs counters every 5 seconds (tag GLMV in launch.log)."),
("gl_hide_msrtt", "int", 1, "GL shim: hide multisampled render-to-texture",
"GL shim only: hide GL_EXT_multisampled_render_to_texture(2) (Zink crashes rendering Unity's runtime MSAA eye "
"buffer through it, for example The Room VR)."),
+342 -29
View File
@@ -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,19 +250,105 @@ 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) -> 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)."""
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
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
folder = Path(folder)
old = _existing_rift(folder)
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
if g.get("kind") == "rift"):
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
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 # set_exe: always analyze with the user's choice
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
if replace is None:
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
if old and not forced and old_extra.get("fingerprint") and \
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
if reporter:
@@ -177,7 +377,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
suggested=library.recipe_to_dict(recipe))
if not old or (old.get("recipe") or {}).get("source") != "user":
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
if replace is not None:
pass # a newer version of the game: its name stays
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
@@ -400,14 +602,20 @@ def missing_obb(entry: dict) -> bool:
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
replace: str | None = None) -> dict:
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
from .analysis import linux
from .core.models import Recipe
path = Path(path)
info = linux.inspect(path)
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
before = (library.game(replace) or {}).get("exe")
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
exe = before
if exe:
info["exe"] = exe
x86 = info["machine"] == linux.EM_X86_64
@@ -417,7 +625,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if x86 and reporter:
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
"build runs faster if there is one)")
package = f"linux.{linux.slug(info['title'])}"
package = replace or f"linux.{linux.slug(info['title'])}"
if reporter:
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
f"{', OpenXR (VR)' if info['openxr'] else ''}")
@@ -439,6 +647,8 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if not old:
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
library.upsert_game(package, **fields)
if replace: # a newer version: the artwork stays as it is
return library.game(package)
try:
from .artwork import fetch
@@ -580,7 +790,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter, x86_64=bool(extra.get("x86_64")),
desktop_entry=entry.get("desktop_entry", True) is not False,
desktop_icon=entry.get("desktop_icon", True) is not False,
overlay=linux_overlay(entry))
overlay=linux_overlay(entry), **_source_args(entry, target.label))
if extra.get("x86_64"):
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
"there; not checked ahead)")
@@ -591,10 +801,23 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
return result
def _source_args(entry: dict, label: str) -> dict:
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
sid = entry.get("source_id")
if not sid:
return {}
rec = (entry.get("installs") or {}).get(label) or {}
return {"source_id": sid,
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
@@ -608,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,
)
@@ -631,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")
@@ -655,13 +882,98 @@ 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}
def _looks_different(before: str | None, now: str | None) -> bool:
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
import difflib
a, b = _norm_title(before or ""), _norm_title(now or "")
if not a or not b or a in b or b in a:
return False
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
def _mark_source_replaced(package: str, **fields) -> dict:
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
import uuid
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
"name": the new version's own name}."""
entry = library.game(package)
if entry is None or not is_linux(entry):
raise ValueError("only Linux apps can get a newer version this way")
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
if reporter:
reporter.log(f"reading {Path(path).name}")
stored = add_linux_app(path, reporter, replace=package)
name = (stored.get("analysis") or {}).get("label")
stored = _mark_source_replaced(package, replaced_source=old_source)
return {"entry": stored, "different": _looks_different(before, name), "name": name}
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
Returns {"entry", "different", "name", "exe_changed"}."""
entry = library.game(package)
if entry is None or not is_rift(entry):
raise ValueError("only PC VR games can get a newer version folder")
folder = Path(folder)
if not folder.is_dir():
raise ValueError(f"{folder} isn't a folder")
other = _existing_rift(folder)
if other and other["package"] != package:
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
if stored is None:
raise ValueError(f"no game program found in {folder.name}")
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
name = (stored.get("analysis") or {}).get("label")
return {"entry": stored, "different": _looks_different(before, name), "name": name,
"exe_changed": stored.get("exe") != old_exe}
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
entry = library.game(package)
if entry is None:
raise ValueError(f"{package} isn't in the library")
if is_linux(entry):
return replace_linux_app(package, path, reporter)
if is_rift(entry):
return replace_rift_game(package, path, reporter)
return replace_apk(package, Path(path), reporter)
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
src = source_of(entry)
if reporter:
@@ -1275,10 +1587,11 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
overlay=rift_overlay(entry, recipe))
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
return result
+10
View File
@@ -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]
+2 -1
View File
@@ -36,7 +36,8 @@ class Target(ABC):
raise NotImplementedError(f"{self.label} can't launch games")
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
changed_since=None) -> dict:
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
raise NotImplementedError(f"{self.label} can't install Linux apps")
+4 -3
View File
@@ -86,14 +86,15 @@ class FrameLeptonTarget(Target):
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
dest=self.install_dest(), overlay=extra.get("overlay"))
dest=self.install_dest(), overlay=extra.get("overlay"),
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
return installer.install_pcvr(self.connect().frame, plan, reporter)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True):
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
desktop_icon=desktop_icon)
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
return installer.install_linux(self.connect().frame, plan, reporter)
def drives(self) -> list[dict]:
+3 -1
View File
@@ -191,7 +191,9 @@ class PcReviveTarget(Target):
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
if "pcvr.steamvr_tuning" in recipe.patches else None,
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
"time": time.time()}
exe_field, _, _ = shortcut_fields(dep)
d = pc_dir() / package
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
+160
View File
@@ -0,0 +1,160 @@
"""The announcement pop-up (frameport.announcements does the fetching and the who-sees-it rules): checked in the
background a little after start and every 6 h, shown once when no other dialog is open and the welcome screen is done.
Any way of closing it (the X, a click outside, Escape, its button) dismisses that announcement for good."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING
import flet as ft
from .. import announcements
from ..core import applog
from ..i18n import tr
from . import components as C
from . import theme as T
if TYPE_CHECKING:
from ..announcements import Announcement
from .app import FramePortApp
FIRST_DELAY = 20 # seconds after start: after the start-up work and the update check
BUSY_RETRY = 15 # another dialog is open / the welcome screen is shown: look again
LONG_BODY = 900 # characters: longer bodies scroll inside the pop-up
class Announcer:
def __init__(self, app: FramePortApp):
self.app = app
self.shown: str | None = None # id on screen (a check while it's open doesn't show it twice)
try:
announcements.since() # first start → only announcements published after it (before anything is saved)
except Exception: # noqa: BLE001
applog.log.exception("announcements: recording the first start failed")
def start(self) -> None:
if not announcements.disabled():
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self) -> None:
time.sleep(FIRST_DELAY)
while True:
try:
a = announcements.fetch()
if announcements.due(a):
self._offer(a)
except Exception: # noqa: BLE001
applog.log.exception("announcement check failed")
time.sleep(announcements.CHECK_EVERY)
def _offer(self, a: Announcement) -> None:
"""Wait for a quiet moment (no dialog, not the welcome screen), then show it (background thread)."""
image = announcements.image_file(a)
while self.app.route[0] == "welcome" or C.dialog_open(self.app.page):
time.sleep(BUSY_RETRY)
if announcements.due(a) and self.shown != a.id:
self.show(a, image)
# ---------------------------------------------------------------- the pop-up
def show(self, a: Announcement, image=None) -> None:
page = self.app.page
closed = []
def dismiss(e=None) -> None:
if closed:
return
closed.append(True)
self.shown = None
try:
announcements.dismiss(a.id)
except Exception: # noqa: BLE001
applog.log.exception("announcement: saving the dismissal failed")
def close(e=None) -> None:
dismiss()
page.pop_dialog()
def act(e=None) -> None:
close()
self.app.open_url(a.url)
self.shown = a.id
page.show_dialog(C.dialog(None, announcement_card(page, a, image, close, act if a.button else None),
content_padding=0, actions_padding=0, inset_padding=self._insets(),
clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
on_dismiss=dismiss, width=T.DIALOG_M))
def _insets(self) -> ft.Padding:
"""Centred over the content area (right of the sidebar, left of an open activity panel), not the whole
window; where that area is narrower than the pop-up, the panel and then the sidebar are left out."""
gap = T.S5
try:
left, right = self.app.content_insets()
width = float(self.app.page.width or 0)
except Exception: # noqa: BLE001
return ft.Padding.all(gap)
need = T.DIALOG_M + 2 * gap
if width and width - left - right < need:
right = 0
if width and width - left - right < need:
left = 0
return ft.Padding(left + gap, gap, right + gap, gap)
def announcement_card(page: ft.Page, a: Announcement, image, on_close, on_action=None) -> ft.Control:
"""The pop-up's content: the portal edge, the picture (if any) with the close button over it, title, date, the
Markdown body and the optional action."""
from ..artwork.thumbs import asset_url
from .updater import _notes_style, _open_url
width = T.DIALOG_M
close_tip = tr("Dismiss")
title = ft.Text(a.title, size=T.px(20), weight=ft.FontWeight.W_700, color=T.TEXT, selectable=True)
date = C.meta(time.strftime("%Y-%m-%d", time.localtime(a.date)))
body = ft.Markdown(a.body, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
md_style_sheet=_notes_style(),
on_tap_link=lambda e: _open_url(page, e.data))
if len(a.body) > LONG_BODY:
body = ft.Column([body], scroll=ft.ScrollMode.AUTO, height=T.px(320))
head: list[ft.Control] = [ft.Container(height=T.px(3), gradient=C.portal_gradient())]
if image is not None:
head.append(ft.Stack([
ft.Image(src=asset_url(image), width=width, height=_image_height(image, width), fit=ft.BoxFit.COVER,
semantics_label=a.image_alt or None),
ft.Container(
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(18), color=T.TEXT),
width=T.px(34), height=T.px(34), border_radius=T.px(17), alignment=ft.Alignment.CENTER,
bgcolor=ft.Colors.with_opacity(0.62, T.BG), border=ft.Border.all(1, T.soft(T.TEXT, 0.18)),
tooltip=close_tip, ink=True, on_click=on_close, right=T.S3, top=T.S3),
], width=width))
title_row: ft.Control = title
else:
title_row = ft.Row([ft.Container(title, expand=True, padding=ft.Padding(0, T.px(6), 0, 0)),
C.icon_btn(ft.Icons.CLOSE_ROUNDED, close_tip, on_click=on_close)],
vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S2)
text = [ft.Column([title_row, date], spacing=T.px(2), tight=True)]
if a.body:
text.append(body)
if on_action is not None:
text.append(ft.Row([C.primary(a.button, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=on_action)],
alignment=ft.MainAxisAlignment.END))
pad = ft.Padding(T.S5, T.S4 if image is None else T.S5, T.S4 if image is None else T.S5, T.S5)
return ft.Container(
ft.Column([*head, ft.Container(ft.Column(text, spacing=T.S4, tight=True), padding=pad)], spacing=0, tight=True),
width=width)
def _image_height(path, width: float) -> float:
"""The picture's height at the pop-up's width, kept between a banner and a third of a 16:9 screen."""
try:
from PIL import Image
with Image.open(path) as im:
ratio = im.height / im.width
except Exception: # noqa: BLE001
ratio = 9 / 21
return max(T.px(140), min(width * ratio, T.px(320)))
+157 -29
View File
@@ -96,6 +96,27 @@ 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)
class FramePortApp:
def __init__(self, page: ft.Page):
from .views.library import load_filters
@@ -145,9 +166,12 @@ class FramePortApp:
self.stop_live(), # and stop a live view (it would keep the Frame encoding)
self.stop_keyboard(), self.stop_monitor(), self.monitor_hub.close(),
_link_state.update(owner=None) if _link_state["owner"] is self else None)
from .announcer import Announcer
from .updater import Updater
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
# maintainer announcements (pop-up once, not on a first start); before updater.start() saves this start
self.announcer = Announcer(self)
page.title = tr("FramePort")
for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance)
@@ -182,6 +206,7 @@ class FramePortApp:
self._start_auto_pair()
threading.Thread(target=self._backfill_covers, daemon=True).start()
self.updater.start()
self.announcer.start()
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
_link_state["owner"] = self
if _link_state["thread"] is None:
@@ -236,13 +261,18 @@ class FramePortApp:
self.activity_card,
self.conn_card,
self.power_row, # under the Frame card (owner's choice)
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
], spacing=T.S2), width=T.px(SIDEBAR_WIDTH), bgcolor=T.SIDEBAR, padding=T.S4,
border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER)))
# dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom
edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else []
self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0,
vertical_alignment=ft.CrossAxisAlignment.STRETCH)]
def content_insets(self) -> tuple[float, float]:
"""How much of the window's left (the sidebar + its edge) and right (the activity panel, when open) isn't the
content area: pop-ups about the content (announcements) centre over what's between."""
return T.px(SIDEBAR_WIDTH) + (2 if T.DUAL else 0), float(self.activity.root.width or 0)
def restyle(self, theme: str) -> None:
"""Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views
FramePort keeps between visits recreated (the files/screenshots views start at their first location again;
@@ -880,7 +910,7 @@ class FramePortApp:
self.toast(msg, error=True, action=tr("Details"),
on_action=lambda e: self.show_activity(True))
self.refresh_view()
if job.kind == "scan" and self.exe_queue:
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
self.page.run_thread(self.next_exe_choice)
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
pkg, transit = self.hero_transit or (None, None)
@@ -1152,23 +1182,29 @@ class FramePortApp:
"move": lambda e: self.move_game(pkg),
"update_app": lambda e: self.update_app_only(pkg),
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
"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)),
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
selectable=lv is not None,
media_button=C.is_media_player(g) and not rift,
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
new_version=rift or linux)
return menu_sections(g, st, handlers.__getitem__)
def open_game_menu(self, pkg: str) -> None:
@@ -1621,6 +1657,60 @@ class FramePortApp:
return tr("{title}: newer APK added").format(title=self._title(pkg))
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
async def update_with_version(self, pkg: str) -> None:
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
and its page then offers Update on Frame (or on this PC)."""
from ..analysis import linux as linux_analysis
if self.jobs.busy_with(pkg):
return
g = library.game(pkg) or {}
rift = g.get("kind") == "rift"
title = tr("Newer version of {title}").format(title=self._title(pkg))
if rift or self._linux_folder_app(g):
path = await C.pick_dir(self.page, dialog_title=title)
if not path:
return
else:
paths = await C.pick_files(self.page, dialog_title=title)
if not paths:
return
path = paths[0]
if not linux_analysis.looks_like_linux_app(Path(path)):
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
.format(name=Path(path).name), error=True)
return
on_frame = self.frame_state == "connected" and \
C.install_state(g, self.frame_info) in ("installed", "outdated")
on_pc = rift and pkg in self.pc_installs()
def run(job: Job):
job.reporter.stage(tr("Reading the new version"))
res = pipeline.replace_source(pkg, Path(path), job.reporter)
if res.get("different"):
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
name=res.get("name") or Path(path).name, title=self._title(pkg)))
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
self.exe_queue.append(pkg)
self.open_game(pkg)
if on_frame or on_pc:
return tr("{title}: newer version added. Press Update to install it.").format(
title=self._title(pkg))
return tr("{title}: newer version added").format(title=self._title(pkg))
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
@staticmethod
def _linux_folder_app(g: dict) -> bool:
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
from ..analysis import linux as linux_analysis
extra = (g.get("analysis") or {}).get("extra") or {}
source = Path(extra.get("source") or g.get("game_dir") or "")
if source.exists():
return source.is_dir()
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
def updatable(self) -> list[tuple[str, str]]:
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
out = []
@@ -1697,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)."""
@@ -1774,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:
@@ -2167,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
@@ -2214,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
+13 -1
View File
@@ -178,6 +178,8 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
# still installed (GitHub #90)
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
if source_replaced(game, dep):
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
b = game.get("build") or {}
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
from ..game_update import plan_update
@@ -194,6 +196,16 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
return "outdated" if settings_diff(game, frame_info) else "installed"
def source_replaced(game: dict, dep: dict | None) -> bool:
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
(the Frame's or the PC's deployment record) doesn't have yet."""
sid = game.get("source_id")
if not sid or game.get("kind") not in ("linux", "rift"):
return False
rec = (dep or {}).get("recipe")
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
def recipe_changed(game: dict) -> bool:
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
@@ -216,7 +228,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
args = game_args(recipe_from_dict(g["recipe"]))
except Exception: # noqa: BLE001
args = dep.get("game_args") or []
return installed != want or list(dep.get("game_args") or []) != args
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
def one_choice():
+13 -2
View File
@@ -62,6 +62,9 @@ HELP: dict[str, str] = _Translated({
"hw_video_decode": "Games with the Hardware video decoding patch play their videos on the Frame's video hardware. "
"Turn it off if videos misbehave; games pick it up at their next start. For one game only, put "
"FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.",
"ssh_keys": "FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, "
"add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH "
"agent instead.",
"launch_test": "Starts the game on the Frame while nobody wears the headset and reads the log: did it start and "
"draw frames. It can't check the picture.",
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
@@ -108,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 "
@@ -117,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 "
+15 -1
View File
@@ -35,6 +35,8 @@ class MenuState:
media_button: bool = False # the game page shows "Add videos" already
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:
@@ -55,10 +57,19 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version and not job: # the same for Linux apps and PC VR games
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.on_frame and not rift and not linux and not job:
# 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"))]
@@ -84,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]
@@ -103,6 +115,8 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
out += st.installs[:1]
if st.android:
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version:
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.settings:
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
+6 -4
View File
@@ -192,11 +192,13 @@ class FrameView:
padding=T.S5)
send = C.ghost(tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda e: self.app.go("files"),
tooltip=C.tip(HELP["files"]))
quest = any(d.get("kind") not in ("pcvr", "linux") and not d.get("overlay") for d in items)
backup = [C.ghost(tr("Back up all saves"), ft.Icons.BACKUP_ROUNDED, lambda e: self.app.backup_all_saves(),
tooltip=C.tip(HELP["save_backups"]))] if quest else []
free = C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED, lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))
return C.section(tr("Installed games ({len})").format(len=len(items)), body,
action=ft.Row([send, C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED,
lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))], spacing=T.S2, tight=True)
if items else send)
action=ft.Row([send, *backup, free], spacing=T.S2, tight=True) if items else send)
def _fill_installed(self, items: list[dict], col: ft.Column) -> None:
def settings_slot() -> ft.Control:
+164
View File
@@ -0,0 +1,164 @@
"""Save backups of Quest games (GitHub discussion #352): "Back up saves" (a job), "Restore saves…" (pick a backup,
confirm; the Frame keeps the replaced saves for one undo), "Open backups folder", and "Back up all saves" on the Frame
page. The work itself is install/saves.py (shared with the CLI)."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import flet as ft
from ...errors import explain
from ...i18n import fmt_size, tr, tr_n
from ...install import saves
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
from ..jobs import Job
def _frame(app: FramePortApp):
return app._target_for("frame").frame
def when(t: float) -> str:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(t))
def backup_line(b: saves.Backup) -> str:
""""12 files · 3.4 MB of saves · version 1.2" (+ large files left out)."""
parts = [tr_n("{n} file", "{n} files", b.files), tr("{size} of saves").format(size=fmt_size(b.bytes))]
if b.version:
parts.append(tr("version {version}").format(version=b.version))
if b.skipped:
parts.append(tr_n("{n} large file left out", "{n} large files left out", b.skipped))
return " · ".join(parts)
def backup_saves(app: FramePortApp, pkg: str) -> Job:
"""Back up the game's saves from the Frame to this PC (a job)."""
title = app._title(pkg)
def run(job: Job):
b = saves.backup(_frame(app), pkg, job.reporter)
return tr("{title}: saves backed up ({line})").format(title=title, line=backup_line(b))
return app.submit(tr("Back up saves: {title}").format(title=title), run, pkg, "tool-frame")
def backup_all(app: FramePortApp, packages: list[str]) -> Job | None:
"""One job backing up every installed Quest game's saves (a game that runs or fails is reported, the rest go
on)."""
if not packages:
return None
def run(job: Job):
done, failed = 0, []
for i, pkg in enumerate(packages):
job.reporter.check_cancel()
job.reporter.log(tr("Backing up {title}").format(title=app._title(pkg)))
try:
saves.backup(_frame(app), pkg, job.reporter)
done += 1
except saves.SavesError as exc:
failed.append(f"{app._title(pkg)}: {explain(exc)}")
job.reporter.log(failed[-1])
job.reporter.progress((i + 1) / len(packages))
if failed:
return tr("Backed up {done} of {total} games' saves; not: {failed}").format(
done=done, total=len(packages), failed="; ".join(failed[:3]) + (" …" if len(failed) > 3 else ""))
return tr_n("Backed up the saves of {n} game", "Backed up the saves of {n} games", done)
return app.submit(tr("Back up all saves"), run, None, "tool-frame")
def open_folder(pkg: str) -> None:
from ...core import winhost
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
winhost.open_folder(d)
def restore_dialog(app: FramePortApp, pkg: str) -> None:
"""Pick one of the game's backups and restore it on the Frame (after a confirmation); undo the last restore."""
title = app._title(pkg)
found = saves.list_backups(pkg)
by_path = {str(b.path): b for b in found}
group = ft.RadioGroup(value=str(found[0].path) if found else None, content=ft.Column([
ft.Row([ft.Radio(value=str(b.path), active_color=T.ACCENT),
ft.Column([C.body(when(b.created) + (tr(" (newest)") if i == 0 else ""), T.TEXT),
C.meta(backup_line(b))], spacing=T.px(2), expand=True)],
spacing=T.S2)
for i, b in enumerate(found)], spacing=T.S2, scroll=ft.ScrollMode.AUTO, height=min(T.px(300),
T.px(52) * len(found))))
undo_row = ft.Row([C.meta(tr("Checking the saves on the Frame…"))], spacing=T.S2,
vertical_alignment=ft.CrossAxisAlignment.CENTER)
def restore(e):
b = by_path.get(group.value or "")
if not b:
return
def go():
app.page.pop_dialog()
def run(job: Job):
r = saves.restore(_frame(app), pkg, b.path, job.reporter)
return tr("{title}: saves from {when} restored ({n} files)").format(
title=title, when=when(b.created), n=r.get("restored", 0))
app.submit(tr("Restore saves: {title}").format(title=title), run, pkg, "tool-frame")
C.confirm(app.page, tr("Replace the saves on the Frame?"),
tr("{title}'s saves on the Frame are replaced by the backup from {when}. The Frame keeps a copy of "
"the saves it has now, so you can undo this.").format(title=title, when=when(b.created)),
tr("Restore"), go, danger=True)
def undo(e):
def go():
app.page.pop_dialog()
def run(job: Job):
saves.undo_restore(_frame(app), pkg)
return tr("{title}: the saves from before the last restore are back").format(title=title)
app.submit(tr("Undo restore: {title}").format(title=title), run, pkg, "tool-frame")
C.confirm(app.page, tr("Undo the last restore?"),
tr("Puts back the saves {title} had before the last restore. The saves it has now become the copy "
"you can undo to.").format(title=title), tr("Undo restore"), go, danger=True)
def fill():
try:
info = saves.info(_frame(app), pkg)
except Exception as exc: # noqa: BLE001
undo_row.controls = [C.meta(tr("Couldn't check the saves on the Frame: {error}").format(
error=explain(exc)))]
C.update(undo_row)
return
now = tr("On the Frame now: {line}").format(line=" · ".join(
[tr_n("{n} file", "{n} files", int(info.get("files") or 0)),
tr("{size} of saves").format(size=fmt_size(int(info.get("bytes") or 0)))]))
rows: list[ft.Control] = [ft.Container(C.meta(now), expand=True)]
rb = info.get("rollback")
if rb:
rows.append(C.ghost(tr("Undo last restore ({when})").format(when=when(rb.get("time") or 0)),
ft.Icons.UNDO_ROUNDED, on_click=undo))
undo_row.controls = rows
C.update(undo_row)
restore_btn = C.primary(tr("Restore"), ft.Icons.RESTORE_ROUNDED, on_click=restore, disabled=not found)
if found:
listing: ft.Control = group
else:
listing = C.callout(tr("No backups of {title} on this PC yet. Back up its saves first (game menu → Back up "
"saves).").format(title=title))
content = ft.Column([
C.body(tr("Choose a backup to put back on the Frame. Close the game first."), T.TEXT_2),
listing,
C.callout(tr("The game's current saves on the Frame are replaced. FramePort keeps a copy of them on the Frame, "
"so you can undo the last restore."), "warn"),
undo_row,
], spacing=T.S3, tight=True)
app.page.show_dialog(C.dialog(
tr("Restore saves · {title}").format(title=title), content, size="m",
actions=[C.ghost(tr("Open backups folder"), ft.Icons.FOLDER_OPEN_ROUNDED, on_click=lambda e: open_folder(pkg)),
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()), restore_btn]))
app.run_bg(fill)
+138 -2
View File
@@ -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")}
@@ -131,9 +133,140 @@ class SettingsView:
C.meta(tr("Large files upload together using OpenSSH when available. "
"Default: 3. Lower this on a busy network.")),
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
tooltip=tr("Your own game files aren't touched."))],
tooltip=tr("Your own game files aren't touched.")),
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)."""
from ...frame import connection
app = self.app
rows = ft.Column(spacing=T.S1)
def fill():
items = []
for path, problem in connection.extra_key_status():
items.append(ft.Row([
C.as_icon(ft.Icons.KEY_ROUNDED if not problem else ft.Icons.WARNING_AMBER_ROUNDED, T.px(18),
T.TEXT_2 if not problem else T.WARN),
ft.Column([C.body(path.name, T.TEXT), C.meta(problem or str(path), selectable=True)],
spacing=0, expand=True),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop using this key (the file stays)"),
on_click=lambda e, p=path: remove(p)),
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
rows.controls = items or [C.meta(tr("Only FramePort's own key, and your SSH agent's keys."))]
C.update(rows)
def remove(path):
connection.remove_extra_key(path)
fill()
async def add(e):
for path in await C.pick_files(app.page, dialog_title=tr("Private SSH key file (not the .pub file)"),
multiple=True):
try:
stored = connection.add_extra_key(path)
except connection.KeyFileError as exc:
app.toast(tr("That key can't be used: {error}").format(error=exc), error=True)
continue
app.toast(tr("FramePort also logs in with {name} from the next connection.").format(name=stored.name))
fill()
fill()
return ft.Column([
ft.Row([C.body(tr("SSH keys"), T.TEXT, weight=ft.FontWeight.W_500), C.help_icon("ssh_keys")],
spacing=T.px(4), vertical_alignment=ft.CrossAxisAlignment.CENTER),
rows,
C.secondary(tr("Add a key file…"), ft.Icons.VPN_KEY_ROUNDED, add),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def updates_card(self) -> ft.Control:
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
import time
@@ -450,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"),
@@ -466,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([
-17
View File
@@ -1,17 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// FrameBridge's xrGetDisplayRefreshRateFB fallback (native/adapter/refresh_rate.h, GitHub #306).
#include <assert.h>
#include "refresh_rate.h"
int main(void) {
assert(fb_refresh_rate(13888888, 0) == 72.0f); // 72 Hz frame timing
assert(fb_refresh_rate(11111111, 0) == 90.0f); // 90 Hz
assert(fb_refresh_rate(8333333, 0) == 120.0f); // 120 Hz
assert(fb_refresh_rate(6944444, 0) == 144.0f); // 144 Hz
assert(fb_refresh_rate(0, 0) == 72.0f); // no xrWaitFrame yet: never 0 Hz (PreyVR: 1000 / 0 ms per tic)
assert(fb_refresh_rate(0, 90.0f) == 90.0f); // ... else the refresh_rate setting
assert(fb_refresh_rate(-5, 0) == 72.0f);
assert(fb_refresh_rate(1000, 0) == 72.0f); // nonsense periods (1 MHz, 1 Hz) aren't believed
assert(fb_refresh_rate(1000000000, 80.0f) == 80.0f);
return 0;
}
+140
View File
@@ -0,0 +1,140 @@
"""Announcements: the committed file parses, who sees one (never on a first start), and dismissing is for good."""
from pathlib import Path
import pytest
from frameport import announcements as A
from frameport.core import library
ROOT = Path(__file__).resolve().parents[1]
DOC = """---
id: one
title: Hello
date: 2026-10-10T12:00:00Z
---
Some **news**.
"""
def ann(text=DOC):
a = A.parse(text)
assert a is not None
return a
def test_committed_announcement_is_valid():
"""announcements/current.md must parse (an inactive one is checked too): a typo fails here, not on users."""
A.parse((ROOT / A.PATH).read_text(encoding="utf-8"))
def test_parse_fields():
a = A.parse("""---
id: frameport-1.3
title: "FramePort 1.3: the Monitor"
date: 2026-11-02
image: announcements/x.png
image_alt: A picture
button: Read more
url: https://frameport.app/changelog/
expires: 2026-11-30
---
Body *here*.""")
assert a.id == "frameport-1.3" and a.title == "FramePort 1.3: the Monitor" and a.body == "Body *here*."
assert a.image == A.RAW + "announcements/x.png" and a.image_alt == "A picture"
assert a.button == "Read more" and a.url == "https://frameport.app/changelog/"
assert a.date == 1793577600.0 # 2026-11-02 00:00 UTC
assert a.expires == 1795996800.0 + 86400 # end of 2026-11-30 (inclusive)
@pytest.mark.parametrize("text", ["", " \n", "---\nactive: false\n---\n"])
def test_nothing_to_show(text):
assert A.parse(text) is None
@pytest.mark.parametrize("front, error", [
("title: x\ndate: 2026-10-10", "id"),
("id: bad id\ntitle: x\ndate: 2026-10-10", "id"),
("id: a\ndate: 2026-10-10", "title"),
("id: a\ntitle: x", "date"),
("id: a\ntitle: x\ndate: soon", "date"),
("id: a\ntitle: x\ndate: 2026-10-10\nbutton: Go", "button and url"),
("id: a\ntitle: x\ndate: 2026-10-10\nimage: http://example.com/x.png", "https"),
("id: a\ntitle: x\ndate: 2026-10-10\nimage: ../secret.png", "https"),
("id: a\ntitle: x\ndate: 2026-10-10\nwhere: app", "unknown"), # the site pop-up was dropped
("id: a\ntitle: x\ndate: 2026-10-10\ncolour: red", "unknown"),
("active: false\nid: a\ntitle: x\ndate: tomorrow", "date"), # switched off, still checked
])
def test_parse_errors(front, error):
with pytest.raises(A.AnnouncementError, match=error):
A.parse(f"---\n{front}\n---\nbody")
def test_fetch_never_raises_on_a_broken_file(monkeypatch):
monkeypatch.setattr(A.cache, "cached_text", lambda *a, **k: "no front matter")
assert A.fetch() is None
def test_first_start_sees_no_older_announcement():
"""A new install: `since` = now, so an announcement published before it never shows."""
assert A.since() > 1.9e9 - 2e8 # roughly now
assert not A.due(ann())
def test_existing_user_sees_it_once():
library.set_setting("update.last_seen_version", "1.2.0") # FramePort ran before announcements existed
assert A.since() == 0
a = ann()
assert A.due(a)
A.dismiss(a.id)
assert not A.due(a)
assert not A.due(a) # for good, also at the next check
# a typo fixed under the same id stays dismissed; a new id shows again
assert not A.due(ann(DOC.replace("Hello", "Hallo")))
assert A.due(ann(DOC.replace("id: one", "id: two")))
def test_since_is_recorded_once():
first = A.since()
library.set_setting("update.last_seen_version", "1.2.0") # written later by this same start
assert A.since() == first
def test_new_user_sees_later_announcements():
start = 1_800_000_000.0
later = ann(DOC.replace("2026-10-10T12:00:00Z", "2027-01-20T00:00:00Z")) # after `start`
assert A.due(later, now=later.date + 60, user_since=start)
assert not A.due(ann(), now=later.date + 60, user_since=start) # published before the install
def test_schedule_and_expiry():
a = ann(DOC.replace("---\nSome", "expires: 2026-10-12\n---\nSome"))
assert not A.due(a, now=a.date - 60, user_since=0) # not published yet
assert A.due(a, now=a.date + 60, user_since=0)
assert not A.due(a, now=a.expires + 1, user_since=0) # expired
def test_dismissed_list_is_bounded():
for i in range(A.KEEP_DISMISSED + 5):
A.dismiss(f"id{i}")
kept = A.dismissed()
assert len(kept) == A.KEEP_DISMISSED and kept[-1] == f"id{A.KEEP_DISMISSED + 4}"
A.dismiss("id10") # again: moves to the end, no duplicate
assert A.dismissed().count("id10") == 1 and A.dismissed()[-1] == "id10"
def test_image_file_checks_the_picture(monkeypatch, tmp_path):
from PIL import Image
src = tmp_path / "pic.png"
Image.new("RGB", (40, 20), "orange").save(src)
def fake_download(url, dest, **kw):
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(src.read_bytes() if url.endswith(".png") else b"not an image")
return dest
monkeypatch.setattr(A.cache, "download", fake_download)
good = A.image_file(ann(DOC.replace("---\nSome", "image: announcements/a.png\n---\nSome")))
assert good is not None and good.suffix == ".png" and good.exists()
assert A.image_file(ann(DOC.replace("---\nSome", "image: https://example.com/a.jpg\n---\nSome"))) is None
assert not list(good.parent.glob("*.part"))
+59
View File
@@ -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))
+9 -2
View File
@@ -55,12 +55,19 @@ def test_rewriter_on_doom3quest_shaders(tmp_path):
assert text.lstrip().startswith("#version 300 es")
vp = (out / "diffuseMapShaderVP.glsl").read_text()
assert "u_viewMatrices[(0u) ]" in vp and "#extension GL_OVR_multiview2 : enable" in vp
# optional: a real GLSL ES front end (glslangValidator: OpenGL ES 3.00 without SPIR-V)
# optional: a real GLSL ES front end (glslangValidator: OpenGL ES 3.00 without SPIR-V). Doom3Quest's sources start
# with a newline before #version (raw string literals); GL drivers accept that, glslang's ES profile doesn't, so the
# copy it checks starts at #version
validator = shutil.which("glslangValidator")
if validator:
checked = tmp_path / "checked"
checked.mkdir()
for f in rewritten:
stage = "vert" if f.stem.endswith("VP") else "frag"
subprocess.run([validator, "-S", stage, str(f)], check=True, capture_output=True)
copy = checked / f.name
copy.write_text(f.read_text().lstrip())
r = subprocess.run([validator, "-S", stage, str(copy)], capture_output=True, text=True)
assert r.returncode == 0, f"{f.name}: {r.stdout}{r.stderr}"
def _khronos_headers(dest: Path) -> Path | None:
+261
View File
@@ -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)
+37 -4
View File
@@ -27,13 +27,15 @@ 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,
installs=[_install("Reinstall on this PC")])),
installs=[_install("Reinstall on this PC")], new_version=True)),
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
selectable=True)),
selectable=True, new_version=True)),
("linux job running", LINUX, MenuState(connected=True, on_frame=True, job=True, selectable=True,
new_version=True)),
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True,
android=True)),
]
@@ -108,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"
@@ -172,3 +174,34 @@ def test_newer_apk_for_quest_games_in_both_menus():
want = g is QUEST and not st.job
assert ("Update with a newer APK…" in quick) == want, name
assert ("Update with a newer APK…" in full) == want, name
def test_newer_version_for_linux_apps_and_pc_vr_games():
"""GitHub #161: Linux apps and PC VR games get "Update with a newer version…" (a file or folder), not the APK
entry, in both menus; never while a job runs."""
for name, g, st in _states():
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
st.quick = False
full = [e[0] for e in _items(menu_sections(g, st, _key))]
want = g is not QUEST and not st.job
assert ("Update with a newer version…" in quick) == want, name
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"
+36 -6
View File
@@ -38,8 +38,36 @@ def test_quote_refuses_batch_injection(value):
native.quote(value)
def test_quote_protects_spaces_quotes_globs():
assert native.quote('a *?["b') == '"a \\*\\?\\[\\"b"'
def test_quote_leaves_globs_to_sftp():
"""Inside double quotes sftp escapes * ? [ itself: an extra backslash became part of the name (GitHub #324)."""
assert native.quote('a *?["b') == '"a *?[\\"b"'
assert native.quote("/x/te*s?t [ENG] \\b+(1).apk") == '"/x/te*s?t [ENG] \\\\b+(1).apk"'
def test_quote_windows_local_path_uses_forward_slashes():
"""Win32-OpenSSH turns a local path's backslashes into '/', escapes included ("C://Users", "/[ENG]")."""
path = r"C:\Users\user_name\AppData\Local\FramePort\output\All-In-One - Sports VR [ENG] v100+1.9.0 (x)\a.apk"
assert native.quote(path, windows=True) == (
'"C:/Users/user_name/AppData/Local/FramePort/output/All-In-One - Sports VR [ENG] v100+1.9.0 (x)/a.apk"')
assert native.quote(r"\\server\share\*?[a].apk", windows=True) == '"//server/share/*?[a].apk"'
assert native.quote('C:\\odd "name"\\a.apk', windows=True) == '"C:/odd \\"name\\"/a.apk"'
@pytest.mark.parametrize("os_name,expected", [("nt", '"C:/games/[ENG] a/x.apk"'),
("posix", '"C:\\\\games\\\\[ENG] a\\\\x.apk"')])
def test_quote_local_follows_the_platform(monkeypatch, os_name, expected):
monkeypatch.setattr(native.os, "name", os_name)
assert native.quote_local(r"C:\games\[ENG] a\x.apk") == expected
def test_batch_line_for_bracketed_folder(tmp_path, monkeypatch):
folder = tmp_path / "All-In-One - Sports VR [ENG] v100+1.9.0"
folder.mkdir()
frame, items, files, commands, processes = setup_batch(folder, monkeypatch)
monkeypatch.setattr(native.os, "name", "posix")
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
assert sorted(commands[0].splitlines()) == [f'put "{folder}/patch.obb" "/incoming/patch.obb.part"',
f'reput "{folder}/main.obb" "/incoming/main.obb.part"']
def test_size_lookup_does_not_hide_connection_errors():
@@ -65,10 +93,11 @@ def setup_batch(tmp_path, monkeypatch, code=0):
monitor.stat.side_effect = stat
monitor.remove.side_effect = lambda path: files.pop(path)
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")), modes_seen=[])
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
@contextlib.contextmanager
def creds(frame):
def creds(frame, relaxed=False):
frame.modes_seen.append(relaxed)
yield ["-o", "StrictHostKeyChecking=yes"]
monkeypatch.setattr(native, "credentials", creds)
commands, processes = [], []
@@ -210,7 +239,7 @@ def setup_tar(tmp_path, monkeypatch, code=0):
path = tmp_path / "small.bin"
path.write_bytes(b"payload")
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222))
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222), modes_seen=[])
pipe = io.BytesIO()
pipe.close = lambda: None
process = Mock(stdin=pipe, returncode=code)
@@ -219,8 +248,9 @@ def setup_tar(tmp_path, monkeypatch, code=0):
monkeypatch.setattr(native.subprocess, "Popen", popen)
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
@contextlib.contextmanager
def creds(frame, port_flag):
def creds(frame, port_flag, relaxed=False):
assert port_flag == "-p"
frame.modes_seen.append(relaxed)
yield ["-o", "StrictHostKeyChecking=yes", port_flag, "2222"]
monkeypatch.setattr(native, "credentials", creds)
return frame, [(path, "nested/small.bin")], process, popen, pipe
+230
View File
@@ -0,0 +1,230 @@
"""GitHub #161: a newer version of a Linux app or a PC VR game already in the library (game menu "Update with a newer
version…", CLI `frameport new-version`)."""
import os
import time
import pytest
from test_install_drives import FakeFrame
from test_linux_apps import elf
from test_rift import unity_game
from frameport import pipeline
from frameport.analysis import linux
from frameport.core import library
from frameport.core.events import Reporter
from frameport.install import installer
from frameport.ui import components as C
def _appimage(path, extra=b""):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(elf(linux.EM_AARCH64, appimage=True, extra=extra))
return path
def _frame(package, recipe=None):
return {"installed": [{"package": package, "sha256": None, "recipe": recipe}]}
# ------------------------------------------------------------------------------------------ Linux apps
def test_newer_appimage_keeps_name_art_and_switches(tmp_path):
old = _appimage(tmp_path / "v1" / "Venera-Prime-2.4.2-aarch64.AppImage")
pkg = pipeline.add_linux_app(old)["package"]
library.upsert_game(pkg, title="My Venera", title_locked=True, tags=["mine"], desktop_entry=False,
desktop_icon=False, vr_overlay=True)
assert C.install_state(library.game(pkg), _frame(pkg)) == "installed" # never updated: no source id needed
new = _appimage(tmp_path / "v2" / "Venera-Prime-2.5.0-aarch64.AppImage", extra=b"newer")
res = pipeline.replace_linux_app(pkg, new)
g = library.game(pkg)
assert g["package"] == pkg and g["exe"] == new.name and g["game_dir"] == str(new.parent)
assert g["analysis"]["extra"]["source"] == str(new) and g["analysis"]["extra"]["files"] == [new.name]
assert g["data_bytes"] == new.stat().st_size
assert (g["title"], g["title_locked"], g["tags"]) == ("My Venera", True, ["mine"])
assert (g["desktop_entry"], g["desktop_icon"], g["vr_overlay"]) == (False, False, True)
assert g["replaced_source"] == str(old) and g["source_id"] and res["different"] is False
# the Frame's copy (installed before) is outdated until an install records this version
assert C.install_state(g, _frame(pkg)) == "outdated"
assert C.install_state(g, _frame(pkg, {"source_id": "older"})) == "outdated"
assert C.install_state(g, _frame(pkg, {"source_id": g["source_id"]})) == "installed"
def test_newer_linux_folder_keeps_the_chosen_program(tmp_path):
def app(root, size):
(root / "bin").mkdir(parents=True)
(root / "Tool").write_bytes(elf(linux.EM_AARCH64, extra=b"\0" * size)) # bigger: FramePort's pick
(root / "bin" / "tool-vr").write_bytes(elf(linux.EM_AARCH64))
return root
pkg = pipeline.add_linux_app(app(tmp_path / "v1" / "Tool", 4096), exe="bin/tool-vr")["package"]
assert library.game(pkg)["exe"] == "bin/tool-vr"
pipeline.replace_linux_app(pkg, app(tmp_path / "v2" / "Tool", 8192))
g = library.game(pkg)
assert g["exe"] == "bin/tool-vr" and g["game_dir"] == str(tmp_path / "v2" / "Tool")
# the program isn't there any more: FramePort's pick
(tmp_path / "v3" / "Tool").mkdir(parents=True)
(tmp_path / "v3" / "Tool" / "Tool").write_bytes(elf(linux.EM_AARCH64))
pipeline.replace_linux_app(pkg, tmp_path / "v3" / "Tool")
assert library.game(pkg)["exe"] == "Tool"
def test_another_linux_app_is_added_with_a_warning(tmp_path):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Venera-Prime-1.0-aarch64.AppImage"))["package"]
res = pipeline.replace_linux_app(pkg, _appimage(tmp_path / "Blender-4.2-aarch64.AppImage"))
assert res["different"] and res["name"] == "Blender" and library.game(pkg)["exe"].startswith("Blender")
assert not library.game("linux.blender") # no second entry: the user's pick is the newer version
with pytest.raises(ValueError):
pipeline.replace_linux_app(pkg, tmp_path / "missing.AppImage")
def test_only_the_right_kind(tmp_path):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Tool-aarch64.AppImage"))["package"]
with pytest.raises(ValueError):
pipeline.replace_rift_game(pkg, tmp_path)
library.upsert_game("com.x.quest", title="Quest", apk="/x.apk")
with pytest.raises(ValueError):
pipeline.replace_linux_app("com.x.quest", tmp_path / "Tool-aarch64.AppImage")
def test_linux_update_sends_changed_files_of_the_same_size(tmp_path, monkeypatch):
(tmp_path / "app").mkdir()
for name in ("tool", "data.txt"):
(tmp_path / "app" / name).write_bytes(b"x" * 10)
old = time.time() - 3600
os.utime(tmp_path / "app" / "data.txt", (old, old))
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "apply_app_icon", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
sent = []
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10, "data.txt": 10}}, "free_bytes": 1 << 40,
"incoming": "/x/incoming", "base": "/b"},
"finalize_linux": {"base": "/b", "appid": 1}})
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool")
installer.install_linux(frame, plan, Reporter())
assert sent == [] and dict(frame.calls)["finalize_linux"]["recipe"] is None # same sizes: nothing sent
# (st_ctime can't be set: the file's own ctime is now) a newer version: files changed since the last install
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", source_id="v2",
changed_since=time.time() + 60)
installer.install_linux(frame, plan, Reporter())
assert sent == []
plan.changed_since = time.time() - 60
installer.install_linux(frame, plan, Reporter())
assert sorted(sent) == ["app/data.txt", "app/tool"]
assert frame.calls[-1][1]["recipe"] == {"source_id": "v2"}
class _Null:
def __enter__(self):
return None
def __exit__(self, *a):
return False
def test_install_records_the_version(tmp_path, monkeypatch):
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-aarch64.AppImage"))["package"]
got = {}
class T:
label = "Frame"
def install_linux(self, *a, **k):
got.clear()
got.update(k)
return {}
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert "source_id" not in got # never updated: installs as before
pipeline.replace_linux_app(pkg, _appimage(tmp_path / "v2" / "Tool-aarch64.AppImage"))
sid = library.game(pkg)["source_id"]
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert got["source_id"] == sid and 0 < got["changed_since"] <= time.time() # since the last install there
assert library.game(pkg)["installs"]["Frame"]["source_id"] == sid
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
assert got["changed_since"] is None # this version is there already: resumes as usual
# ------------------------------------------------------------------------------------------ PC VR (Rift) games
def _rift(tmp_path):
first = unity_game(tmp_path / "v1")
[g] = pipeline.add_path(tmp_path / "v1")
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", tags=["mine"], exe_confirmed=True,
build={"ok": True, "sha256": "old", "kind": "rift"})
return pkg, first
def test_newer_rift_folder_keeps_name_recipe_and_program(tmp_path):
pkg, first = _rift(tmp_path)
new = unity_game(tmp_path / "v2")
res = pipeline.replace_rift_game(pkg, new)
g = library.game(pkg)
assert g["game_dir"] == str(new) and g["exe"] == "Space Game.exe" and g["exe_confirmed"] is True
assert (g["title"], g["tags"]) == ("My Space", ["mine"])
assert "pcvr.no_crash_reporter" in g["recipe"]["patches"] and g["recipe"]["source"] == "user"
assert g["build"] is None and g["replaced_dirs"] == [str(first)] and not res["different"]
from frameport.patches.pcvr import game_args
dep = {"package": pkg, "sha256": "old", "recipe": {"patches": sorted(g["recipe"]["patches"])},
"game_args": game_args(library.recipe_from_dict(g["recipe"]))}
assert C.install_state(g, {"installed": [dep]}) == "outdated"
assert C.pc_outdated(g, dep)
dep["recipe"]["source_id"] = g["source_id"]
assert C.install_state(g, {"installed": [dep]}) == "installed" and not C.pc_outdated(g, dep)
# a rescan of the folders doesn't bring the old copy back
assert pipeline.add_path(tmp_path / "v1") == [] and library.game(pkg)["game_dir"] == str(new)
def test_newer_rift_folder_without_the_program_picks_again(tmp_path):
pkg, _ = _rift(tmp_path)
new = unity_game(tmp_path / "v2", name="Space Game VR")
res = pipeline.replace_rift_game(pkg, new)
assert res["exe_changed"] and library.game(pkg)["exe"] == "Space Game VR.exe"
with pytest.raises(ValueError):
pipeline.replace_rift_game(pkg, tmp_path / "nothing")
other = unity_game(tmp_path / "other", name="Moon Game")
pipeline.add_path(tmp_path / "other")
with pytest.raises(ValueError, match="already in the library"):
pipeline.replace_rift_game(pkg, other)
def test_rift_update_resends_changed_game_files(tmp_path, monkeypatch):
game = unity_game(tmp_path)
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
sent = []
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
monkeypatch.setattr("frameport.artwork.sources.has_art", lambda pkg: True)
manifest = installer.local_data_manifest(game)
frame = FakeFrame({"prepare_pcvr": {"proton": {"display_name": "Proton"}, "openxr": None,
"existing": {"game": dict(manifest)}, "free_bytes": 1 << 40,
"incoming": "/i", "base": "/b"},
"finalize_pcvr": {"base": "/b", "proton": "p", "appid": 1}})
from frameport.core.models import Recipe
plan = installer.PcvrPlan("rift.space_game", "Space", game, "Space Game.exe", Recipe(package="rift.space_game"),
None, source_id="v2", changed_since=time.time() - 60)
installer.install_pcvr(frame, plan, Reporter())
assert sorted(sent) == sorted(f"game/{rel}" for rel in manifest)
assert frame.calls[-1][1]["recipe"]["source_id"] == "v2"
# ------------------------------------------------------------------------------------------ CLI
def test_cli_new_version(tmp_path):
from typer.testing import CliRunner
from frameport import cli
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-1.0-aarch64.AppImage"))["package"]
new = _appimage(tmp_path / "v2" / "Tool-1.1-aarch64.AppImage")
out = CliRunner().invoke(cli.app, ["new-version", pkg, str(new)])
assert out.exit_code == 0, out.output
assert library.game(pkg)["exe"] == new.name and "program Tool-1.1-aarch64.AppImage" in out.output
(tmp_path / "empty").mkdir()
bad = CliRunner().invoke(cli.app, ["new-version", pkg, str(tmp_path / "empty")]) # no program in it
assert bad.exit_code == 2
-43
View File
@@ -1,43 +0,0 @@
"""FrameBridge answers xrGetDisplayRefreshRateFB with a real rate (GitHub #306, PreyVR's black main menu).
The Frame's runtime has no XR_FB_display_refresh_rate; OVRPort's dispatcher still offers it and answered 0 Hz. Team
Beef's idTech 4 ports (PreyVR, the Doom3Quest engine) run 1000 / refresh ms per tic, so 0 Hz stopped their game and
GUI clock: the main menu stayed at its first animation step (black, only the cursor)."""
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from frameport.validate import triage
ROOT = Path(__file__).resolve().parents[1]
def test_fallback_rate_is_never_zero(tmp_path):
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
if not cc or os.name == "nt":
pytest.skip("needs a POSIX host C compiler")
binary = tmp_path / "refresh-rate-test"
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/adapter"),
str(ROOT / "tests/fixtures/src/refresh_rate_test.c"), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_the_hook_is_always_installed():
src = (ROOT / "native/adapter/frame_adapter.c").read_text()
# not behind a per-game setting: every game that asks gets a rate, also where the runtime lacks the function
assert re.search(r"\n if \(instance != XR_NULL_HANDLE\) HOOK_AS\(xrGetDisplayRefreshRateFB, hook_get_refresh_rate\)",
src)
assert "fb_refresh_rate(" in (ROOT / "native/adapter/session_fixes.c").read_text()
def test_a_single_rejected_frame_is_not_an_error():
entries = {e["id"]: e for e in yaml.safe_load((ROOT / "catalog/triage.yaml").read_text())["signatures"]}
assert entries["cylinder-menu-layers-rejected"]["severity"] == "info"
log = "10-16 05:06:16.011 1188 1248 I FrameBridge: xrEndFrame failed -16: blend=1 layers=7\n"
found = [f for f in triage.triage(log).findings if f.id == "cylinder-menu-layers-rejected"]
assert found and found[0].severity == "info"
+204
View File
@@ -0,0 +1,204 @@
"""Save backups on the PC (GitHub discussion #352): storage, retention, manifests, and the stream to/from the agent,
which runs here for real (a stand-in `podman` whose `unshare` just runs the command)."""
import json
import os
import subprocess
import sys
import tarfile
import time
from pathlib import Path
import pytest
from frameport.core import library
from frameport.install import saves
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
PKG = "com.example.game"
class _Chan:
def __init__(self, proc):
self.proc = proc
def shutdown_write(self):
if not self.proc.stdin.closed:
self.proc.stdin.close()
def recv_exit_status(self):
return self.proc.wait(30)
def close(self):
self.proc.kill()
class _Stream:
def __init__(self, f, chan):
self.f, self.channel = f, chan
def read(self, n=-1):
return self.f.read(n)
def write(self, data):
self.f.write(data)
self.f.flush()
class FakeFrame:
"""The agent run as a local process over pipes, like an SSH exec channel."""
def __init__(self, home: Path, env: dict):
self.home, self.env = home, env
self.client = self
def ensure_agent(self):
return str(AGENT)
def exec_command(self, cmd, timeout=None):
proc = subprocess.Popen(["sh", "-c", cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=self.env)
chan = _Chan(proc)
return _Stream(proc.stdin, chan), _Stream(proc.stdout, chan), _Stream(proc.stderr, chan)
def agent(self, command, **args):
p = subprocess.run([sys.executable, str(AGENT), command], input=json.dumps(args), capture_output=True,
text=True, env=self.env)
reply = json.loads(p.stdout.strip().splitlines()[-1])
if not reply["ok"]:
raise RuntimeError(reply["error"])
return reply["result"]
@pytest.fixture
def frame(tmp_path, monkeypatch):
home = tmp_path / "frame-home"
anchor = home / "Applications/quest-frame" / PKG
(anchor / "lepton-app").mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": PKG, "appid": 7, "version": "2.0"}))
internal = anchor / "lepton-data/internal" / PKG
external = anchor / "lepton-data/external/Android/data" / PKG
for path, data in ((internal / "shared_prefs/p.xml", "<map/>"), (internal / "files/slot.sav", "level 3"),
(external / "files/save.dat", "coins 10"), (external / "cache/x", "cache")):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
podman = bin_dir / "podman"
podman.write_text('#!/bin/sh\nif [ "$1" = unshare ]; then shift; exec "$@"; fi\n'
'if [ "$1" = ps ]; then cat "$FAKE_PS" 2>/dev/null; fi\nexit 0\n')
podman.chmod(0o755)
env = dict(os.environ, HOME=str(home), PATH=f"{bin_dir}:{os.environ['PATH']}", FAKE_PS=str(tmp_path / "ps"))
env.pop("FRAMEPORT_SAVES_NS", None)
return FakeFrame(home, env), anchor, internal, external, tmp_path
def test_backup_restore_round_trip_through_the_agent(frame):
fr, anchor, internal, external, tmp = frame
b = saves.backup(fr, PKG)
assert b.path.parent == saves.backups_dir(PKG) and b.path.exists() and b.sidecar.exists()
assert b.files == 3 and b.version == "2.0" and b.bytes == len("<map/>level 3coins 10")
m = saves.read_manifest(b.path)
assert {f["path"] for f in m["files"]} == {"internal/shared_prefs/p.xml", "internal/files/slot.sav",
"external/files/save.dat"}
assert saves.list_backups(PKG)[0].path == b.path
(external / "files/save.dat").write_text("coins 0")
r = saves.restore(fr, PKG, b.path)
assert (external / "files/save.dat").read_text() == "coins 10"
assert Path(r["rollback"]) == anchor / "saves-before-restore.tar.gz"
assert saves.info(fr, PKG)["rollback"]["size"] > 0
saves.undo_restore(fr, PKG)
assert (external / "files/save.dat").read_text() == "coins 0"
def test_backup_refused_while_the_game_runs(frame):
fr, anchor, internal, external, tmp = frame
(tmp / "ps").write_text("lepton-steamlaunch-7\n")
with pytest.raises(saves.SavesError, match="running"):
saves.backup(fr, PKG)
assert not list(saves.backups_dir(PKG).glob("*.part")) and not saves.list_backups(PKG)
with pytest.raises(saves.SavesError, match="running"):
saves.restore(fr, PKG, _fake_backup(PKG))
def _fake_backup(pkg: str, created: float | None = None, name: str | None = None) -> Path:
import io
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
path = d / (name or time.strftime("%Y%m%d-%H%M%S.tar.gz"))
raw = json.dumps({"format": 1, "package": pkg, "created": created or time.time(), "files": []}).encode()
with tarfile.open(path, "w:gz") as tar:
ti = tarfile.TarInfo(saves.MANIFEST)
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
return path
def test_retention_keeps_the_newest(tmp_path):
for i in range(6):
_fake_backup(PKG, created=1000 + i, name=f"2026010{i}-120000.tar.gz")
assert len(saves.list_backups(PKG)) == 6 # sidecars rebuilt from the archives
assert all(p.with_suffix("").with_suffix(".json").exists() for p in saves.backups_dir(PKG).glob("*.tar.gz"))
gone = saves.prune(PKG, keep=4)
assert sorted(p.name for p in gone) == ["20260100-120000.tar.gz", "20260101-120000.tar.gz"]
left = saves.list_backups(PKG)
assert [b.created for b in left] == [1005, 1004, 1003, 1002]
assert not (saves.backups_dir(PKG) / "20260100-120000.json").exists()
def test_keep_setting(tmp_path):
assert saves.keep_count() == saves.KEEP_DEFAULT
library.set_setting("saves.keep", 3)
assert saves.keep_count() == 3
library.set_setting("saves.keep", "junk")
assert saves.keep_count() == saves.KEEP_DEFAULT
def test_restore_refuses_a_foreign_or_broken_file_before_touching_the_frame(tmp_path):
other = _fake_backup("com.other.game")
class NoFrame:
def ensure_agent(self):
raise AssertionError("the Frame must not be contacted")
with pytest.raises(saves.SavesError, match="com.other.game"):
saves.restore(NoFrame(), PKG, other)
junk = tmp_path / "junk.tar.gz"
junk.write_bytes(b"nope")
with pytest.raises(ValueError):
saves.restore(NoFrame(), PKG, junk)
def test_cli_backup_list_restore_undo(frame, monkeypatch):
from types import SimpleNamespace
from typer.testing import CliRunner
from frameport import cli
fr, anchor, internal, external, tmp = frame
target = SimpleNamespace(frame=fr, installed=lambda: [{"package": PKG}, {"package": "rift.x", "kind": "pcvr"}])
monkeypatch.setattr(cli, "_target", lambda frame=None, password=None, to="frame": target)
run = CliRunner().invoke
r = run(cli.app, ["saves", "backup", "--all"])
assert r.exit_code == 0, r.output
assert f"{PKG}: 3 file(s)" in r.output and "rift.x" not in r.output
r = run(cli.app, ["saves", "list", PKG])
assert r.exit_code == 0 and r.output.lstrip().startswith("1 ")
(external / "files/save.dat").write_text("lost")
r = run(cli.app, ["saves", "restore", PKG, "1", "--yes"])
assert r.exit_code == 0, r.output
assert (external / "files/save.dat").read_text() == "coins 10"
r = run(cli.app, ["saves", "undo", PKG])
assert r.exit_code == 0, r.output
assert (external / "files/save.dat").read_text() == "lost"
r = run(cli.app, ["saves", "restore", PKG, "7", "--yes"])
assert r.exit_code == 2 # no 7th backup
(tmp / "ps").write_text("lepton-steamlaunch-7\n")
r = run(cli.app, ["saves", "backup", PKG])
assert r.exit_code == 1 and "running" in r.output
def test_bad_package_names_never_make_paths():
with pytest.raises(ValueError):
saves.backups_dir("../../etc")
assert saves.has_backups("../x") is False
+323
View File
@@ -0,0 +1,323 @@
"""Save backups on the Frame (GitHub discussion #352): the agent's archive builder, the untrusted-archive checks on
restore, refusing while the game runs, and the rollback copy. podman unshare is switched off (files are ours here)."""
import importlib.util
import io
import json
import os
import tarfile
from pathlib import Path
import pytest
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
PKG = "com.example.game"
def load_agent(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("FRAMEPORT_SAVES_NS", raising=False)
spec = importlib.util.spec_from_file_location("frameport_agent_saves", AGENT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
monkeypatch.setattr(mod, "as_owner", lambda: [])
monkeypatch.setattr(mod, "container_running", lambda appid: False)
return mod
def write(path: Path, data, mode=0o660):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data if isinstance(data, bytes) else data.encode())
os.chmod(path, mode)
return path
@pytest.fixture
def game(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = Path(a.ANCHORS) / PKG
anchor.mkdir(parents=True)
(anchor / "lepton-app").mkdir()
(anchor / "deployment.json").write_text(json.dumps({"package": PKG, "appid": 123, "version": "1.2.3",
"title": "Example"}))
internal = anchor / "lepton-data/internal" / PKG
external = anchor / "lepton-data/external/Android/data" / PKG
write(internal / "shared_prefs/prefs.xml", "<map><int name='level' value='7'/></map>")
write(internal / "files/UnrealGame/G/G/Saved/SaveGames/slot1.sav", b"\x01" * 100)
write(internal / "files/UnrealGame/G/G/Saved/Logs/G.log", "log")
write(internal / "cache/tmp.bin", b"x" * 10)
write(internal / "code_cache/x", b"x")
write(internal / "databases/game.db", b"db")
write(external / "files/SaveData.es3", "save")
write(external / "files/fb_snap_0.ppm", b"P6")
write(external / "files/framebridge.conf", "scale=1")
write(external / "files/il2cpp/Metadata/global-metadata.dat", b"m" * 50)
write(external / "files/VulkanProgramBinaryCache/x.bin", b"v")
write(external / "files/Video/intro.mp4", b"vid")
write(external / "files/big.bundle", b"b" * 2000)
write(external / "cache/ga.sqlite3", b"g")
os.symlink("/etc", external / "files/escape")
return a, anchor, internal, external
def backup_bytes(a, max_file=1000):
out, err = io.BytesIO(), io.StringIO()
assert a.saves_backup_session({"package": PKG, "max_file": max_file}, out, err) == 0, err.getvalue()
reply = json.loads(err.getvalue().strip().splitlines()[-1])
assert reply["ok"]
return out.getvalue(), reply["result"]
def names(data: bytes) -> list[str]:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
return tar.getnames()
def manifest_of(data: bytes) -> dict:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
return json.loads(tar.extractfile("frameport-saves.json").read())
def test_backup_holds_saves_and_leaves_caches_out(game):
a, anchor, internal, external = game
data, summary = backup_bytes(a)
got = names(data)
assert got[-1] == "frameport-saves.json"
files = {n for n in got if not n.endswith(".json")}
assert "internal/shared_prefs/prefs.xml" in files
assert "internal/files/UnrealGame/G/G/Saved/SaveGames/slot1.sav" in files
assert "internal/databases/game.db" in files
assert "external/files/SaveData.es3" in files
for left_out in ("internal/cache/tmp.bin", "internal/code_cache/x",
"internal/files/UnrealGame/G/G/Saved/Logs/G.log",
"external/files/fb_snap_0.ppm", "external/files/framebridge.conf",
"external/files/il2cpp/Metadata/global-metadata.dat",
"external/files/VulkanProgramBinaryCache/x.bin", "external/files/Video/intro.mp4",
"external/files/big.bundle", "external/cache/ga.sqlite3", "external/files/escape"):
assert left_out not in files, left_out
m = manifest_of(data)
assert m["package"] == PKG and m["version"] == "1.2.3" and m["format"] == 1
by_path = {f["path"]: f for f in m["files"]}
assert by_path["internal/files/UnrealGame/G/G/Saved/SaveGames/slot1.sav"]["size"] == 100
assert len(by_path["external/files/SaveData.es3"]["sha256"]) == 64
assert by_path["internal/shared_prefs/prefs.xml"]["mode"] == 0o660
reasons = {s["path"]: s["reason"] for s in m["skipped"]}
assert reasons["external/files/big.bundle"] == "size" and reasons["external/files/escape"] == "link"
assert summary["files"] == len(m["files"])
assert [s["path"] for s in summary["skipped"]] == ["external/files/big.bundle"]
def test_backup_refused_while_the_game_runs(game, monkeypatch):
a, *_ = game
monkeypatch.setattr(a, "container_running", lambda appid: True)
out, err = io.BytesIO(), io.StringIO()
assert a.saves_backup_session({"package": PKG}, out, err) == 1
assert out.getvalue() == b"" and "running" in json.loads(err.getvalue())["error"]
def test_backup_and_restore_refused_while_the_launcher_holds_its_lock(game):
import fcntl
a, anchor, *_ = game
fd = os.open(anchor / ".launch.lock", os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
try:
err = io.StringIO()
assert a.saves_backup_session({"package": PKG}, io.BytesIO(), err) == 1
assert "running" in err.getvalue()
out = io.StringIO()
assert a.saves_restore_session({"package": PKG}, io.BytesIO(b""), out) == 1
assert "running" in out.getvalue()
finally:
os.close(fd)
def test_backup_refuses_pc_vr_and_unknown_games(game):
a, anchor, *_ = game
err = io.StringIO()
assert a.saves_backup_session({"package": "com.not.installed"}, io.BytesIO(), err) == 1
assert "not installed" in err.getvalue()
err = io.StringIO()
assert a.saves_backup_session({"package": "../etc"}, io.BytesIO(), err) == 1
def restore(a, data: bytes, **extra) -> dict:
out = io.StringIO()
a.saves_restore_session({"package": PKG, **extra}, io.BytesIO(data), out)
return json.loads(out.getvalue().strip().splitlines()[-1])
def test_restore_round_trip_keeps_modes_and_makes_a_rollback_copy(game):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
save = external / "files/SaveData.es3"
prefs = internal / "shared_prefs/prefs.xml"
os.chmod(prefs, 0o600) # the Frame's current file keeps its own mode
save.write_text("newer progress")
write(internal / "files/new_slot.sav", "made after the backup")
big = external / "files/big.bundle"
reply = restore(a, data)
assert reply["ok"], reply
r = reply["result"]
assert save.read_text() == "save"
assert not (internal / "files/new_slot.sav").exists() # replaced, not merged
assert big.exists() and (internal / "cache/tmp.bin").exists() # left-out files stay
assert (prefs.stat().st_mode & 0o777) == 0o600
assert r["restored"] >= 4 and r["removed"] == 1
rollback = anchor / "saves-before-restore.tar.gz"
assert Path(r["rollback"]) == rollback and rollback.exists()
assert "internal/files/new_slot.sav" in names(rollback.read_bytes())
assert not list(anchor.glob(".saves-restore-*")) # staging cleaned up
# undo: the rollback copy goes back, and the restored state becomes the new rollback copy
reply = restore(a, b"", rollback=True)
assert reply["ok"], reply
assert save.read_text() == "newer progress" and (internal / "files/new_slot.sav").exists()
assert "internal/files/new_slot.sav" not in names(rollback.read_bytes())
def test_restore_onto_a_fresh_install_creates_the_folders(game):
import shutil
a, anchor, internal, external = game
data, _ = backup_bytes(a)
shutil.rmtree(anchor / "lepton-data")
reply = restore(a, data)
assert reply["ok"], reply
assert (internal / "files/UnrealGame/G/G/Saved/SaveGames/slot1.sav").read_bytes() == b"\x01" * 100
assert (external / "files/SaveData.es3").read_text() == "save"
assert internal.is_dir() and not (external / "cache").exists()
def evil_archive(members: list[tuple[tarfile.TarInfo, bytes | None]], manifest: dict | None = None) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for ti, data in members:
tar.addfile(ti, io.BytesIO(data) if data is not None else None)
if manifest is not None:
raw = json.dumps(manifest).encode()
ti = tarfile.TarInfo("frameport-saves.json")
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
return buf.getvalue()
def reg(name: str, data: bytes) -> tuple[tarfile.TarInfo, bytes]:
ti = tarfile.TarInfo(name)
ti.size = len(data)
return ti, data
def good_manifest(*files: tuple[str, bytes]) -> dict:
import hashlib
return {"format": 1, "package": PKG, "files": [{"path": n, "size": len(d), "sha256": hashlib.sha256(d).hexdigest()}
for n, d in files]}
@pytest.mark.parametrize("name", ["../../../etc/passwd", "/etc/passwd", "internal/../../x", "other/file", "internal",
"internal\\..\\x"])
def test_restore_refuses_paths_outside_the_save_folders(game, name):
a, anchor, *_ = game
data = evil_archive([reg(name, b"x")], good_manifest((name, b"x")))
reply = restore(a, data)
assert not reply["ok"]
assert not (anchor / "saves-before-restore.tar.gz").exists() # nothing touched
@pytest.mark.parametrize("kind", [tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.CHRTYPE, tarfile.FIFOTYPE])
def test_restore_refuses_links_and_devices(game, kind):
a, *_ = game
ti = tarfile.TarInfo("internal/files/x")
ti.type, ti.linkname = kind, "/etc/passwd"
reply = restore(a, evil_archive([(ti, None)], good_manifest()))
assert not reply["ok"] and "link or special" in reply["error"]
def test_restore_refuses_writing_through_a_link_on_the_frame(game):
a, anchor, internal, external = game
os.symlink(str(anchor.parent), internal / "lib")
data = evil_archive([reg("internal/lib/evil.txt", b"x")], good_manifest(("internal/lib/evil.txt", b"x")))
reply = restore(a, data)
assert not reply["ok"] and "link" in reply["error"]
assert not (anchor.parent / "evil.txt").exists()
def test_restore_refuses_damaged_foreign_or_manifestless_archives(game):
a, *_ = game
good = ("internal/files/s.sav", b"save")
assert "no manifest" in restore(a, evil_archive([reg(*good)]))["error"]
bad_hash = good_manifest(good)
bad_hash["files"][0]["sha256"] = "0" * 64
assert "doesn't match" in restore(a, evil_archive([reg(*good)], bad_hash))["error"]
other = good_manifest(good)
other["package"] = "com.other.game"
assert "com.other.game" in restore(a, evil_archive([reg(*good)], other))["error"]
assert not restore(a, b"not a tar at all")["ok"]
whole, _ = backup_bytes(a)
assert not restore(a, whole[: len(whole) // 2])["ok"] # cut off mid-stream
def test_restore_refuses_oversize_members(game, monkeypatch):
a, *_ = game
monkeypatch.setattr(a, "SAVES_RESTORE_MAX_FILE", 3)
good = ("internal/files/s.sav", b"save")
reply = restore(a, evil_archive([reg(*good)], good_manifest(good)))
assert not reply["ok"] and "too big" in reply["error"]
def test_restore_refused_while_running_leaves_everything(game, monkeypatch):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
monkeypatch.setattr(a, "container_running", lambda appid: True)
reply = restore(a, data)
assert not reply["ok"] and "running" in reply["error"]
assert not (anchor / "saves-before-restore.tar.gz").exists()
def test_failed_swap_puts_the_old_saves_back(game, monkeypatch):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
save = external / "files/SaveData.es3"
save.write_text("current")
real = a.apply_staged_saves
calls = []
def flaky(roots, entries, manifest):
calls.append(1)
if len(calls) == 1:
real(roots, {k: e for k, e in entries.items() if e["kind"] == "dir" or "SaveData" in k[-1]}, manifest)
raise OSError("disk full")
return real(roots, entries, manifest)
monkeypatch.setattr(a, "apply_staged_saves", flaky)
reply = restore(a, data)
assert not reply["ok"] and "put back" in reply["error"]
assert save.read_text() == "current"
def test_saves_info_and_rollback_delete(game):
a, anchor, *_ = game
info = a.cmd_saves_info({"package": PKG, "max_file": 1000})
assert info["files"] >= 4 and info["rollback"] is None and info["running"] is False
assert [s["path"] for s in info["skipped"]] == ["external/files/big.bundle"]
data, _ = backup_bytes(a)
assert restore(a, data)["ok"]
assert a.cmd_saves_info({"package": PKG})["rollback"]["size"] > 0
assert a.cmd_saves_rollback_delete({"package": PKG}) == {"removed": True}
assert a.cmd_saves_info({"package": PKG})["rollback"] is None
def test_namespace_handoff_runs_the_agent_in_podman_unshare(game, monkeypatch):
a, *_ = game
seen = {}
class Done:
returncode = 0
def fake_run(cmd, env=None, **kw):
seen["cmd"], seen["ns"] = cmd, env.get("FRAMEPORT_SAVES_NS")
return Done()
monkeypatch.setattr(a, "as_owner", lambda: ["podman", "unshare"])
monkeypatch.setattr(a.subprocess, "run", fake_run)
assert a.saves_backup_session({"package": PKG}, io.BytesIO(), io.StringIO()) == 0
assert seen["cmd"][:2] == ["podman", "unshare"] and seen["cmd"][3:5] == [str(AGENT), "_saves_ns"]
assert seen["cmd"][5] == "backup" and json.loads(seen["cmd"][6]) == {"package": PKG}
assert seen["ns"] == "1"
+340
View File
@@ -0,0 +1,340 @@
"""SSH keys beyond FramePort's own (GitHub #242): key files from Settings, the login a connection used, and OpenSSH
uploads that fall back to this PC's own keys/agent when the Frame refuses FramePort's key."""
import contextlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import paramiko
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from frameport.core import library
from frameport.core.events import Reporter
from frameport.frame import connection as C
from frameport.frame import native_upload as native
REFUSED = (b"debug1: Reading configuration data nul\r\n"
b"debug1: identity file C:/Temp/frameport-upload-x/key0 type 3\r\n"
b"debug1: Connecting to 10.0.0.2 [10.0.0.2] port 22.\r\n"
b"@ WARNING: UNPROTECTED PRIVATE KEY FILE! @\r\n"
b"debug1: Offering public key: C:/Temp/frameport-upload-x/key0 ED25519 SHA256:abc explicit\r\n"
b"debug1: Authentications that can continue: publickey,password\r\n"
b"debug1: kex: algorithm: curve25519-sha256\r\n"
b"steamos@10.0.0.2: Permission denied (publickey,password).\r\n")
@pytest.fixture(autouse=True)
def no_real_home_keys(tmp_path, monkeypatch):
"""Never read the real ~/.ssh (relaxed mode looks there)."""
empty = tmp_path / "empty-home"
empty.mkdir()
monkeypatch.setattr(native.Path, "home", lambda: empty)
def write_key(path: Path, password: bytes | None = None) -> Path:
enc = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
key = ed25519.Ed25519PrivateKey.generate()
path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc))
path.with_name(path.name + ".pub").write_bytes(key.public_key().public_bytes(
serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH))
return path
# ------------------------------------------------------------------------------------------- key files
def test_key_file_validation(tmp_path):
good = write_key(tmp_path / "id_ed25519")
assert isinstance(C.load_key_file(good), paramiko.PKey)
with pytest.raises(C.KeyFileError, match="passphrase"):
C.load_key_file(write_key(tmp_path / "locked", b"secret"))
with pytest.raises(C.KeyFileError, match="public half"):
C.load_key_file(tmp_path / "id_ed25519.pub")
with pytest.raises(C.KeyFileError, match="not found"):
C.load_key_file(tmp_path / "missing")
(tmp_path / "notes.txt").write_text("hello")
with pytest.raises(C.KeyFileError, match="isn't a private SSH key"):
C.load_key_file(tmp_path / "notes.txt")
def test_extra_keys_come_from_settings_and_skip_unusable_ones(tmp_path):
good = write_key(tmp_path / "mine")
locked = write_key(tmp_path / "locked", b"secret")
library.set_setting(C.EXTRA_KEYS_SETTING, [str(good), str(good), str(locked), str(tmp_path / "gone"), "", 3])
assert C.extra_key_paths() == [good, locked, tmp_path / "gone"]
assert [path for path, _ in C.extra_keys()] == [good]
library.set_setting(C.EXTRA_KEYS_SETTING, str(good)) # a single path, e.g. edited by hand
assert C.extra_key_paths() == [good]
class _Client:
"""paramiko.SSHClient stand-in: accepts the key `accepted` (or the agent when accepted == "agent")."""
accepted = None
tried: list = []
def load_host_keys(self, path):
pass
def set_missing_host_key_policy(self, policy):
pass
def close(self):
pass
def save_host_keys(self, path):
pass
def get_transport(self):
return Mock()
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
kind = "password" if password else "agent" if allow_agent else pkey
_Client.tried.append(kind)
if kind != _Client.accepted:
raise paramiko.AuthenticationException("Authentication failed.")
@pytest.mark.parametrize("accepted,auth", [("app", "app_key"), ("user", "user_key"), ("agent", "agent")])
def test_connection_records_the_login_that_worked(monkeypatch, accepted, auth):
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
monkeypatch.setattr(C, "app_keys", lambda: ["app"])
monkeypatch.setattr(C, "extra_keys", lambda: [(Path("/k"), "user")])
monkeypatch.setattr(C.Frame, "run", lambda self, cmd: (0, "/home/steamos", ""))
_Client.accepted, _Client.tried = accepted, []
frame = C.Frame(C.FrameTarget("10.0.0.2")).connect()
assert frame.auth == auth
assert _Client.tried == ["app", "user", "agent"][:["app", "user", "agent"].index(accepted) + 1]
# ------------------------------------------------------------------------------------------- OpenSSH arguments
def frame_with_key():
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
return C.Frame(C.FrameTarget("paired.example"), client=client)
def test_credentials_offer_own_and_settings_keys_strict_or_relaxed(tmp_path, monkeypatch):
own = tmp_path / "ssh"
own.mkdir()
write_key(own / "id_ed25519")
mine = write_key(tmp_path / "id_ed25519") # same file name as FramePort's: copies get their own names
monkeypatch.setattr(native, "ssh_dir", lambda: own)
library.set_setting(C.EXTRA_KEYS_SETTING, [str(mine)])
with native.credentials(frame_with_key()) as args:
copies = [Path(args[i + 1]) for i, a in enumerate(args) if a == "-i"]
assert [c.read_bytes() for c in copies] == [(own / "id_ed25519").read_bytes(), mine.read_bytes()]
assert "IdentitiesOnly=yes" in args and "BatchMode=yes" in args and "-v" in args
assert all(oct(c.stat().st_mode & 0o777) == "0o600" for c in copies)
assert not any(c.exists() for c in copies)
with native.credentials(frame_with_key(), relaxed=True) as args:
assert "IdentitiesOnly=yes" not in args and args.count("-i") == 2
assert "StrictHostKeyChecking=yes" in args # host key still pinned
def test_relaxed_credentials_work_without_any_key_file(tmp_path, monkeypatch):
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
with pytest.raises(OSError, match="no FramePort key"):
with native.credentials(frame_with_key()):
pass
with native.credentials(frame_with_key(), relaxed=True) as args:
assert "-i" not in args
def test_windows_sid_survives_a_non_ascii_user_name(monkeypatch):
out = '"desktop-1\\josé müller","S-1-5-21-1004336348-1177238915-682003330-1001"\r\n'.encode("cp850")
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
assert native.current_sid() == "S-1-5-21-1004336348-1177238915-682003330-1001"
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: b"nothing")
with pytest.raises(OSError):
native.current_sid()
# ------------------------------------------------------------------------------------------- failures and modes
def test_failure_keeps_openssh_messages_and_key_lines():
exc = native.failure("OpenSSH SFTP", 255, REFUSED)
assert isinstance(exc, native.OpenSSHAuthFailed)
text = str(exc)
assert "UNPROTECTED PRIVATE KEY FILE" in text and "Offering public key" in text
assert "identity file" in text and "Permission denied (publickey,password)" in text
assert "kex: algorithm" not in text and "Reading configuration" not in text
other = native.failure("OpenSSH SFTP", 1, b"stat x: No such file or directory\n")
assert type(other) is OSError and "No such file" in str(other)
assert type(native.failure("OpenSSH tar", 255, b"ssh: connect to host x port 22: Connection timed out")) is OSError
@pytest.mark.parametrize("auth,known,modes", [("app_key", None, [False, True]), ("user_key", None, [False, True]),
("password", None, [False, True]), ("agent", None, [True]),
("app_key", True, [True]), ("agent", False, [False])])
def test_login_modes(auth, known, modes):
assert native.login_modes(SimpleNamespace(auth=auth, openssh_relaxed=known)) == modes
assert native.login_modes(SimpleNamespace()) == [False, True] # stand-ins without the fields
def sftp_setup(tmp_path, monkeypatch, refuse_strict=True, refuse_relaxed=False, auth="app_key"):
"""Two .part-less files; strict/relaxed logins refused as asked; returns the frame and the Popen calls."""
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
for path in paths:
path.write_bytes(b"0123456789")
items = [(path, path.name, 10) for path in paths]
files = {}
monitor = Mock()
monitor.__enter__ = Mock(return_value=monitor)
monitor.__exit__ = Mock(return_value=False)
def stat(path):
if path not in files:
raise FileNotFoundError(path)
return SimpleNamespace(st_size=files[path])
monitor.stat.side_effect = stat
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor), auth=auth,
openssh_relaxed=None, target=C.FrameTarget("paired"), run=Mock(return_value=(0, "", "")))
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
mode = {}
@contextlib.contextmanager
def creds(frame, port_flag="-P", relaxed=False):
mode["relaxed"] = relaxed
yield ["relaxed" if relaxed else "strict"]
monkeypatch.setattr(native, "credentials", creds)
calls = []
def popen(args, **kwargs):
relaxed = mode["relaxed"]
calls.append(relaxed)
refused = refuse_relaxed if relaxed else refuse_strict
if refused:
kwargs["stderr"].write(REFUSED)
code = 255
else:
for _, rel, size in items:
files["/incoming/" + rel + ".part"] = size
code = 0
process = Mock(returncode=code)
process.poll.return_value = code
return process
monkeypatch.setattr(native.subprocess, "Popen", popen)
return frame, items, calls
def test_refused_strict_login_retries_with_this_pcs_keys(tmp_path, monkeypatch):
frame, items, calls = sftp_setup(tmp_path, monkeypatch)
logs = []
reporter = Reporter()
reporter.log = lambda message, **k: logs.append(message)
assert native.upload(frame, items, "/incoming", reporter, 20, 0, 2) == 20
assert calls == [False, False, True, True] # both strict workers are refused, then both run relaxed
assert frame.openssh_relaxed is True
assert any("trying this PC's own SSH keys" in m and "UNPROTECTED" in m for m in logs)
assert native.login_modes(frame) == [True] # the next upload starts in the mode that worked
def test_agent_login_goes_straight_to_relaxed(tmp_path, monkeypatch):
frame, items, calls = sftp_setup(tmp_path, monkeypatch, auth="agent")
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1) == 20
assert calls == [True]
def test_refused_in_both_modes_raises_the_auth_failure(tmp_path, monkeypatch):
frame, items, calls = sftp_setup(tmp_path, monkeypatch, refuse_relaxed=True)
with pytest.raises(native.OpenSSHAuthFailed, match="Permission denied"):
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
assert calls == [False, True] and frame.openssh_relaxed is None
assert not frame.run.called # nothing promoted
def test_tar_retries_a_refused_login(tmp_path, monkeypatch):
path = tmp_path / "small.bin"
path.write_bytes(b"abc")
frame = SimpleNamespace(target=C.FrameTarget("paired"), auth="app_key", openssh_relaxed=None)
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
mode = {}
@contextlib.contextmanager
def creds(frame, port_flag="-P", relaxed=False):
mode["relaxed"] = relaxed
yield []
monkeypatch.setattr(native, "credentials", creds)
class Pipe:
def write(self, data):
if not mode["relaxed"]:
raise BrokenPipeError(32, "Broken pipe") # ssh already exited
return len(data)
def flush(self):
pass
def close(self):
pass
calls = []
def popen(args, **kwargs):
calls.append(mode["relaxed"])
code = 0 if mode["relaxed"] else 255
if code:
kwargs["stderr"].write(REFUSED)
process = Mock(stdin=Pipe(), returncode=code)
process.poll.return_value = code
return process
monkeypatch.setattr(native.subprocess, "Popen", popen)
native.put_tar(frame, [(path, "small.bin")], "/incoming", Reporter(), Mock())
assert calls == [False, True] and frame.openssh_relaxed is True
def test_azure_ad_sid(monkeypatch):
out = b'"azuread\\jmueller","S-1-12-1-1234567890-1234567890-1234567890-1234567890"\r\n'
monkeypatch.setattr(native.subprocess, "check_output", lambda *a, **k: out)
assert native.current_sid() == "S-1-12-1-1234567890-1234567890-1234567890-1234567890"
def test_relaxed_mode_names_this_pcs_default_keys(tmp_path, monkeypatch):
"""OpenSSH skips its default keys once any -i is given, so relaxed mode names the ones Paramiko tries."""
home = tmp_path / "home"
(home / ".ssh").mkdir(parents=True)
rsa = home / ".ssh" / "id_rsa"
from cryptography.hazmat.primitives.asymmetric import rsa as rsa_keys
rsa.write_bytes(rsa_keys.generate_private_key(65537, 2048).private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()))
write_key(home / ".ssh" / "id_ecdsa", b"secret") # passphrase: only usable through the agent
(home / ".ssh" / "id_ed25519").write_text("garbage")
monkeypatch.setattr(native.Path, "home", lambda: home)
own = tmp_path / "own"
own.mkdir()
write_key(own / "id_ed25519")
monkeypatch.setattr(native, "ssh_dir", lambda: own)
assert native.default_key_files() == [rsa]
assert native.key_files() == [own / "id_ed25519"]
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
library.set_setting(C.EXTRA_KEYS_SETTING, [str(rsa)]) # a default key also named in Settings: offered once
assert native.key_files(relaxed=True) == [own / "id_ed25519", rsa]
def test_add_remove_and_status_of_settings_keys(tmp_path):
good = write_key(tmp_path / "frame key")
with pytest.raises(C.KeyFileError, match="public half"):
C.add_extra_key(tmp_path / "frame key.pub")
with pytest.raises(C.KeyFileError, match="passphrase"):
C.add_extra_key(write_key(tmp_path / "locked", b"pw"))
assert C.add_extra_key(good) == good.resolve()
C.add_extra_key(good) # once only
assert C.extra_key_paths() == [good.resolve()]
good.unlink()
[(path, problem)] = C.extra_key_status()
assert path == good.resolve() and "not found" in problem
assert C.remove_extra_key(good) is True and C.remove_extra_key(good) is False
assert C.extra_key_paths() == []
def test_cli_ssh_keys(tmp_path):
from typer.testing import CliRunner
from frameport.cli import app
good = write_key(tmp_path / "mine")
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(good)])
assert run.exit_code == 0 and f"added {good.resolve()}" in run.output
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--add", str(tmp_path / "nope")])
assert run.exit_code == 1 and str(good.resolve()) in run.output
run = CliRunner().invoke(app, ["frame", "ssh-keys", "--remove", str(good.resolve())])
assert run.exit_code == 0 and "no extra keys" in run.output
+29
View File
@@ -510,3 +510,32 @@ def test_obb_and_cube_advice_describes_limits():
advice = next(f for f in cube.findings if f.id == "cube-swapchain-refused")
assert "GLES" in advice.diagnosis
assert "For a Vulkan game, the current build cannot stand in" in advice.diagnosis
# Deadly Delivery 1.0.30 (GitHub #322): renders, then its lobby's Meta entitlement check fails four times and it quits
LOG_ENTITLEMENT = """
10-15 12:45:23.964 212 240 I ActivityManager: Start proc 1214:com.FlatHeadStudio.DeadlyDelivery/u0a14 for \
pre-top-activity {com.FlatHeadStudio.DeadlyDelivery/com.unity3d.player.UnityPlayerActivity}
10-15 12:45:38.349 1214 1238 E Unity : [LobbyManager]: Entitlement check failed: .
10-15 12:45:38.349 1214 1238 E Unity : FlatHeads.<>c__DisplayClass105_0:<CheckEntitlementAndConfigureAsync>b__0(Message)
10-15 12:45:38.352 1214 1238 I Unity : [LobbyManager]: Retrying oculus authentication in 2 seconds.
10-15 12:45:43.372 1214 1286 I FrameBridge: pacing: 71.6 fps, displayTime vs predicted: avg 13.97 ms, max 83.33 ms
10-15 12:45:46.349 1214 1238 I Unity : stopping xr plugin
10-15 12:45:47.424 1214 1238 I Unity : [Photon] Disconnected from Photon (None)
10-15 12:45:48.857 1214 1214 I Process : Sending signal. PID: 1214 SIG: 9
"""
def test_meta_entitlement_quest():
r = triage(LOG_ENTITLEMENT, "EXITED", "com.FlatHeadStudio.DeadlyDelivery")
f = next(f for f in r.findings if f.id == "meta-entitlement")
assert f.severity == "error" and f.suggest == []
assert "Entitlement check failed" in f.evidence
assert "oculus-entitlement" not in {x.id for x in r.findings} # the Rift signature stays PC VR only
assert r.verdict == "fail"
assert "meta-entitlement" in {x.id for x in triage("E Unity: ovrPlatformInitialize_NotEntitled", "EXITED").findings}
# routine Platform SDK lines of games that work don't match; a game that logs it and keeps running still passes
for ok in ("I Unity : CheckEntitlementAndConfigureAsync", "I Unity : Entitlement check passed",
"I Unity : ovr_Entitlement_GetIsViewerEntitled", "I Unity : User is entitled"):
assert "meta-entitlement" not in {x.id for x in triage(ok + "\n" + LOG_OK, "RUNNING").findings}
assert triage("E Unity : Entitlement check failed\n" + LOG_OK, "RUNNING").verdict == "pass"