Compare commits

...
13 Commits
Author SHA1 Message Date
spoopyghosty0 3e8a480ea6 Android version / web wrapper: warnings only, never unsupported from the manifest alone (owner's call): the recipe notes, game page (warning callout) and CLI say what the manifest asks for; a launch test's triage (android-too-new, web-wrapper) decides 2026-10-07 23:08:36 -04:00
spoopyghosty0 ea2cd893b4 Linux apps in Desktop Mode's menu: per-app switch on the game page, docs
The game page's "What FramePort will do" for a Linux app gets a Desktop Mode switch ("In the menu and on the
desktop", default on): kept in the library entry (desktop_entry; patches don't apply to Linux apps), passed to
finalize_linux at install and applied at once on the Frame through the agent's desktop_entry command when the app is
installed. The agent side (menu + desktop .desktop files, launcher with FRAMEPORT_DESKTOP=1, refresh for existing
installs, removal on uninstall/purge) came with agent v63. Help text desktop_entry, INSTALL/FRAME_RUNTIME/PLAYBOOK,
CLAUDE.md. Game Mode's Steam Input problem from the report is separate.

Closes #84
2026-10-07 22:46:38 -04:00
spoopyghosty0 a90a8c27dc Install to and move games to a microSD card: Frame page Storage section, game menu Move to…
The Steam Frame page lists the Frame's drives (filesystem, free space, games on each; exFAT/NTFS cards say why they
can't hold games) and sets where new games go (library setting install.drive, default internal storage). Game menu
→ "Move to…" (Frame-installed games, agent v63) picks a drive in a dialog and queues a Frame job that follows the
agent's copy with progress (cancellable until the copy starts). The game page and the Frame page's installed list
show "on SD Card" / "SD Card not inserted"; a game whose card is out counts as installed, not "Not installed".
Help texts install_drive / move_game, ui_smoke --fake-frame drives + the Move dialog, docs (FRAME_RUNTIME "Where
FramePort keeps games", INSTALL, PLAYBOOK row, CLAUDE.md).

Closes #90
2026-10-07 22:46:37 -04:00
spoopyghosty0 9d0d72c40a Agent v63: games on another drive (microSD) and Desktop Mode entries for Linux apps
#90: `drives` lists internal storage, the drives under /run/media (from /proc/mounts) and the drives of Steam
library folders, with free space and whether they can hold games (vfat/exfat/ntfs can't: Lepton data and Proton
prefixes need Unix permissions and symlinks; read-only mounts neither). Games on a drive live in
<mount>/FramePort/<pkg>; the anchor stays internal, so the Steam shortcut never changes. prepare/prepare_pcvr/
prepare_linux take dest (free space from it); a drive that isn't mounted is an error, never a silent fallback to
internal storage, and an installed game keeps its folder. `move` (refused while the game runs, free-space check)
renames on the same drive, else copies with cp -a (in podman's user namespace when podman is there: container-owned
files), checks file count + bytes, retargets symlinks into the old folder (LibOVRRT redirect), points launch.sh at
the new folder (Quest: the app_dir line; Linux/PC VR: written again from the install record, which now keeps env and
game args) and rewrites deployment.json before removing the old copy; detached via systemd-run with a status file
(move_status). list_installed adds drive + drive_missing; launch refuses a game whose card is out; cleanup may remove
leftovers in a drive's FramePort folder (never an installed game); purge/uninstall remove an empty FramePort folder.
PC: install/drives.py (list, setting install.drive, move with progress, picks a running move up again), targets
pass dest for Quest/PC VR/Linux installs, CLI `frameport frame drives`, `frameport frame move <pkg> --to`,
`frameport install --dest`.

#84: finalize_linux writes ~/.local/share/applications/frameport-<slug>.desktop (+ an executable copy on ~/Desktop
when it exists; Exec quoted per the Desktop Entry spec, X-FramePort-Package marks ours) unless desktop_entry is off;
`desktop_entry` command switches it per app; ensure_host_fixes adds entries for existing Linux installs and drops
stale ones; uninstall/purge remove them. The Linux launcher with FRAMEPORT_DESKTOP=1 skips the Steam-parent
watchdog and Steam's display (upgrade_launchers updates existing launchers).
2026-10-07 22:46:36 -04:00
spoopyghosty0 56d2ac117f Missing OBB: analysis extra.expects_obb (Unreal's GameActivity.bHasOBBFiles, UE4 and UE5 names); a game that expects one but has no data folder gets an error callout on the game page, a question before install (default: skip) and a warning in frameport scan/install, and a launch test without frames gets the missing-obb finding from the library (GitHub #85: TRIANGLE STRATEGY was installed without its OBB and hung silently after OVRPlugin's JNI_OnLoad). In the dumps every Unreal game with the flag has its data folder 2026-10-07 22:42:41 -04:00
spoopyghosty0 cb8202d891 Scan: OBBs in more backup layouts (quest_dump.find_data_dir): a <package> folder with .obb files up to 3 levels below the APK's folder (obb/<package>/, OBB/, Android/obb/<package>/) or 2 below its parent, skipping neighbouring folders with APKs of their own (other games/versions); <game>/apk(s)/ + <game>/obb/<package>/ (SideQuest-style backups) is one game named after <game>. The data folder is always the one holding the .obb files (obb/<package>/ used to be sent as obb/ and land one level too deep). Both dump folders scan exactly as before 2026-10-07 22:42:40 -04:00
spoopyghosty0 029b0674d1 Web wrappers: a Trusted Web Activity (manifest meta-data android.support.customtabs.trusted.DEFAULT_URL, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) is analysis extra.web_wrapper with its URL; suggested status unsupported, the game page/CLI say to open the site in a browser (GitHub #86 Mahjong Table VR opened Meta's com.oculus.browser, which the Frame doesn't have). Triage web-wrapper 2026-10-07 22:42:39 -04:00
spoopyghosty0 c852473a43 Android version gate: analysis records the APK's minSdk (extra.min_sdk, read from the original: OVRPort lowers it to 29); above 32 the suggested status is unsupported and the game page/CLI say which Android it needs (GitHub #71/#72: minSdk 34 apps crash at start on android.window / VarHandle fences, Lepton runs Android 11). minSdk 32 stays allowed: 16 working catalog games declare it. Triage android-too-new 2026-10-07 22:42:38 -04:00
spoopyghosty0 1043eb1d1b Catalog: Richie's Plank Experience (works)
Closes #95
2026-10-07 22:13:33 -04:00
spoopyghosty0 0f444da595 Catalog: RUINSMAGUS (works)
Closes #89
2026-10-07 22:13:27 -04:00
spoopyghosty0 fda644d036 Catalog: GORN2 (works)
Closes #88
2026-10-07 22:13:19 -04:00
spoopyghosty0 fa0fbbd29a Catalog: Time Crisis VR (Experimental) (works)
Closes #80
2026-10-07 22:13:14 -04:00
spoopyghosty0 3446999109 Upstream fixes replace our workarounds per build (patches/upstream.py)
Each build probes OVRPort's output for registered upstream fixes and leaves out the workarounds they make
unnecessary (recipes unchanged: builds with an older runtime keep them). Logged ("not needed: ..."), recorded in
build.superseded, left out of settings.conf at install, shown on the game page and in `frameport show`;
FRAMEPORT_KEEP_WORKAROUNDS=1 turns it off. Registered: ovrport.haptic_envelope (-> adapter.haptic_fix) and
ovrport.microphone_stream (-> frame.ovr_microphone), both fixed in OVRPort runtime 3.4.3-aa54c3f (ovrport/app#73;
haptics verified in the headset with Lucky's Tale). Probes checked against both real runtimes; fixtures in
tests/fixtures/src/fakeruntime.S.
2026-10-07 21:02:12 -04:00
47 changed files with 2994 additions and 86 deletions

No files matched your search

+17
View File
@@ -71,6 +71,14 @@ 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).
- 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;
installed games keep their base), `move` (detached systemd-run + `move_status`; cp -a under podman unshare,
count+bytes check, symlink retarget, launch.sh: Quest app_dir line / Linux+PC VR rewritten from the record's
`launcher` field, else text swap), list_installed `drive`/`drive_missing` (install_state keeps such games
"installed"). PC: `install/drives.py`, library setting `install.drive` (Frame page → Storage), game menu
"Move to…" (job kind tool-frame), CLI `frame drives`/`frame move`/`install --dest`. Untested on the device.
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
@@ -131,6 +139,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
OpenXR, source) instead of recipe/patches, `C.missing_libraries` callout (Frame deployment, else last install);
no Analyze/Rebuild/recipe/share/Game settings actions, Frame only; platform "Linux" badge + library filter; Steam
tags "Linux app on Frame"/"Linux"; `_follow_catalog` skips them; local files of a lone AppImage = the file only.
Desktop Mode entries (GitHub #84, agent v63): finalize_linux writes `frameport-<slug>.desktop` to
~/.local/share/applications (+ ~/Desktop if it exists; `X-FramePort-Package` marks ours), launch.sh with
`FRAMEPORT_DESKTOP=1` skips the Steam-parent watchdog and Steam's display; ensure_host_fixes refreshes entries
(older installs, stale ones removed), uninstall/purge remove them. Per app: library entry field `desktop_entry`
(default on; patches don't apply to Linux apps) → game page switch → agent `desktop_entry`. Untested on device.
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
- `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
@@ -146,6 +159,10 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
- `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(...)`.
`upstream.py`: upstream fixes that replace a workaround per build (a probe finds the fix in OVRPort's output →
the build leaves the workaround out, `build.superseded`; recipes unchanged). Registered: `ovrport.haptic_envelope`
(→ `adapter.haptic_fix`, `frame/haptic_envelope.py`) and `ovrport.microphone_stream` (→ `frame.ovr_microphone`),
both fixed in OVRPort runtime 3.4.3-aa54c3f (ovrport/app#73; haptics owner-verified with Lucky's Tale 2026-10-07).
- `analysis/` — APK/ELF inspection (`detect.py`), `elf.py` (pyelftools reads; own DT_NEEDED writer), `stubgen.py`
(generates the ovr_* stub .so without a compiler).
- `apk/` — `axml.py` (binary manifest editor), `workspace.py` (staged zip edits), `sign.py` (apksigner; it aligns too).
+719 -34
View File
@@ -11,7 +11,8 @@ Streaming: python3 frameport_agent.py _keyboard (a virtual keyboard: JSON line
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)
<dest>/<pkg>/lepton-app/{game.apk,obb/} game files (dest defaults to ~/Applications/quest-frame)
<dest>/<pkg>/lepton-app/{game.apk,obb/} game files (dest defaults to ~/Applications/quest-frame; another
drive: <mount>/FramePort, see `drives`; `move` moves a game)
<dest>/<pkg>/lepton-data/ container data + saves (kept across reinstalls)
<dest>/<pkg>/lepton-shaders/, settings.conf, launch.log
@@ -36,7 +37,7 @@ import time
import zlib
from types import SimpleNamespace
AGENT_VERSION = 62
AGENT_VERSION = 63
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -679,6 +680,12 @@ def ensure_host_fixes():
upgraded = []
if upgraded:
changed.append(f"launchers: exit watchdog, dashboard, play log ({len(upgraded)})")
try:
entries = refresh_desktop_entries()
except Exception: # noqa: BLE001
entries = []
if entries:
changed.append(f"Desktop Mode entries ({len(entries)})")
return changed
@@ -1215,6 +1222,176 @@ def cmd_shortcut_status(args):
return {"state": "none"}
# ------------------------------------------------------------------------------------------ drives (GitHub #90)
# Games can live on another drive (a microSD card): their files go to <mount>/FramePort/<pkg>; the anchor (launch.sh,
# deployment.json, artwork) always stays on internal storage, so the Steam shortcut never changes. SteamOS mounts
# removable drives under /run/media/<user>/<label or uuid>.
PROC_MOUNTS = "/proc/mounts"
MEDIA_ROOT = "/run/media"
DRIVE_DIR = "FramePort"
# Lepton's container data and Proton prefixes need Unix permissions, owners and symlinks: these can't hold them
UNUSABLE_FS = ("vfat", "msdos", "exfat", "ntfs", "ntfs3", "fuseblk")
MOVE_STATUS = os.path.join(HOME, ".cache/frameport-move.json")
MOVE_HEADROOM = 512 << 20
# what stays in the anchor when a game's files live there too (base == anchor): never moved
ANCHOR_ITEMS = ("launch.sh", "launch.sh.tmp", "deployment.json", "deployment.json.tmp", "artwork", "plays.log")
def _unescape_mount(field):
"""/proc/mounts writes spaces, tabs, newlines and backslashes in paths as octal escapes (\\040 ...)."""
return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), field)
def mounts(path=None):
"""[(device, mount point, fstype, options)] from /proc/mounts."""
out = []
try:
with open(path or PROC_MOUNTS, encoding="utf-8", errors="replace") as f:
for line in f:
p = line.split()
if len(p) >= 4:
out.append((_unescape_mount(p[0]), _unescape_mount(p[1]), p[2], p[3].split(",")))
except OSError:
pass
return out
def _inside(path, root):
path, root = os.path.normpath(path), os.path.normpath(root)
return path == root or path.startswith(root.rstrip("/") + "/")
def mount_of(path, table=None):
"""The mount entry holding `path` (longest mount point prefix), or None."""
best = None
for m in mounts() if table is None else table:
if _inside(path, m[1]) and (best is None or len(m[1]) > len(best[1])):
best = m
return best
def _space(path):
try:
st = os.statvfs(path)
except OSError:
return None, None
return st.f_bavail * st.f_frsize, st.f_blocks * st.f_frsize
def _drive(path, install_dir, m, internal=False, steam_library=False):
fstype = m[2] if m else ""
free, total = _space(path)
reason = ""
if not internal:
if fstype in UNUSABLE_FS:
reason = (f"{fstype} can't hold game data (no Unix permissions or symlinks); format the drive in SteamOS "
f"to install games on it")
elif m and "ro" in m[3]:
reason = "mounted read-only"
elif free is None:
reason = "can't be read"
return {"id": "internal" if internal else path, "path": path, "install_dir": install_dir,
"label": "Internal storage" if internal else (os.path.basename(path.rstrip("/")) or path),
"fstype": fstype, "device": m[0] if m else "", "internal": internal,
"removable": not internal and _inside(path, MEDIA_ROOT), "steam_library": steam_library,
"free_bytes": free, "total_bytes": total, "usable": not reason, "reason": reason}
def list_drives(table=None):
"""Internal storage, every drive mounted under /run/media and the drives of Steam library folders."""
table = mounts() if table is None else table
home_m = mount_of(HOME, table)
libs = [os.path.dirname(lib) for lib in steam_libraries()]
drives = [_drive(HOME, ANCHORS, home_m, internal=True, steam_library=any(_inside(lib, HOME) for lib in libs))]
seen = {home_m[1] if home_m else HOME}
for m in sorted(table, key=lambda m: m[1]):
if _inside(m[1], MEDIA_ROOT) and m[1] != os.path.normpath(MEDIA_ROOT) and m[1] not in seen \
and os.path.isdir(m[1]):
seen.add(m[1])
drives.append(_drive(m[1], os.path.join(m[1], DRIVE_DIR), m,
steam_library=any(_inside(lib, m[1]) for lib in libs)))
for lib in libs: # a Steam library on a drive mounted elsewhere
m = mount_of(lib, table)
if m and m[1] not in seen and m[1] != "/" and not _inside(HOME, m[1]):
seen.add(m[1])
drives.append(_drive(m[1], os.path.join(m[1], DRIVE_DIR), m, steam_library=True))
return drives
def cmd_drives(args):
"""Where games can be installed: internal storage and other drives (microSD), with free space and whether they can
hold games (`usable`, else `reason`); `games` = how many installed games each holds."""
drives = list_drives()
games = cmd_list_installed({})["games"]
for d in drives:
d["games"] = sum(1 for g in games if drive_path(g["base"], drives) == d["path"])
return {"drives": drives}
def drive_path(base, drives):
"""The path of the listed drive holding `base` (internal storage: HOME), or None."""
best = None
for d in drives:
if not d["internal"] and _inside(base, d["path"]) and (best is None or len(d["path"]) > len(best)):
best = d["path"]
return best or (HOME if _inside(base, HOME) else None)
def drive_missing(base, table=None):
"""A game's files are on a drive that isn't there now (the microSD card was taken out)."""
if _inside(base, HOME):
return False
m = mount_of(base, table)
return m is None or m[1] == "/" or not os.path.isdir(base)
def resolve_dest(dest, table=None):
"""The install dir for new games: internal storage (None, "", "internal", ~/Applications/quest-frame) or a drive's
FramePort folder (its mount point is accepted too). A drive that isn't mounted is an error, never a silent
fallback to internal storage."""
if not dest or dest == "internal":
return ANCHORS
dest = os.path.normpath(os.path.expanduser(dest))
if dest == ANCHORS:
return ANCHORS
if not os.path.isabs(dest):
raise AgentError(f"bad install location {dest!r}")
for d in list_drives(table):
if d["internal"] or not _inside(dest, d["path"]):
continue
if not d["usable"]:
raise AgentError(f"{d['label']}: {d['reason']}")
if dest == d["path"]:
dest = d["install_dir"]
try:
os.makedirs(dest, exist_ok=True)
except OSError as exc:
raise AgentError(f"can't create {dest} on {d['label']}: {exc.strerror or exc}") from exc
return dest
raise AgentError(f"{dest}: that drive isn't inserted (or not mounted)")
def check_base_present(base, title):
if drive_missing(base):
raise AgentError(f"{title}'s files are on a drive that isn't inserted ({base})")
def new_base(args, pkg, dep):
"""(base, free bytes there) for prepare*: an installed game keeps its base; a new one goes to args["dest"]."""
if dep:
check_base_present(dep["base"], dep.get("title") or pkg)
base = dep["base"]
probe = base
while not os.path.exists(probe) and probe != os.path.dirname(probe):
probe = os.path.dirname(probe)
else:
dest = resolve_dest(args.get("dest"))
base = os.path.join(dest, pkg)
probe = dest if os.path.exists(dest) else HOME
st = os.statvfs(probe)
return base, st.f_bavail * st.f_frsize
# ------------------------------------------------------------------------------------------ install
def deployment(pkg):
path = os.path.join(ANCHORS, pkg, "deployment.json")
@@ -1231,6 +1408,7 @@ def deployment(pkg):
def cmd_list_installed(args):
games = []
table = mounts()
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
try:
dep = json.load(open(dep_path))
@@ -1244,6 +1422,15 @@ def cmd_list_installed(args):
dep.setdefault("title", dep["package"])
dep.setdefault("kind", "quest")
dep["anchor"] = os.path.dirname(dep_path) # its artwork/ feeds the Steam grid (shortcuts)
# GitHub #90: games on another drive (microSD); drive_missing = that drive isn't inserted now
dep["drive_missing"] = drive_missing(dep["base"], table)
if _inside(dep["base"], HOME):
dep["drive"] = {"internal": True, "path": HOME, "label": "Internal storage"}
else:
m = mount_of(dep["base"], table)
mp = m[1] if m and m[1] != "/" and not dep["drive_missing"] else \
os.path.dirname(os.path.dirname(dep["base"].rstrip("/"))) # <mount>/FramePort/<pkg>
dep["drive"] = {"internal": False, "path": mp, "label": os.path.basename(mp.rstrip("/")) or mp}
if dep["kind"] == "linux":
exe = os.path.join(dep["base"], "app", dep.get("exe", ""))
dep["apk_present"] = os.path.isfile(exe)
@@ -1448,6 +1635,8 @@ def cmd_launch(args):
dep = deployment(pkg)
if not dep or not dep.get("appid"):
raise AgentError(f"{pkg} is not installed")
if dep.get("base"):
check_base_present(dep["base"], dep.get("title") or pkg)
if run(["pgrep", "-x", "steam"]).returncode != 0:
raise AgentError("Steam isn't running on the Frame")
devkit = devkit_gameid(pkg)
@@ -1902,10 +2091,9 @@ def cmd_prepare(args):
pkg = check_pkg(args["package"])
title = args["title"]
ensure_host_fixes()
dest = os.path.expanduser(args.get("dest") or ANCHORS)
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
base = dep["base"] if dep else os.path.join(dest, pkg)
base, free = new_base(args, pkg, dep)
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', title)
if container_running(appid):
raise AgentError(f"{title} is running on the Frame. Close it first.")
@@ -1923,12 +2111,10 @@ def cmd_prepare(args):
want_sha = args.get("apk_sha256")
same_apk = bool(want_sha and os.path.exists(apk) and os.path.getsize(apk) == args.get("apk_size")
and sha256_file(apk) == want_sha)
st = os.statvfs(base if os.path.exists(base)
else os.path.dirname(base) if os.path.exists(os.path.dirname(base)) else HOME)
lepton, _ = lepton_path()
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "same_apk": same_apk, "existing_obb": existing,
"free_bytes": st.f_bavail * st.f_frsize, "lepton": lepton}
"free_bytes": free, "lepton": lepton}
LAUNCH_SH = (r"""#!/usr/bin/env bash
@@ -2024,6 +2210,8 @@ def upgrade_launchers():
except OSError:
continue
new = text
if "a Linux app. Generated by FramePort" in new and "FRAMEPORT_DESKTOP" not in new:
new = upgrade_linux_launcher(new)
if OLD_WATCHDOG in new and "parent=$PPID" not in new:
new = new.replace(OLD_WATCHDOG, WATCHDOG, 1)
if "_dashboard_worker" not in new and 'child=$!\nwait "$child"' in new:
@@ -2053,6 +2241,18 @@ def upgrade_launchers():
return changed
def upgrade_linux_launcher(text):
"""A Linux app's launcher from before agent v63, made fit for Desktop Mode's menu entry (GitHub #84): no Steam
parent watchdog and no display taken from Steam when FRAMEPORT_DESKTOP is set."""
old_if = 'if [[ -z "${DISPLAY:-}" ]]; then'
if old_if in text:
text = text.replace(old_if, 'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then', 1)
if "\nparent=$PPID\n" in text:
text = text.replace("\nparent=$PPID\n",
'\nparent=$PPID\n[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1\n', 1)
return text
def write_launcher(anchor, base, pkg, title, appid, lepton, env):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
@@ -2493,10 +2693,9 @@ def cmd_prepare_pcvr(args):
"""Where to upload a Windows game + Revive, and what the Frame already has (unchanged files aren't re-sent)."""
pkg = check_pkg(args["package"])
title = args["title"]
dest = os.path.expanduser(args.get("dest") or ANCHORS)
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
base = dep["base"] if dep else os.path.join(dest, pkg)
base, free = new_base(args, pkg, dep)
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', title)
if dep and pcvr_pids(base):
raise AgentError(f"{title} is running on the Frame. Close it first.")
@@ -2506,10 +2705,9 @@ def cmd_prepare_pcvr(args):
# files already in place plus files uploaded by an interrupted install (still in incoming/): not sent again
existing = {t: {**tree_manifest(os.path.join(base, t)), **tree_manifest(os.path.join(incoming, t))}
for t in PCVR_TREES}
st = os.statvfs(base if os.path.exists(base) else HOME)
status = cmd_proton_status({"tool": args.get("tool")})
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "existing": existing, "free_bytes": st.f_bavail * st.f_frsize,
"installed": bool(dep), "existing": existing, "free_bytes": free,
"proton": status.get("ready"), "proton_suggested": status.get("suggested"), "openxr": status.get("openxr")}
@@ -2731,6 +2929,7 @@ def cmd_finalize_pcvr(args):
"sha256": args.get("exe_sha256"), "revive": revive, "revive_version": args.get("revive_version"),
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "vr": vr,
"libovr_redirect": bool(args.get("libovr_redirect")),
"launcher": {"env": args.get("env") or {}, "game_args": args.get("game_args") or []},
"recipe": args.get("recipe"),
"installed_by": "frameport",
"files": {t: manifests.get(t) or {} for t in PCVR_TREES},
@@ -2749,8 +2948,9 @@ LINUX_LAUNCH_SH = r"""#!/usr/bin/env bash
set -uo pipefail
cd {run_dir_q} || {{ echo "App files missing at {run_dir_q}" >&2; exit 1; }}
export XDG_RUNTIME_DIR="${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}"
# Steam passes the display session (gamescope's X/Wayland); headless launches (launch tests) take it from Steam
if [[ -z "${{DISPLAY:-}}" ]]; then
# Steam passes the display session (gamescope's X/Wayland); headless launches (launch tests) take it from Steam.
# Started from Desktop Mode's menu (FRAMEPORT_DESKTOP=1, GitHub #84) the desktop's own session is used.
if [[ -z "${{FRAMEPORT_DESKTOP:-}}" && -z "${{DISPLAY:-}}" ]]; then
steam_pid=$(pgrep -x steam | head -n1 || true)
if [[ -n "$steam_pid" && -r "/proc/$steam_pid/environ" ]]; then
while IFS= read -r -d '' kv; do
@@ -2760,6 +2960,8 @@ if [[ -z "${{DISPLAY:-}}" ]]; then
fi
fi
{extra_env}parent=$PPID
# Desktop Mode's launcher exits right after starting this: no Steam parent to watch
[[ -n "${{FRAMEPORT_DESKTOP:-}}" ]] && parent=1
{exe_q} "$@" >{log_q} 2>&1 &
child=$!
trap 'kill -TERM $child 2>/dev/null' INT TERM
@@ -2823,7 +3025,7 @@ def cmd_prepare_linux(args):
pkg = check_pkg(args["package"])
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
base = dep["base"] if dep else anchor
base, free = new_base(args, pkg, dep)
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', args["title"])
if dep and pcvr_pids(base):
raise AgentError(f"{args['title']} is running on the Frame. Close it first.")
@@ -2831,9 +3033,8 @@ def cmd_prepare_linux(args):
os.makedirs(os.path.join(incoming, "app"), exist_ok=True)
existing = {"app": {**tree_manifest(os.path.join(base, "app")), **tree_manifest(os.path.join(incoming, "app"))}}
existing["app"] = {k: v for k, v in existing["app"].items() if not k.startswith("squashfs-root/")}
st = os.statvfs(base if os.path.exists(base) else HOME)
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
"installed": bool(dep), "existing": existing, "free_bytes": st.f_bavail * st.f_frsize}
"installed": bool(dep), "existing": existing, "free_bytes": free}
def cmd_finalize_linux(args):
@@ -2841,7 +3042,7 @@ def cmd_finalize_linux(args):
libraries, write the launcher and deployment.json."""
pkg = check_pkg(args["package"])
title = args["title"]
prep = cmd_prepare_linux({"package": pkg, "title": title})
prep = cmd_prepare_linux({"package": pkg, "title": title, "dest": args.get("dest")})
base, anchor, appid, incoming = prep["base"], prep["anchor"], prep["appid"], prep["incoming"]
exe_rel = os.path.normpath(args["exe"])
if exe_rel.startswith("..") or os.path.isabs(exe_rel):
@@ -2889,13 +3090,42 @@ def cmd_finalize_linux(args):
q = os.path.normpath(os.path.join(app, name))
if q.startswith(app + os.sep) and os.path.isfile(q):
os.chmod(q, os.stat(q).st_mode | 0o111)
command = prefix + [program]
# ldd can't read x86_64 programs here: their libraries come from FEX's x86 root (/usr/share/guestos/fex-mesa)
missing = [] if x86 else missing_libraries(app, appimage_programs(run_dir) if appimage else [exe])
os.makedirs(anchor, exist_ok=True)
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (args.get("env") or {}).items()
write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, args.get("env"))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
vr = bool(args.get("openxr"))
dep = {"package": pkg, "kind": "linux", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
"appimage": appimage, "vr": vr, "x86_64": x86, "tags": args.get("tags") or [],
"sha256": args.get("exe_sha256"),
"missing_libraries": missing, "recipe": args.get("recipe"), "installed_by": "frameport",
"launcher": {"env": args.get("env") or {}}, "desktop_entry": args.get("desktop_entry", True) is not False,
"files": {"app": want or {}}, "agent_version": AGENT_VERSION, "time": time.time()}
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
desktop = None
try:
if dep["desktop_entry"]:
desktop = write_desktop_entry(pkg)
else:
remove_desktop_entries(pkg)
except OSError:
pass
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "missing_libraries": missing,
"desktop_entry": desktop}
def write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, env):
"""launch.sh of a Linux app; prefix = FEX's command for x86_64 builds ([] for arm64)."""
run_dir, program = linux_run_target(base, exe_rel, appimage)
command = list(prefix) + [program]
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
if x86: # fex-compat-tool exits ("No compat data path?") without it; FEX keeps its config in <it>/fex-emu
if prefix: # fex-compat-tool exits ("No compat data path?") without it; FEX keeps its config in <it>/fex-emu
data = shlex.quote(os.path.join(base, "compatdata"))
extra = f"mkdir -p {data}\nexport STEAM_COMPAT_DATA_PATH={data}\n" + extra
text = LINUX_LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, run_dir_q=shlex.quote(run_dir),
@@ -2907,19 +3137,154 @@ def cmd_finalize_linux(args):
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
shutil.move(art_in, os.path.join(anchor, "artwork"))
vr = bool(args.get("openxr"))
dep = {"package": pkg, "kind": "linux", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
"appimage": appimage, "vr": vr, "x86_64": x86, "tags": args.get("tags") or [],
"sha256": args.get("exe_sha256"),
"missing_libraries": missing, "recipe": args.get("recipe"), "installed_by": "frameport",
"files": {"app": want or {}}, "agent_version": AGENT_VERSION, "time": time.time()}
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "missing_libraries": missing}
# ------------------------------------------------------------------------------------------ Desktop Mode entries
# Linux apps also get an entry in Desktop Mode's application menu and an icon on its desktop (GitHub #84: some apps
# work better with the desktop's mouse and keyboard than in Gaming Mode, where Steam Input owns the controllers).
DESKTOP_APPS = os.path.join(HOME, ".local/share/applications")
DESKTOP_DIR = os.path.join(HOME, "Desktop")
DESKTOP_KEY = "X-FramePort-Package"
GAME_TAGS = {"game", "games", "action", "adventure", "arcade", "casual", "fighting", "platformer", "puzzle", "racing",
"rhythm", "rpg", "role playing", "shooter", "simulation", "sports", "strategy", "survival", "horror"}
def desktop_value(text):
"""A Desktop Entry string value: one line, backslashes escaped."""
return re.sub(r"[\r\n\t]", " ", str(text).replace("\\", "\\\\"))
def desktop_exec_arg(arg):
"""One Exec argument (Desktop Entry spec): in double quotes with \\ " ` $ backslash-escaped and % doubled, then
escaped once more as a string value."""
quoted = '"' + re.sub(r'([\\"`$])', r"\\\1", str(arg)).replace("%", "%%") + '"'
return desktop_value(quoted)
def desktop_file_name(pkg):
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", pkg[len("linux."):] if pkg.startswith("linux.") else pkg)
return f"frameport-{slug.strip('-.') or 'app'}.desktop"
def desktop_entry_text(dep, anchor):
icon = next(iter(sorted(glob.glob(os.path.join(anchor, "artwork", "icon.*")))), "")
tags = {str(t).lower() for t in dep.get("tags") or []}
category = "Game;" if tags & GAME_TAGS else "Utility;"
lines = ["[Desktop Entry]", "Type=Application", f"Name={desktop_value(dep.get('title') or dep['package'])}",
"Comment=Installed by FramePort",
f"Exec=env FRAMEPORT_DESKTOP=1 {desktop_exec_arg(os.path.join(anchor, 'launch.sh'))}",
f"Path={desktop_value(anchor)}", "Terminal=false", f"Categories={category}"]
if icon:
lines.append(f"Icon={desktop_value(icon)}")
lines.append(f"{DESKTOP_KEY}={dep['package']}")
return "\n".join(lines) + "\n"
def _write_if_changed(path, text, mode):
try:
with open(path, encoding="utf-8") as f:
if f.read() == text and os.stat(path).st_mode & 0o777 == mode:
return False
except OSError:
pass
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path + ".tmp", "w", encoding="utf-8") as f:
f.write(text)
os.chmod(path + ".tmp", mode)
os.replace(path + ".tmp", path)
return True
def desktop_entries(pkg=None, folder=None):
"""FramePort's .desktop files (of one app, or all) in the menu and desktop folders: {path: package}."""
out = {}
for d in [folder] if folder else (DESKTOP_APPS, DESKTOP_DIR):
for path in sorted(glob.glob(os.path.join(d, "frameport-*.desktop"))):
try:
with open(path, encoding="utf-8", errors="replace") as f:
m = re.search(rf"^{DESKTOP_KEY}=(.+)$", f.read(), re.M)
except OSError:
continue
if m and (pkg is None or m[1].strip() == pkg):
out[path] = m[1].strip()
return out
def write_desktop_entry(pkg):
"""The app's menu entry, plus a copy on ~/Desktop when that folder exists (Plasma starts executable .desktop files
there without asking). Returns {"menu": path, "desktop": path or None, "changed": bool}."""
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
text = desktop_entry_text(dep, anchor)
name = desktop_file_name(pkg)
for path in desktop_entries(pkg): # the same app's entry under an older file name
if os.path.basename(path) != name:
os.remove(path)
menu = os.path.join(DESKTOP_APPS, name)
changed = _write_if_changed(menu, text, 0o755)
desk = None
if os.path.isdir(DESKTOP_DIR):
desk = os.path.join(DESKTOP_DIR, name)
changed = _write_if_changed(desk, text, 0o755) or changed
return {"menu": menu, "desktop": desk, "changed": changed}
def remove_desktop_entries(pkg=None):
removed = []
for path in desktop_entries(pkg):
try:
os.remove(path)
removed.append(path)
except OSError:
pass
return removed
def refresh_desktop_entries():
"""Entries for every installed Linux app that wants one (older installs, changed art or titles); entries of apps
that are gone or switched off are removed. Returns the packages whose entries changed."""
changed, wanted = [], set()
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
try:
with open(dep_path) as f:
dep = json.load(f)
except (OSError, ValueError):
continue
if not isinstance(dep, dict) or dep.get("kind") != "linux" or not dep.get("package") \
or dep.get("desktop_entry", True) is False:
continue
wanted.add(dep["package"])
try:
if write_desktop_entry(dep["package"])["changed"]:
changed.append(dep["package"])
except (OSError, AgentError):
pass
for path, pkg in desktop_entries().items():
if pkg not in wanted:
os.remove(path)
changed.append(pkg)
return sorted(set(changed))
def cmd_desktop_entry(args):
"""Turn a Linux app's Desktop Mode entry on or off (deployment.json keeps the choice)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep or dep.get("kind") != "linux":
raise AgentError(f"{pkg} is not an installed Linux app")
on = bool(args.get("enabled", True))
path = os.path.join(ANCHORS, pkg, "deployment.json")
with open(path) as f:
raw = json.load(f)
raw["desktop_entry"] = on
with open(path + ".tmp", "w") as f:
json.dump(raw, f, indent=2)
os.replace(path + ".tmp", path)
if on:
return {"enabled": True, **write_desktop_entry(pkg)}
return {"enabled": False, "removed": remove_desktop_entries(pkg)}
def cmd_usb_link(args):
@@ -3067,6 +3432,9 @@ def cmd_uninstall(args):
os.remove(p)
if not keep_data:
remove_tree(base)
remove_empty_drive_dir(base)
if linux:
remove_desktop_entries(pkg)
anchor = os.path.join(ANCHORS, pkg)
removed_sc = False
if args.get("remove_shortcut") and steam_users():
@@ -3086,6 +3454,310 @@ def cmd_uninstall(args):
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
# ------------------------------------------------------------------------------------------ move (GitHub #90)
def remove_empty_drive_dir(base):
"""<drive>/FramePort once its last game is gone."""
parent = os.path.dirname(os.path.normpath(base))
if os.path.basename(parent) != DRIVE_DIR or _inside(parent, HOME):
return False
try:
os.rmdir(parent)
return True
except OSError:
return False
def base_items(base, anchor):
"""The entries of a game's base that are the game's files (with base == anchor, the anchor's own files stay)."""
try:
names = sorted(os.listdir(base))
except OSError:
return []
if os.path.normpath(base) == os.path.normpath(anchor):
names = [n for n in names if n not in ANCHOR_ITEMS]
return names
def as_owner():
"""Commands that must reach files owned by Lepton containers' user ids (overlayfs work dirs, the app's own
files) run in podman's user namespace, where the steamos user owns them all."""
return ["podman", "unshare"] if shutil.which("podman") else []
def tree_stats(root, names):
"""(files, bytes) of these entries of root: regular files and symlinks count (symlinks without size)."""
paths = [os.path.join(root, n) for n in names if os.path.lexists(os.path.join(root, n))]
if not paths:
return 0, 0
p = run(as_owner() + ["find"] + paths + ["(", "-type", "f", "-o", "-type", "l", ")", "-printf", r"%y %s\n"])
files = size = 0
for line in p.stdout.splitlines():
kind, _, n = line.partition(" ")
files += 1
if kind == "f" and n.isdigit():
size += int(n)
return files, size
def replace_path(text, old, new):
"""launch.sh text with the game's old folder replaced (the plain path and Wine's Z:\\ form). Paths that need
quoting differently can't be swapped as text: those launchers are written again instead."""
if "'" in new or (shlex.quote(old) == old and shlex.quote(new) != new):
raise AgentError(f"can't rewrite the launcher for {new!r}; reinstall the game once, then move it")
for o, n in ((old, new), (windows_path(old), windows_path(new))):
text = re.sub(re.escape(o) + r"(?=[/\\'\"\s;)|&]|$)", lambda m, n=n: n, text, flags=re.M)
return text
def rebase_launcher(dep, anchor, new):
"""launch.sh pointing at the game's new folder: the Quest launcher's app_dir line, Linux and PC VR launchers written
again from the install record (their settings are kept there since agent v63), else the path swapped as text."""
pkg, old = dep["package"], dep["base"]
path = os.path.join(anchor, "launch.sh")
with open(path) as f:
text = f.read()
kind = dep.get("kind", "quest")
launcher = dep.get("launcher")
if kind == "quest":
text, n = re.subn(r"^app_dir=.*$", lambda m: "app_dir=" + shlex.quote(new), text, count=1, flags=re.M)
if not n:
raise AgentError("launch.sh has no app_dir line; reinstall the game once, then move it")
elif kind == "linux" and launcher is not None:
prefix = []
if dep.get("x86_64"):
tool = pick_tool("linux_x86", linux_x86_tools())
prefix = compat_command(tool["dir"]) if tool and tool.get("dir") else None
if prefix is not None:
write_linux_launcher(anchor, new, pkg, dep.get("title") or pkg, dep["exe"], dep.get("appimage"), prefix,
launcher.get("env"))
return
text = replace_path(text, old, new)
elif kind == "pcvr" and launcher is not None:
tool = next((t for t in proton_tools() if t["name"] == dep.get("proton")), None)
if tool and tool.get("dir"):
write_proton_launcher(anchor, new, pkg, dep.get("title") or pkg, dep["appid"], tool, dep["exe"],
dep.get("revive", True), launcher.get("env"), dep.get("xr_layer"),
launcher.get("game_args") or [], dep.get("oculus_hmd"), vr=dep.get("vr", True))
return
text = replace_path(text, old, new)
else:
text = replace_path(text, old, new)
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
if shutil.which("bash") and run(["bash", "-n", path + ".tmp"]).returncode != 0:
os.remove(path + ".tmp")
raise AgentError("the rewritten launcher isn't valid; nothing was moved")
os.replace(path + ".tmp", path)
def retarget_symlinks(root, names, old, new):
"""Symlinks in the moved files that point into the old folder (PC VR: the LibOVRRT redirect to Revive's runtime,
links in the Proton prefix) point into the new one."""
fixed = 0
for name in names:
for r, dirs, files in os.walk(os.path.join(root, name)):
for n in files + dirs:
p = os.path.join(r, n)
if not os.path.islink(p):
continue
target = os.readlink(p)
if os.path.isabs(target) and _inside(target, old):
try:
os.remove(p)
os.symlink(new + target[len(os.path.normpath(old)):], p)
fixed += 1
except OSError:
pass
return fixed
def _write_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path + ".tmp", "w") as f:
json.dump(data, f, indent=2)
os.replace(path + ".tmp", path)
def same_device(a, b):
return os.stat(a).st_dev == os.stat(b).st_dev
def game_is_running(dep):
if dep.get("kind") in ("pcvr", "linux"):
return bool(pcvr_pids(dep["base"]))
return container_running(dep["appid"])
def cmd_move(args):
"""Move an installed game's files to another drive (or back to internal storage): `dest` as for prepare (a drive's
FramePort folder or its mount point, "internal"). The launcher, Steam shortcut and saves stay valid. Copies run
detached (poll move_status); detach=False runs it here (tests)."""
pkg = check_pkg(args["package"])
dep = deployment(pkg)
if not dep:
raise AgentError(f"{pkg} is not installed")
title = dep.get("title") or pkg
old = os.path.normpath(dep["base"])
check_base_present(old, title)
dest = resolve_dest(args.get("dest"))
new = os.path.normpath(os.path.join(dest, pkg))
anchor = os.path.join(ANCHORS, pkg)
if new == old:
raise AgentError(f"{title} is already there")
if game_is_running(dep):
raise AgentError(f"{title} is running on the Frame. Close it first.")
try:
cur = json.load(open(MOVE_STATUS))
if cur.get("state") == "running" and cur.get("pid") and os.path.exists(f"/proc/{cur['pid']}"):
raise AgentError(f"another move is running ({cur.get('title') or cur.get('package')})")
except (OSError, ValueError):
pass
items = base_items(old, anchor)
clash = [n for n in items if os.path.lexists(os.path.join(new, n))]
if new != anchor and os.path.isdir(new) and os.listdir(new):
clash = clash or sorted(os.listdir(new))
if clash:
raise AgentError(f"{new} already has files ({clash[0]}); remove them first")
os.makedirs(dest, exist_ok=True)
same_fs = same_device(old, dest)
files, size = tree_stats(old, items)
if not same_fs:
free = _space(dest)[0] or 0
if size > free - MOVE_HEADROOM:
raise AgentError(f"not enough space: {title} needs {size / 2**30:.1f} GiB, the drive has "
f"{free / 2**30:.1f} GiB free")
job = {"package": pkg, "title": title, "from": old, "to": new, "items": items, "same_fs": same_fs,
"files": files, "total_bytes": size, "status": args.get("status") or MOVE_STATUS}
_write_json(job["status"], {"state": "running", "phase": "starting", "package": pkg, "title": title, "from": old,
"to": new, "done_bytes": 0, "total_bytes": size, "started": time.time()})
if args.get("detach", True) is False:
move_worker(json.dumps(job))
return json.load(open(job["status"]))
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-move-{int(time.time())}",
"--setenv=HOME=" + HOME, sys.executable, os.path.abspath(__file__), "_move_worker", json.dumps(job)])
if p.returncode != 0:
_write_json(job["status"], {"state": "failed", "package": pkg, "error": p.stderr.strip()[-300:]})
raise AgentError(f"couldn't start the move: {p.stderr.strip()[-300:]}")
return {"started": True, "package": pkg, "from": old, "to": new, "total_bytes": size, "files": files,
"same_drive": same_fs, "status": job["status"]}
def cmd_move_status(args):
try:
return json.load(open(args.get("status") or MOVE_STATUS))
except (OSError, ValueError):
return {"state": "none"}
def move_worker(payload):
job = json.loads(payload)
status = {"state": "running", "phase": "copying", "package": job["package"], "title": job["title"],
"from": job["from"], "to": job["to"], "done_bytes": 0, "total_bytes": job["total_bytes"],
"started": time.time(), "pid": os.getpid()}
_write_json(job["status"], status)
try:
status.update(_move(job, status))
status["state"] = "done"
except Exception as exc: # noqa: BLE001
status.update(state="failed", error=str(exc) if isinstance(exc, AgentError) else f"{type(exc).__name__}: {exc}")
status.update(finished=time.time(), phase=status["state"])
_write_json(job["status"], status)
def _move(job, status):
import threading
pkg, old, new, items = job["package"], job["from"], job["to"], job["items"]
anchor = os.path.join(ANCHORS, pkg)
dep = deployment(pkg)
if not dep or os.path.normpath(dep["base"]) != old:
raise AgentError("the game's install record changed; nothing was moved")
os.makedirs(new, exist_ok=True)
done = []
try:
if job["same_fs"]: # same drive: rename, nothing to copy
for n in items:
os.rename(os.path.join(old, n), os.path.join(new, n))
done.append(n)
else:
stop = threading.Event()
def watch(): # progress for the PC: what has arrived so far
while not stop.wait(3):
arrived = [n for n in items if os.path.lexists(os.path.join(new, n))]
status["done_bytes"] = tree_stats(new, arrived)[1]
_write_json(job["status"], status)
t = threading.Thread(target=watch, daemon=True)
t.start()
try:
for n in items:
done.append(n)
p = run(as_owner() + ["cp", "-a", "--", os.path.join(old, n), new + "/"])
if p.returncode != 0:
raise AgentError(f"copy failed: {(p.stderr or p.stdout).strip()[-300:]}")
finally:
stop.set()
t.join(5)
status["phase"] = "verifying"
_write_json(job["status"], status)
have = tree_stats(new, items)
if have != (job["files"], job["total_bytes"]):
raise AgentError(f"the copy doesn't match ({have[0]} files, {have[1]} bytes; expected "
f"{job['files']} files, {job['total_bytes']} bytes)")
status["done_bytes"] = job["total_bytes"]
status["relinked"] = retarget_symlinks(new, items, old, new)
old_launcher = open(os.path.join(anchor, "launch.sh")).read()
rebase_launcher(dep, anchor, new)
except BaseException:
if job["same_fs"]:
for n in reversed(done):
try:
os.rename(os.path.join(new, n), os.path.join(old, n))
except OSError:
pass
else:
for n in done:
_remove_owned(os.path.join(new, n))
if new != anchor:
try:
os.rmdir(new)
except OSError:
pass
remove_empty_drive_dir(new)
raise
dep_path = os.path.join(anchor, "deployment.json")
try:
with open(dep_path) as f:
raw = json.load(f)
raw.update(base=new, moved={"from": old, "time": time.time()})
_write_json(dep_path, raw)
except Exception:
with open(os.path.join(anchor, "launch.sh"), "w") as f: # keep the game where it was
f.write(old_launcher)
raise
status["phase"] = "removing"
_write_json(job["status"], status)
if not job["same_fs"]:
for n in items:
_remove_owned(os.path.join(old, n))
if old != os.path.normpath(anchor):
try:
os.rmdir(old)
except OSError:
pass
remove_empty_drive_dir(old)
return {"base": new}
def _remove_owned(path):
"""Remove a copied or moved tree, including files owned by the container's user ids."""
remove_tree(path)
if os.path.lexists(path) and as_owner():
run(as_owner() + ["rm", "-rf", "--", path])
# ------------------------------------------------------------------------------------------ launch tests
def cmd_stop(args):
dep = deployment(check_pkg(args["package"]))
@@ -3521,6 +4193,11 @@ def purge_worker(payload):
if not keep and os.path.lexists(base):
result["errors"].append(f"couldn't remove {base}")
result["removed"].append(d.get("title") or d["package"])
for path in remove_desktop_entries(): # Linux apps' Desktop Mode entries
result["removed"].append(path)
for d in games: # <drive>/FramePort folders left empty
if remove_empty_drive_dir(d["base"]):
result["removed"].append(os.path.dirname(d["base"].rstrip("/")))
if not keep or not result["kept"]:
remove_tree(ANCHORS)
else: # anchors hold only launchers/artwork; saves live in the bases
@@ -3572,10 +4249,15 @@ def cmd_cleanup(args):
freed += os.path.getsize(p)
os.remove(p)
removed.append(p)
bases = [d["base"] for d in cmd_list_installed({})["games"]]
drive_dirs = [d["install_dir"] for d in list_drives() if not d["internal"]]
for extra in args.get("paths", []):
p = os.path.realpath(os.path.expanduser(extra))
first = os.path.relpath(p, HOME).split(os.sep)[0] if p.startswith(HOME + os.sep) else ""
if not first or first.startswith(".") or p == ANCHORS or p.startswith(ANCHORS + os.sep):
# inside a drive's FramePort folder (leftovers of games on a microSD), never an installed game's files
on_drive = any(p != os.path.realpath(d) and _inside(p, os.path.realpath(d)) for d in drive_dirs) \
and not any(_inside(p, os.path.realpath(b)) or _inside(os.path.realpath(b), p) for b in bases)
if not on_drive and (not first or first.startswith(".") or p == ANCHORS or p.startswith(ANCHORS + os.sep)):
# only ordinary folders in the home folder: never dot folders (.ssh, .steam, .local, .config…)
raise AgentError(f"refusing to remove {extra}")
if os.path.exists(p):
@@ -4614,6 +5296,9 @@ def main():
if len(sys.argv) >= 3 and sys.argv[1] == "_purge_worker":
purge_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_move_worker":
move_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_xr_probe":
xr_probe(sys.argv[2])
return 0
@@ -0,0 +1,19 @@
package: com.CortopiaStudios.Graves
title: GORN2
status: works
tested_version: 1.10.1
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-07'
known_good_sha256: d8a7949edc90c19902eb89f888ad6a350475387d630991767b3097667ad16d4c
app: 0.12.0
overport_cli: 1.2.5
issue: 88
source_hint: GORN 2
@@ -0,0 +1,22 @@
package: com.ToastVR.RichiesPlankExperience
title: Richie's Plank Experience
status: works
tested_version: 2.3.585
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-07'
known_good_sha256: 7eb1366c2398e554624a5d66aefb0c731c64b1b6944bc1726d9187f123c37fb7
app: 0.12.0
overport_cli: 1.2.5
frame_build: '20260922.6101926'
agent: 59
issue: 95
source_hint: apks
@@ -0,0 +1,21 @@
package: com.characterBank.ruinsmagus
title: RUINSMAGUS
status: works
tested_version: 1.2.3
engine: Unity
xr: VrApi
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-07'
known_good_sha256: 230e5625fbdfebb47c4d96aeb87f45270e4e6c79a33c08315c90626ab7465fe5
app: 0.12.0
overport_cli: 1.2.5
frame_build: '20261006.6173745'
agent: 59
issue: 89
source_hint: RUINSMAGUS
+18
View File
@@ -0,0 +1,18 @@
package: org.timecrisis.quest
title: Time Crisis VR (Experimental)
status: works
notes: Fully playable at 120hz
tested_version: 0.8.3
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
verified:
date: '2026-10-06'
known_good_sha256: 4e67411a8e9c0b5fbb19ca7908c4222aa5ad1b2d47a8f4b5018b4fe9c2f5324e
app: 0.12.0
overport_cli: 1.2.3
frame_build: '20261005.6135832'
agent: 59
issue: 80
source_hint: TimeCrisisVR-v0.8.3-quest
+10
View File
@@ -18,6 +18,16 @@ signatures:
severity: fatal
diagnosis: 32-bit-only APK. The Steam Frame has no AArch32; this cannot run. Use the PC (Rift) version via Revive.
suggest: []
- id: android-too-new
pattern: '(NoClassDefFoundError|ClassNotFoundException|NoSuchMethodError)\b.*(Landroid/window/|android\.window\.|(storeStoreFence|loadLoadFence)\(\)V in class Ljava/lang/invoke/VarHandle)'
severity: fatal
diagnosis: The app calls Android 12/13+ classes (android.window.*, VarHandle fences) that the Frame's Android 11 (API 30) container doesn't have; it needs a newer Android (e.g. minSdk 34 apps). Nothing to patch - it can't run until Valve updates the Frame's Android.
suggest: []
- id: web-wrapper
pattern: '(Creating TwaLauncher for com\.oculus\.browser|NameNotFoundException:? com\.oculus\.browser)'
severity: fatal
diagnosis: The app is a website in an Android wrapper (Trusted Web Activity) that opens Meta's browser (com.oculus.browser), which the Frame doesn't have, so nothing opens. There's no game to port - open the website (TWALauncherActivity's "Using URL" line) in a browser instead.
suggest: []
- id: unity-render-crash
pattern: 'CRASH\s*:.*#\d+\s+pc [0-9a-f]+\s+\S*libgallium_dri\.so'
severity: fatal
+5
View File
@@ -35,6 +35,11 @@
## Extending
- **New fix**: `patches/frame/<name>.py` with a `Patch` subclass (`detect`, `apply`, optional `validate`), plus a
signature in `catalog/triage.yaml` and a PLAYBOOK row. Stages: `overport` | `apk` | `install`.
- **Upstream fixed a bug we work around**: register an `UpstreamFix` (`patches/upstream.py`) in the workaround's
module: a probe that finds the fix in OVRPort's output (True / False / None = can't tell). Each build runs the probes
on the converted APK and leaves out the workarounds whose fix is there (log "not needed: …", `build.superseded`,
left out of settings.conf at install; game page and `frameport show` say so). The recipe keeps asking for the fix,
so builds made with an older runtime keep the workaround. `FRAMEPORT_KEEP_WORKAROUNDS=1` turns it off.
- **New heuristic**: put it in the patch's `detect()` (and `applies()` for visibility), with the evidence in the
reason text; check `scripts/eval_heuristics.py` still reproduces the catalog and add a test in
`tests/test_heuristics.py`.
+23
View File
@@ -28,6 +28,23 @@
work on Android 11: `policy_control` is gone, `cmd statusbar send-disable-flag home recents` has no `back` and
moves the back button onto the app's controls, the `sysui_nav_bar` layout and disabling SystemUI had no effect.
## Where FramePort keeps games (internal storage, microSD)
- Every game has an **anchor** on internal storage: `~/Applications/quest-frame/<pkg>/` with `launch.sh`,
`deployment.json`, `artwork/` and `plays.log`. Steam's shortcut points at the anchor's `launch.sh`, so it never
changes when the files move.
- The game's files (`lepton-app/`, `lepton-data/` = saves, `lepton-shaders/`, `settings.conf`, `launch.log`; PC VR:
`game/`, `revive/`, `compatdata/` = Proton prefix; Linux: `app/`) live in `deployment.json["base"]`: the anchor
itself on internal storage, or `<mount>/FramePort/<pkg>` on another drive (GitHub #90, agent v63).
- SteamOS mounts removable drives (microSD) under `/run/media/<user>/<label or uuid>`; the agent's `drives` reads
`/proc/mounts` (plus the drives of Steam library folders) and refuses vfat/exfat/ntfs (Lepton's data and Proton
prefixes need Unix owners, permissions and symlinks) and read-only mounts. A drive that isn't mounted is an error for
new installs (never a silent fallback to internal storage); `list_installed` marks games on it `drive_missing` and
their launchers stop with "storage not mounted?".
- `move` copies with `cp -a` inside `podman unshare` (files Lepton's containers own belong to subordinate user ids),
compares file count + bytes, retargets absolute symlinks into the old folder (the LibOVRRT → Revive redirect), points
`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.
## 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
@@ -120,6 +137,12 @@
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
- mDNS: avahi-daemon runs by default; hostname `frame` → `frame.local`.
- Desktop Mode menu entries (GitHub #84, agent v63): Linux apps get `~/.local/share/applications/frameport-<slug>.desktop`
(+ an executable copy in `~/Desktop` when that folder exists; Plasma starts executable `.desktop` files there
without a trust prompt), `Exec=env FRAMEPORT_DESKTOP=1 "<anchor>/launch.sh"`, marked `X-FramePort-Package=<pkg>`.
Plasma's launcher exits right after starting the program, so with `FRAMEPORT_DESKTOP=1` the Linux launcher skips
its "Steam parent gone → end the app" watchdog and keeps the desktop's DISPLAY/WAYLAND_DISPLAY instead of taking
gamescope's from Steam. Not yet tried from the Frame's Desktop Mode.
## Video of the headset view (surveyed 2026-10-05; used by the Live view tab)
- `steamvr-v4l2cam.service` (user unit, part of gamescope-session.target, `Restart=always`) runs SteamVR's
+5
View File
@@ -27,6 +27,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Dinosaur Island | Quest | ✅ Works | |
| Espire 2 | Quest | ✅ Works | |
| Genotype | Quest | ✅ Works | |
| GORN2 | Quest | ✅ Works | |
| H.U.N.T | Quest | ✅ Works | |
| I Am Cat | Quest | ✅ Works | |
| I Am Monkey | Quest | ✅ Works | |
@@ -50,9 +51,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Path of the Warrior | Quest | ✅ Works | |
| PowerWash Simulator VR | Quest | ✅ Works | |
| QuestCraft | Quest | ✅ Works | |
| Richie's Plank Experience | Quest | ✅ Works | |
| Rick and Morty: Virtual Rick-ality | PC VR | ✅ Works | |
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
| Space Pirate Trainer Quest | Quest | ✅ Works | |
| Stremio | Quest | ✅ Works | |
@@ -62,6 +65,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| The Boys VR | Quest | ✅ Works | |
| The Climb 2 | Quest | ✅ Works | |
| The Room VR | Quest | ✅ Works | |
| Time Crisis VR (Experimental) | Quest | ✅ Works | |
| Toy Master | Quest | ✅ Works | |
| Under Cover | Quest | ✅ Works | |
| VR4 | Quest | ✅ Works | |
@@ -71,6 +75,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
| BONELAB | Quest | ⚠️ Works with issues | Build 1.2068 plays (head tracking and controls work with frame.unity_user_presence); the newer build 1.2974 crashes at start (Vulkan), no fix yet. |
| Doom3Quest | Quest | ⚠️ Works with issues | PDA shows black screen. |
| 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. |
+9
View File
@@ -85,6 +85,11 @@ which would end Desktop Mode). This is new: please report anything odd with **Re
- If the Frame goes to sleep, turns off or leaves the Wi-Fi, the queue **waits** and continues once it's back; uploads
pick up where they stopped. While installs run, FramePort keeps the Frame from going to sleep. Before a large batch
it checks the Frame has enough free space.
- **microSD card / other drives:** the **Steam Frame** page's **Storage** section lists the Frame's drives and sets
where new games go (**Install new games to**). Games go into a `FramePort` folder on the card. To move a game that's
installed already, right-click it → **Move to…** (the game must be closed; saves, settings and the Steam entry stay).
A game on the card only starts while the card is inserted (FramePort then says "SD Card not inserted"). Cards
formatted as FAT, exFAT or NTFS can't hold games: format the card in SteamOS first.
- Your own game files are never changed. The converted copy is temporary: it's removed once the game is on the Frame
(Settings → Installing: keep them, or remove all now).
- **Game settings…** (game menu or the Steam Frame page): sharpness, refresh rate, controllers, menus, 360° video and
@@ -217,6 +222,9 @@ work like for other games.
- The app must bring the libraries SteamOS doesn't have (checked for arm64 builds; x86_64 builds use FEX's x86
system, which has glibc and Mesa, and aren't checked ahead). If some are missing, the install reports them and the game
page lists them: look for a build that includes them.
- **Desktop Mode:** Linux apps also appear in Desktop Mode's application menu and as an icon on its desktop. Some
apps work better there, with a mouse and keyboard, than in Gaming Mode (where Steam Input turns the controllers into
a gamepad). Switch it off per app on the game page (**Desktop Mode**).
- From the command line: `frameport add-linux <AppImage, folder or archive> [--exe <program>]`.
## Files on the Frame (videos, documents, mods, saves)
@@ -261,6 +269,7 @@ and `frameport <command> --help` describe every option. The main ones:
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for PC VR on this PC), launch test |
| `frame discover` / `frame connect` / `frame info` | find, pair with and describe the Frame |
| `frame send` / `frame storage` / `frame cleanup` | copy files to the Frame, show where they go, free space |
| `frame drives` / `frame move <game> --to <drive>` / `install --dest <drive>` | the Frame's drives (microSD), move a game, install to a drive |
| `tools status` / `tools install` | the tools FramePort downloads |
| `open-link "<link>"` | install from an "Install with FrameDrop" button's address or a manifest/APK/zip link (`--yes`, `--no-install`) |
| `diag report <game>` / `share-recipe <game>` | report a problem / share a working recipe |
+8 -1
View File
@@ -21,7 +21,10 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| `cannot locate symbol "ovrPeerConnectionState_ToString"` (or another `ovr<Enum>_ToString`) at start (BlazeRush, GitHub #57) | OVRPort's platform loader lacks several Platform SDK enum helpers | `frame.ovrstubs` now stubs them too; `*_ToString` stubs return "" (not NULL) |
| Exits right after the OpenXR instance is created: `Failed to create XR session: -50` (`XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING`, Lambda1VR) | the game skips `xrGet*GraphicsRequirementsKHR`; Meta's runtime tolerates it, SteamVR's doesn't. Lambda1VR's TBXR doesn't even enable `XR_KHR_opengl_es_enable` (only `XR_EXT_local_floor`) | FrameBridge adds `XR_KHR_opengl_es_enable` when an app enables no graphics API extension, asks for the requirements on the game's behalf and retries xrCreateSession once (logs `added XR_KHR_opengl_es_enable …`, `the app skipped …; asked for it`; every launch logs the final extension list `xrCreateInstance with N extension(s)`); triage `graphics-requirements-missing`. Verified headless: Lambda1VR runs at 72 fps |
| Exits at start: `OVRAvatar-Loader: DisplayErrorAndExit: Failed to launch SystemActivities` after `ovrAvatar_Initialize: Failed to load AvatarSDK driver` (BlazeRush, GitHub #57) | Meta's avatar loader needs Horizon; it then tries Meta's error screen and aborts | `frame.avatar_stub` (same exports, do nothing); triage `avatar-driver-missing`. Games that ship the library but don't start avatars at launch (Lucky's Tale, BattleSisters, Arcsmith) don't need it |
| Frame freezes or the game is killed (OOM) on the first controller vibration (e.g. Lucky's Tale's save slots, BattleSisters) | OVRPort runtimes up to 3.4.3-23204ea read the haptic envelope's duration (ns) as seconds and allocate gigabytes of samples (GitHub #9, ovrport/app#73) | `adapter.haptic_fix` (envelope → one plain vibration in `native/xrshim`). Fixed in runtime 3.4.3-aa54c3f: builds made with it leave the workaround out by themselves (log "not needed: adapter.haptic_fix", `frameport show` "last build left out"); rebuild an older build to switch |
| Controller vibrations much stronger than on a Quest (The Boys VR, Jurassic World, BONELAB, …) | OVRPlugin games send amplitude envelopes; `haptic_fix` turned each into one vibration at the envelope's *peak* for its whole duration (a short fading pulse became a long full-strength buzz) | `haptic_fix` now uses the envelope's RMS; Game settings → "Vibration strength" (`haptic_scale`, 0-1) scales every vibration in FrameBridge; launch.log shows the first requests (`haptic: …`). OVRPlugin stops a vibration with amplitude 0 (2 s duration): FrameBridge turns that into xrStopHapticFeedback (without it the last buzz ran its full 2 s) |
| Play or a launch test fails at once: `Game files missing at <path> (storage not mounted?)` in launch.log, or "… files are on a drive that isn't inserted" (GitHub #90) | the game was installed or moved to a microSD card (`<mount>/FramePort/<pkg>`) that isn't inserted or mounted now | insert the card; or move the game back (game menu → Move to…, `frameport frame move <pkg> --to internal`) once the card is back. `frameport frame drives` shows what the agent sees (exFAT/NTFS cards are refused: format in SteamOS) |
| A Linux app started from Desktop Mode's menu closes after ~2 s, or opens on the wrong display (GitHub #84) | its launcher predates agent v63: the "Steam parent gone" watchdog ended it when Plasma's launcher exited, and it took gamescope's DISPLAY from Steam | connect FramePort once (agent v63's `upgrade_launchers` adds the `FRAMEPORT_DESKTOP` checks; the menu entry runs `env FRAMEPORT_DESKTOP=1 <anchor>/launch.sh`). Game Mode controller problems in such apps are Steam Input's (a separate issue) |
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (e.g. Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
@@ -42,14 +45,18 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Uploads to the Frame are slow (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
| 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 |
| 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 |
| A backup's game was added without its data (library `data_dir` null, `data_bytes` 0) although the folder has .obb files | The OBBs sit in a layout the scan didn't know: `<game>/obb/<package>/`, `Android/obb/<package>/`, or SideQuest-style `<game>/apk/x.apk` + `<game>/obb/<package>/` | `sources/quest_dump.find_data_dir` finds a `<package>` folder with .obb files ≤3 levels below the APK's folder or ≤2 below its parent (skipping neighbouring folders with APKs: other games); an `apk`/`apks` folder counts as part of its game folder. Add the folder again (Analyze again doesn't look for data) |
| Unreal game: `JNI_OnLoad` (OVRPlugin), then silence: no OpenXR instance, no crash, no frames (GitHub #85 TRIANGLE STRATEGY; launch-test finding `missing-obb`) | Installed without its OBB: the manifest says `com.epicgames.ue4.GameActivity.bHasOBBFiles` = true (UE5: `com.epicgames.unreal.…`) but the library has no data folder (`data_dir` null, `data_bytes` 0) | Put the .obb files in a `<package>` (or `obb/`) folder next to the APK, add the folder again, reinstall. Analysis `extra.expects_obb`; the game page, the install question and `frameport scan`/`install` warn; launch tests that sent no frames get the `missing-obb` finding from the library (the game logs nothing). Unity split-binary builds have no reliable sign in the APK |
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK; Frame has no AArch32 | None. PC/Rift version via Revive |
| Crash at start: `NoClassDefFoundError: … Landroid/window/OnBackInvokedCallback;` or `NoSuchMethodError: No static method storeStoreFence()V in class Ljava/lang/invoke/VarHandle;` (GitHub #71/#72, triage `android-too-new`) | The APK needs Android 13+ (minSdk 33 or more; those were 34) and calls its classes at start; Lepton's Android is 11 (API 30). OVRPort lowers minSdk to 29, so it installs anyway. minSdk ≤ 32 (Quest's Android 12L) is fine: 16 working catalog games declare 32 | None (analysis `extra.min_sdk` > 32 → a warning on the game page/CLI and in the recipe notes; never unsupported from the manifest alone, the launch test's triage decides). Waits for Valve to update Lepton's Android |
| Nothing opens; `TWALauncherActivity: Using URL from Manifest (https://…)`, `Creating TwaLauncher for com.oculus.browser`, `NameNotFoundException: com.oculus.browser` (GitHub #86 Mahjong Table VR, triage `web-wrapper`) | The APK is a Trusted Web Activity (Bubblewrap / Meta's PWA packaging): a website that opens in Meta's browser, which Lepton doesn't have | None: open the URL in a browser. Analysis `extra.web_wrapper` (manifest meta-data `android.support.customtabs.trusted.DEFAULT_URL`, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) → a warning with the URL (not unsupported: the manifest alone never decides) |
| `UnsatisfiedLinkError` / `cannot locate symbol "ovr_…"` | overport's platform loader lacks Meta platform functions | `frame.ovrstubs` (automatic, generated stubs) |
| missing `ovrMessageType_ToString` | same, but the game needs a real string | `frame.ovrplatformcompat` (automatic) |
| The game stays in its built-in language or waits at start although its OBB / files hold language packs (`<tag>.lang`, e.g. `de.lang`) | overport's platform loader answers `ovr_LanguagePack_GetCurrent/SetCurrent` with request id 0, so the game never gets the pack's path | `frame.langpacks` (opt-in, experimental, shown for games whose data has `*.lang`; `native/langpack`, build it with `python native/build.py --only langpack`). Default pack: the one the game applies, else env `FRAMEPORT_LANGPACK=<tag>`, else the only pack; search path override `FRAMEPORT_LANGPACK_DIRS`. Unreal games (Deadpool VR) grey out a pack whose asset `Metadata` differs from the game's version string: the patch writes the APK's versionName as Metadata (env `FRAMEPORT_LANGPACK_META` overrides) and reports the pack under the `/storage/emulated/0/Android/obb/<pkg>/` spelling of its path; with `/sdcard/…` the text switched but the dialogue stayed silent (verified in the headset with Deadpool VR) |
| `ClassNotFoundException com.oculus.os.AnalyticsEvent` → abort | Quest telemetry lookup in Meta XR Audio (Unreal build) or native code | `frame.metaxr_telemetry` + `frame.oculusos` (automatic) |
| `JNI DETECTED ERROR`, `GetStringUTFChars … NULL` | CheckJNI is on because overport marks the app debuggable | `frame.nodebug` |
| Unreal game quits a few seconds after start (`System.exit`) | ForceQuit after a failed Quest platform check | alternate build with `patch_remove_unreal_force_quit` (`use_alt`) |
| Unreal game crashes a few seconds after the logo with no backtrace (Unreal's own signal handler hides it: logcat-crash.log empty, Zygote `exited due to signal 11`); the crashing thread is `GameThread`, pc in `libaaudio.so`, lr `libovrplatformloader.so (ovr_Microphone_GetOutputBufferMaxSize+0x10)`; the last platform call is `ovr_Microphone_Create` (e.g. The Walking Dead: Saints & Sinners Ch. 2) | OVRPort's platform loader opens the microphone's AAudio stream only in `ovr_Microphone_Start`, but `GetOutputBufferMaxSize` reads that stream unchecked; Unreal's Oculus voice asks for the size right after `Create` | `frame.ovr_microphone` (NULL check rewritten in place; size 0 until the microphone starts) |
| Unreal game crashes a few seconds after the logo with no backtrace (Unreal's own signal handler hides it: logcat-crash.log empty, Zygote `exited due to signal 11`); the crashing thread is `GameThread`, pc in `libaaudio.so`, lr `libovrplatformloader.so (ovr_Microphone_GetOutputBufferMaxSize+0x10)`; the last platform call is `ovr_Microphone_Create` (e.g. The Walking Dead: Saints & Sinners Ch. 2) | OVRPort's platform loader opens the microphone's AAudio stream only in `ovr_Microphone_Start`, but `GetOutputBufferMaxSize` reads that stream unchecked; Unreal's Oculus voice asks for the size right after `Create` | `frame.ovr_microphone` (NULL check rewritten in place; size 0 until the microphone starts). OVRPort runtime 3.4.3-aa54c3f has the check itself: builds made with it leave the patch out (log "not needed: frame.ovr_microphone", `patches/upstream.py`) |
| `xrCreateSwapchain` -26 / format unsupported (GLES) | Frame takes only sRGB formats, no MSAA | adapter `swapchain_fix` (default on) |
| Frames rejected, "Waiting…" forever | a layer uses a failed swapchain or an extension that isn't enabled (e.g. equirect2) | adapter `layer_fix` (default on) |
| `xrConvert…TimeKHR` FUNCTION_UNSUPPORTED spam; VrApi bridge stalls before recenter | runtime lacks timespec conversion | current adapter emulates it |
+39 -1
View File
@@ -152,6 +152,40 @@ class FakeTarget:
"apk_present": True, "recipe": None, "exe": g.get("exe"),
"missing_libraries": ["libwebkit2gtk-4.1.so.0"]}
for g in library.games() if g.get("kind") == "linux" and g["package"] == LINUX_APPIMAGE]
# GitHub #90: most games on internal storage, the second one on a microSD card
sd = {"internal": False, "path": "/run/media/steamos/SD Card", "label": "SD Card"}
for i, g in enumerate(self.games):
g["drive"] = sd if i == 1 else {"internal": True, "path": "/home/steamos", "label": "Internal storage"}
g["drive_missing"] = False
def drives(self) -> list[dict]:
sd_games = sum(1 for g in self.games if not g["drive"]["internal"])
return [{"id": "internal", "path": "/home/steamos", "install_dir": "/home/steamos/Applications/quest-frame",
"label": "Internal storage", "fstype": "ext4", "internal": True, "removable": False,
"free_bytes": 312 * 2**30, "total_bytes": 460 * 2**30, "usable": True, "reason": "",
"games": len(self.games) - sd_games},
{"id": "/run/media/steamos/SD Card", "path": "/run/media/steamos/SD Card",
"install_dir": "/run/media/steamos/SD Card/FramePort", "label": "SD Card", "fstype": "ext4",
"internal": False, "removable": True, "free_bytes": 187 * 2**30, "total_bytes": 238 * 2**30,
"usable": True, "reason": "", "games": sd_games},
{"id": "/run/media/steamos/USB", "path": "/run/media/steamos/USB", "install_dir":
"/run/media/steamos/USB/FramePort", "label": "USB", "fstype": "exfat", "internal": False,
"removable": True, "free_bytes": 50 * 2**30, "total_bytes": 64 * 2**30, "usable": False,
"reason": "exfat can't hold game data (no Unix permissions or symlinks); format the drive in "
"SteamOS to install games on it", "games": 0}]
def move(self, package, dest, reporter) -> dict:
reporter.stage("Move game files")
for i in range(5):
reporter.progress(i / 4, f"Copying {i / 2:.1f}/2.0 GiB")
time.sleep(0.5)
return {"state": "done", "package": package}
def set_desktop_entry(self, package, enabled) -> dict:
for g in self.games:
if g["package"] == package:
g["desktop_entry"] = enabled
return {"enabled": enabled}
def describe(self) -> dict:
return {"hostname": "steamframe", "os": "SteamOS", "os_version": "3.8", "build_id": "20260922",
@@ -405,6 +439,8 @@ def main() -> int:
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game))),
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game)))]
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))))
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
@@ -478,7 +514,9 @@ def main() -> int:
("linux-folder-customize", lambda a: a.open_game(LINUX_FOLDER, advanced=True)),
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
("linux-menu", lambda a: (a.navigate(0), time.sleep(3), a.library_view.open_menu(LINUX_APPIMAGE)))]
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(LINUX_APPIMAGE)))]
if args.links:
from frameport import deeplink
from frameport.ui.views import link_dialog
+66 -3
View File
@@ -16,12 +16,67 @@ UNITY_GGM = "assets/bin/Data/globalgamemanagers"
IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
ANDROID_VERSIONS = {29: "10", 30: "11", 31: "12", 32: "12L", 33: "13", 34: "14", 35: "15", 36: "16", 37: "17"}
FRAME_API = 30
# Quest games declare up to API 32 (Quest's Android 12L) and run on the Frame (OVRPort lowers minSdk to 29; they
# don't call newer Android classes). From API 33 on, apps call Android 13+ classes at start (GitHub #71/#72: minSdk
# 34, NoClassDefFoundError android/window/OnBackInvokedCallback, NoSuchMethodError VarHandle.storeStoreFence).
MAX_RUNNABLE_MIN_SDK = 32
def android_version(api: int) -> str:
"""'14' for API 34."""
return ANDROID_VERSIONS.get(api, f"API {api}")
def too_new_android(min_sdk: int | None) -> bool:
"""The APK needs a newer Android than the Frame's Lepton (11): it crashes at start on missing Android classes."""
return bool(min_sdk) and min_sdk > MAX_RUNNABLE_MIN_SDK
TWA_URL_KEY = "android.support.customtabs.trusted.DEFAULT_URL"
TWA_ACTIVITY = "com.google.androidbrowserhelper.trusted.LauncherActivity"
UE_OBB_KEY = ".GameActivity.bHasOBBFiles" # com.epicgames.ue4.… (UE4) / com.epicgames.unreal.… (UE5)
def expects_obb(meta: dict) -> bool:
"""Unreal packaged the game's content as an OBB (expansion file) and opens it at start."""
return any(k.endswith(UE_OBB_KEY) and v in (True, "true", "True") for k, v in meta.items())
def _read_manifest_info(path: Path):
"""(package, versionName, label, main activity, pyaxmlparser APK)."""
from pyaxmlparser import APK
apk = APK(str(path))
label = apk.application or apk.package
return apk.package, apk.version_name or "", label, apk.get_main_activity()
return apk.package, apk.version_name or "", label, apk.get_main_activity(), apk
def _resolve_string(apk, value) -> str | None:
"""A meta-data value: the string itself, or a @string/… reference resolved through resources.arsc."""
if isinstance(value, str):
return value
if apk is None or isinstance(value, bool) or not isinstance(value, int):
return None
try:
res = apk.get_android_resources()
for _config, v in (res.get_resolved_res_configs(value) if res else ()):
if isinstance(v, str) and v:
return v
except Exception: # noqa: BLE001 - a broken resource table only loses the URL
pass
return None
def web_wrapper(meta: dict, manifest_strings: list[str], apk=None) -> dict | None:
"""A Trusted Web Activity (Bubblewrap, Meta's PWA packaging): the APK only opens a website in a browser app
(Meta's com.oculus.browser on Quest), which Lepton doesn't have (GitHub #86). {"url": str | None} or None."""
if TWA_URL_KEY not in meta and TWA_ACTIVITY not in manifest_strings:
return None
url = _resolve_string(apk, meta.get(TWA_URL_KEY))
return {"url": url if url and url.startswith(("https://", "http://")) else None}
def _unreal_version(z: zipfile.ZipFile, lib: str) -> str | None:
@@ -112,7 +167,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
ggm = z.read(UNITY_GGM) if deep and UNITY_GGM in names else None
il2cpp_meta = z.read(IL2CPP_METADATA) if deep and IL2CPP_METADATA in names and "libil2cpp.so" in libs else None
package, version, label, activity = _read_manifest_info(path)
package, version, label, activity, apk_info = _read_manifest_info(path)
libset = set(libs)
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
else "CryEngine" if "libCrySystem.so" in libset else "Other")
@@ -159,6 +214,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
manifest_strings = axml.Axml(manifest).strings()
features = _features(manifest)
used_perms, _ = axml.used_and_declared_permissions(manifest)
meta = axml.meta_data(manifest)
return Analysis(
package=package,
version=version,
@@ -216,6 +272,13 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
# a 2D launcher activity that starts a separate VR activity (frame.start_activity)
"vr_activity": axml.vr_activity(manifest),
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
# the minimum Android version the APK declares (read from the original: OVRPort lowers it to 29)
"min_sdk": axml.min_sdk(manifest),
# a website in an Android wrapper (TWA): nothing to port
"web_wrapper": web_wrapper(meta, manifest_strings, apk_info),
# Unreal packaged its content as an OBB (expansion file): without it the game hangs at start (GitHub #85).
# (Unity's split-binary builds show no reliable sign in the APK.)
"expects_obb": expects_obb(meta),
},
)
+45
View File
@@ -15,6 +15,9 @@ RES_XML_START_ELEMENT = 0x0102
RES_XML_END_ELEMENT = 0x0103
TYPE_STRING = 0x03
TYPE_INT_BOOLEAN = 0x12
TYPE_REFERENCE = 0x01
TYPE_INT_DEC = 0x10
TYPE_INT_HEX = 0x11
ANDROID_NS = "http://schemas.android.com/apk/res/android"
LAUNCHER = "android.intent.category.LAUNCHER"
INFO = "android.intent.category.INFO"
@@ -214,6 +217,48 @@ def undeclared_meta_permissions(manifest: bytes) -> list[str]:
return [p for p in used if p.startswith(("com.oculus.permission.", "horizonos.permission.")) and p not in declared]
def min_sdk(manifest: bytes) -> int | None:
"""<uses-sdk android:minSdkVersion> (None when absent or a preview codename)."""
x = Axml(manifest)
names = x.strings()
for el in x.elements():
if el.name != "uses-sdk":
continue
for a in el.attrs:
if a.name < len(names) and names[a.name] == "minSdkVersion":
if a.dtype in (TYPE_INT_DEC, TYPE_INT_HEX):
return a.value
s = x.attr_str(el, "minSdkVersion")
return int(s) if s and s.isdigit() else None
return None
def meta_data(manifest: bytes) -> dict[str, str | int | bool | None]:
"""<meta-data android:name android:value|resource> entries: a string, a bool, an int, or a resource id (int)
for references (@string/…), None when the value isn't one of those."""
x = Axml(manifest)
names = x.strings()
out: dict = {}
for el in x.elements():
if el.name != "meta-data":
continue
key = x.attr_str(el, "name")
if not key:
continue
value = None
for a in el.attrs:
if a.name >= len(names) or names[a.name] not in ("value", "resource"):
continue
if a.raw < len(names) or a.dtype == TYPE_STRING:
value = x.attr_str(el, names[a.name])
elif a.dtype == TYPE_INT_BOOLEAN:
value = a.value != 0
elif a.dtype in (TYPE_INT_DEC, TYPE_INT_HEX, TYPE_REFERENCE):
value = a.value
out[key] = value
return out
def categories(manifest: bytes) -> set[str]:
x = Axml(manifest)
return {v for el in x.elements() if el.name == "category" for v in [x.attr_str(el, "name")] if v}
+15 -10
View File
@@ -10,7 +10,7 @@ from .apk.workspace import ApkWorkspace
from .core.events import Reporter
from .core.models import Analysis, BuildResult, Recipe, SourceGame
from .core.paths import work_dir
from .patches import base
from .patches import base, upstream
from .patches.overport import OVERPORT_PATCHES
from .tools import overport as overport_tool
from .validate.static import check_apk
@@ -36,25 +36,28 @@ def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
return ids
def apk_patches(recipe: Recipe) -> list[base.Patch]:
patches = [base.get(pid) for pid in recipe.patches if base.get(pid).stage == "apk"]
def apk_patches(selection: dict) -> list[base.Patch]:
patches = [base.get(pid) for pid in selection if base.get(pid).stage == "apk"]
return sorted(patches, key=lambda p: p.order)
def apply_frame_fixes(apk_in: Path, apk_out_unsigned: Path, analysis: Analysis, recipe: Recipe,
reporter: Reporter) -> tuple[list[str], list[dict]]:
reporter: Reporter) -> tuple[list[str], list[dict], dict[str, str]]:
"""Apply the recipe's apk-stage patches; returns (applied ids, checks, workarounds left out because OVRPort's
output already has the upstream fix (patches/upstream.py))."""
applied, checks = [], []
with ApkWorkspace(apk_in) as ws:
for patch in apk_patches(recipe):
selection = upstream.resolve(ws, recipe.patches, reporter)
for patch in apk_patches(selection.patches):
reporter.check_cancel()
ctx = base.ApkContext(ws, analysis, recipe.params(patch.id), reporter, recipe.patches)
ctx = base.ApkContext(ws, analysis, selection.patches.get(patch.id) or {}, reporter, selection.patches)
if patch.apply(ctx):
applied.append(patch.id)
reporter.log(f"applied {patch.id}" + (f" ({'; '.join(ctx.notes)})" if ctx.notes else ""))
for name, ok, detail in patch.validate(ctx):
checks.append({"name": name, "ok": ok, "detail": detail})
ws.write(apk_out_unsigned)
return applied, checks
return applied, checks, selection.superseded
def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path, reporter: Reporter,
@@ -73,7 +76,7 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
variants = [("primary", False)] + ([("alt", True)] if recipe.alt_patches and recipe.overport else [])
alt_copy = Path(source.apk).with_name(f"{pkg}.alt-noforcequit.apk")
results = {}
all_checks, applied = [], []
all_checks, applied, superseded = [], [], {}
try:
for variant, alt in variants:
input_apk = alt_copy if converted and alt and alt_copy.exists() else Path(source.apk)
@@ -88,7 +91,9 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
shutil.copyfile(input_apk, patched)
reporter.stage(f"Frame fixes ({variant})")
unsigned = work / f"{pkg}.{variant}.unsigned.apk"
applied, checks = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
applied, checks, left_out = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
if variant == "primary":
superseded = left_out
patched.unlink(missing_ok=True)
reporter.stage(f"sign ({variant})")
final = outdir / (f"{pkg}.apk" if not alt else f"{pkg}.alt-noforcequit.apk")
@@ -106,4 +111,4 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
primary, alt_apk = results["primary"], results.get("alt")
return BuildResult(pkg, primary, alt_apk, sha256(primary), sha256(alt_apk) if alt_apk else None, applied,
all_checks, {"overport": overport_ids(recipe), "alt_overport": overport_ids(recipe, True)
if alt_apk else None})
if alt_apk else None, "superseded": superseded})
+47 -1
View File
@@ -229,6 +229,8 @@ def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs
for g in pipeline.add_path(path, rep):
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}")
@app.command("catalog-update")
@@ -329,6 +331,9 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
typer.echo(f"{g.get('title')} ({pkg} {a['version']})\n engine {a['engine']}, XR {a['xr']}, {a['graphics']}, "
f"ABIs {', '.join(a['abis'])}, direct VrApi: {a['direct_vrapi']}")
typer.echo(f" status: {r['status']} recipe source: {r['source']} {r['notes']}")
for warning in pipeline.analysis_warnings(g):
if warning not in r["notes"]:
typer.echo(f" ! {warning}")
from .recommend.engine import visible_patches
shown, hidden = visible_patches(library.analysis_from_dict(a), library.recipe_from_dict(r))
@@ -337,6 +342,8 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
params = r["patches"].get(p.id) or {}
typer.echo(f" [{'x' if on else ' '}] {p.id:36} {p.title}{' ' + json.dumps(params) if params else ''}"
f"{' — ' + r['reasons'][p.id] if on and p.id in r.get('reasons', {}) else ''}")
for pid, fix in ((g.get("build") or {}).get("superseded") or {}).items():
typer.echo(f" last build left out {pid}: fixed upstream ({fix})")
if hidden and not all_:
typer.echo(f" ({len(hidden)} patches hidden as not relevant for this game; --all to list them)")
if r.get("alt_patches"):
@@ -414,13 +421,20 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; e.g. "
"a test build signed with the game's key)")):
"a test build signed with the game's key)"),
dest: Optional[str] = typer.Option(None, help="drive for new installs: 'internal' or a drive's path from "
"'frameport frame drives' (default: the app's setting); "
"installed games stay where they are")):
"""Install games on the Frame (or PC VR games on this PC) and add them to the Steam library."""
target = _target(frame, password, to)
if dest is not None and hasattr(target, "dest"):
target.dest = "" if dest == "internal" else dest
pkgs = _pkgs(package, all_)
if apk and len(pkgs) != 1:
raise typer.BadParameter("--apk needs exactly one game")
for pkg in pkgs:
if not apk_only and pipeline.missing_obb(library.game(pkg) or {}):
typer.echo(f"{pkg}: warning: " + pipeline.MISSING_OBB_NOTE.format(package=pkg), err=True)
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False, apk=apk)
if not no_library:
target.add_to_library(pkgs, printing_reporter(False))
@@ -553,6 +567,38 @@ def frame_storage(game: Optional[str] = typer.Option(None, help="also show this
typer.echo(f"{t['id']:10} {t['android']:45} {t['path']}" + (" (every app)" if t["shared"] else ""))
@frame_app.command("drives")
def frame_drives(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
"""The Frame's drives (internal storage, a microSD card) games can be installed on or moved to."""
from .install import drives
found = _target(frame).drives()
if as_json:
typer.echo(json.dumps(found, indent=1))
return
current = drives.install_dest()
for d in found:
free = drives.free_text(d) or "?"
mark = "*" if (d["internal"] and not current) or current in (d["install_dir"], d["path"]) else " "
state = f"{d.get('games', 0)} game(s)" if d.get("usable") else f"can't be used: {d.get('reason')}"
typer.echo(f"{mark} {d['label']:20} {d.get('fstype') or '':6} {free:>16} {state}")
if not d["internal"]:
typer.echo(f" {'':20} {d['path']}")
typer.echo("* = where new games go (frameport install --dest, or the app's Settings: Frame)")
@frame_app.command("move")
def frame_move(package: str = typer.Argument(..., help="an installed game"),
to_: str = typer.Option(..., "--to", help="'internal' or a drive's path from 'frameport frame drives'"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Move an installed game's files to another drive of the Frame (e.g. the microSD card) or back. Saves, the
Steam library entry and settings stay as they are."""
pkg = _pkgs(package, False)[0]
st = _target(frame).move(pkg, to_, printing_reporter())
typer.echo(f"moved to {st.get('base') or st.get('to')}")
@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)."""
+98
View File
@@ -0,0 +1,98 @@
"""Games on another drive of the Frame (a microSD card, GitHub #90): which drives there are, where new games go
(library setting `install.drive`) and moving an installed game. No Flet here (the CLI and GUI share it).
The agent keeps each game's anchor (launch.sh, deployment.json, artwork) on internal storage and its files in
`<drive>/FramePort/<package>`; a drive is given by its install dir (or mount point), internal storage by "internal"."""
from __future__ import annotations
import time
from ..core import library
from ..core.events import Reporter
from ..frame.connection import AgentFailed, Frame
SETTING = "install.drive"
INTERNAL = "internal"
def list_drives(frame: Frame) -> list[dict]:
"""The agent's `drives`: [{id, path, install_dir, label, fstype, internal, removable, free_bytes, total_bytes,
usable, reason, games}]."""
return frame.agent("drives")["drives"]
def install_dest() -> str | None:
"""Where new games go (None = internal storage)."""
value = library.setting(SETTING) or INTERNAL
return None if value == INTERNAL else value
def set_install_dest(install_dir: str | None) -> None:
library.set_setting(SETTING, install_dir or INTERNAL)
def free_text(d: dict) -> str:
""""112.4 GiB free" ("" when unknown)."""
from ..i18n import tr
free = d.get("free_bytes")
return tr("{size:.1f} GiB free").format(size=free / 2**30) if isinstance(free, (int, float)) else ""
def drive_text(d: dict) -> str:
""""SD Card · 112.4 GiB free" (labels for menus and dropdowns)."""
return " · ".join(filter(None, [d.get("label") or d.get("path"), free_text(d)]))
def game_drive(dep: dict | None) -> dict | None:
"""{"internal", "path", "label", "missing"} of an installed game (the agent's list_installed entry), or None for
agents before v63."""
if not dep or "drive" not in dep:
return None
return {**dep["drive"], "missing": bool(dep.get("drive_missing"))}
def is_on(dep: dict | None, d: dict) -> bool:
"""The game (list_installed entry) is on drive d (a `drives` entry)."""
g = game_drive(dep)
if not g:
return d.get("internal", False)
return bool(g["internal"]) if d.get("internal") else (not g["internal"] and g["path"] == d["path"])
def move(frame: Frame, package: str, dest: str, reporter: Reporter, poll: float = 2.0,
timeout: float = 6 * 3600, sleep=time.sleep) -> dict:
"""Move an installed game to `dest` (a drive's install dir or "internal"). The copy runs on the Frame by itself;
this follows its progress (a reconnect picks a running move of the same game up again). Cancel works until the
copy has started."""
reporter.stage("Move game files")
st = frame.agent("move_status")
if not (st.get("state") == "running" and st.get("package") == package):
reporter.check_cancel()
try:
st = frame.agent("move", package=package, dest=dest)
except AgentFailed as exc:
# a job that lost the Frame runs again: the move it started may have finished meanwhile
if "already there" in str(exc):
reporter.check("Game files moved", True, "already there")
return {"state": "done", "package": package}
raise
reporter.log(f"moving {st['from']} → {st['to']} ({st['total_bytes'] / 2**30:.1f} GiB"
+ (", same drive" if st.get("same_drive") else "") + ")")
end = time.time() + timeout
while time.time() < end:
st = frame.agent("move_status")
if st.get("state") in ("done", "failed"):
break
total, done = st.get("total_bytes") or 0, st.get("done_bytes") or 0
phase = {"copying": "Copying", "verifying": "Checking the copy", "removing": "Removing the old copy"}.get(
st.get("phase") or "", "Starting")
reporter.progress(done / total if total else 0.0,
f"{phase} {done / 2**30:.1f}/{total / 2**30:.1f} GiB" if total else phase)
sleep(poll)
else:
raise RuntimeError("the move didn't finish in time; it may still be running on the Frame")
if st.get("state") != "done":
raise RuntimeError(f"moving failed: {st.get('error') or 'unknown error'} (the game stays where it was)")
reporter.check("Game files moved", True, st.get("base") or st.get("to") or "")
return st
+8 -2
View File
@@ -228,6 +228,8 @@ class LinuxPlan:
appimage: bool = False
openxr: bool = False
x86_64: bool = False # runs through FEX (installed on the Frame first, like Proton)
dest: str | None = None # a drive's install dir for a new install (GitHub #90); None = internal storage
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
@@ -236,7 +238,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
if plan.x86_64:
ensure_proton(frame, reporter, kind="linux_x86")
reporter.stage("Prepare Frame")
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title)
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title, dest=plan.dest)
if plan.files:
manifest = {name: (plan.root / name).stat().st_size for name in plan.files}
else:
@@ -259,8 +261,12 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
executables = [rel for rel in manifest if rel != plan.exe and _is_elf(plan.root / rel)][:200]
result = frame.agent("finalize_linux", package=plan.package, title=plan.title, exe=plan.exe,
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
executables=executables, tags=_tags(plan.package), timeout=900)
executables=executables, tags=_tags(plan.package), dest=plan.dest,
desktop_entry=plan.desktop_entry, timeout=900)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
if result.get("desktop_entry"):
reporter.check("Desktop Mode entry", True, "in the application menu"
+ (" and on the desktop" if result["desktop_entry"].get("desktop") else ""))
return result
+33
View File
@@ -19,9 +19,11 @@
" · link": "",
" · not installed yet": "",
" · on the Frame: ": "",
" · on {label}": "",
" · restart the Frame soon to reset it": "",
" · Revive provides the Oculus runtime": "",
" · {human} in files": "",
" · {label} not inserted": "",
" · {queued} more queued": "",
" · {value:.0f} MiB": "",
" · {value:.1f} GiB": "",
@@ -90,6 +92,7 @@
"Add {name}": "",
"Added automatically (engine, VR API or store genre)": "",
"Added automatically when needed": "",
"Added to Desktop Mode": "",
"Added {n} game": [
"",
""
@@ -101,6 +104,7 @@
"Adds a small piece of Meta's platform library that some games need to start.": "",
"Adds no-op com.oculus.os.AnalyticsEvent / UnifiedTelemetryLogger classes for native code that looks them up through the app class loader (e.g. Nano, NOPE Challenge).": "",
"Adds OVRPort's OpenXR loader dispatcher and platform loader. Required: without it nothing is translated.": "",
"Adds the app to Desktop Mode's application menu and puts an icon on its desktop. Some apps work better there, with a mouse and keyboard, than in Gaming Mode, where Steam Input turns the controllers into a gamepad.": "",
"Adds the parts that let a Quest game talk to the Steam Frame. Needed for every Quest game.": "",
"Adds the VR app metadata other launchers expect.": "",
"Advanced": "",
@@ -166,6 +170,7 @@
"Build page": "",
"Cable connected.": "",
"Camera": "",
"can't hold games: {reason}": "",
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
"Can't run": "",
"Can't use that link: {reason}": "",
@@ -235,6 +240,7 @@
"Controllers": "",
"Converts Quest games to OpenXR": "",
"Copied": "",
"Copies the game's files, data and saves to the other drive, checks the copy and then removes the old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a microSD card only starts while the card is inserted.": "",
"Copy": "",
"Copy all": "",
"Copy failed; select the text instead": "",
@@ -247,6 +253,7 @@
"Copy PID {pid}": "",
"Couldn't check this PC": "",
"Couldn't connect: {exc}": "",
"Couldn't list the Frame's drives: {error}": "",
"Couldn't list the screenshots: {exc}": "",
"Couldn't list {path}: {exc}": "",
"couldn't load the full image: {exc}": "",
@@ -286,6 +293,7 @@
"Delete…": "",
"Deselect": "",
"Desktop": "",
"Desktop Mode": "",
"Details": "",
"Dev builds let you test fixes before they're released. They're less tested than releases and may have bugs. You get the next release as a normal update (you have {version}).": "",
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
@@ -472,6 +480,7 @@
"Game config files": "",
"Game configs are up to date": "",
"Game configs from GitHub: last checked {when}": "",
"Game data (.obb) not found": "",
"game data (OBB)": "",
"Game launch capacity": "",
"Game not found": "",
@@ -535,6 +544,7 @@
"If you know the Frame's address.": "",
"Ignore focus dips up to": "",
"Image for the {label}": "",
"In the menu and on the desktop": "",
"In your Steam library": "",
"In your Steam library · launch settings changed — update it": "",
"Install": "",
@@ -548,6 +558,7 @@
"Install link: {host}": "",
"Install links": "",
"Install missing": "",
"Install new games to": "",
"Install on Frame": "",
"Install on this PC": "",
"Install Proton on the Frame": "",
@@ -570,6 +581,7 @@
"Installs the app as it is, unpacked on the Frame, with an entry in its Steam library": "",
"Installs the app as it is, with an entry in the Frame's Steam library": "",
"Installs the game exactly as it is: no patches (your copy is already patched)": "",
"Internal storage": "",
"into {value}": "",
"It belongs to a running game, which may stop with it.": "",
"It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort continues by itself, and uploads pick up where they stopped.": "",
@@ -582,6 +594,9 @@
"It will be closed without saving. Continue?": "",
"It won't start on the Frame: SteamOS doesn't have these libraries and the app doesn't bring them. Look for a build of the app that includes them, then install that one.": "",
"It works: share…": "",
"it's here now": "",
"its drive": "",
"Its manifest asks for Android {version} (API {api}); the Frame's Android is 11 (API 30). Apps like this often crash at start, but not always: try it, a launch test tells.": "",
"Java runtime": "",
"Java, OVRPort and apksigner are ready.": "",
"just now": "",
@@ -651,6 +666,7 @@
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
"Logs every Meta Platform SDK request the game makes (user, entitlement, cloud saves, ...) and the messages that answer them, and every 10 s the requests that never got an answer (logcat tag fp_ovrtrace, in launch.log). For games that wait forever on a loading screen (e.g. Vader Immortal after its intro). Changes nothing: every call goes to the real function.": "",
"Longer for headsets that briefly think they're off your head.": "",
"Looking for drives…": "",
"Looking for Frames on your network…": "",
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this computer).": "",
"Looking for your Frame…": "",
@@ -686,6 +702,10 @@
"Monitor": "",
"Monitor is using {pct} % of the Frame's CPU": "",
"More actions": "",
"Move": "",
"Move to…": "",
"Move {title} to {label}": "",
"Move {title} to…": "",
"Moves where the pointer starts, forward or back.": "",
"Moves where the pointing ray starts forward (+) or back (−).": "",
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.": "",
@@ -702,6 +722,7 @@
"New folder": "",
"New folder…": "",
"New Frame? Run one command on it and FramePort does the rest. Did this once already? Your Frame appears under step 2.": "",
"New games go to {label}": "",
"New name": "",
"Next": "",
"No": "",
@@ -734,6 +755,8 @@
"Not enough space on the Frame ({detail}). Remove games you don't play or use Free up space on the Steam Frame page.": "",
"Not from a FramePort game": "",
"Not installed": "",
"Not needed in the last build, OVRPort fixed it itself: {names}": "",
"Not needed in the last build: OVRPort fixed this itself.": "",
"Not now": "",
"Not offered by Steam on this Frame yet": "",
"Not recommended: breaks games that load VrApi through OVRPlugin.": "",
@@ -748,6 +771,7 @@
"Nothing running. Installs, launch tests and downloads show up here.": "",
"Nothing to install": "",
"Nothing to install on the Frame — those Oculus games need PC mode (SteamVR + Revive).": "",
"Nothing to install: add the games' .obb files first": "",
"Nothing to patch: it runs as is.": "",
"Now {scale:.0%}.": "",
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
@@ -773,6 +797,7 @@
"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 frameport:// links in FramePort": "",
@@ -908,6 +933,7 @@
"Remove VrApi library": "",
"Remove XR_KHR_composition_layer_depth chains (diagnostic).": "",
"Remove {title} from the library?": "",
"Removed from Desktop Mode": "",
"Removed the converted copies ({size})": "",
"Removed {title}": "",
"Removed {title} and deleted its files on this PC ({size})": "",
@@ -1177,6 +1203,7 @@
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
"These games can't run on the Steam Frame": "",
"These games check their Oculus license": "",
"These games keep their content in a data file (.obb), and none was found next to their APK. Without it they hang at start. Put the .obb files in a folder named like the game's package (or obb/) next to the APK and add the folder again. Tick any you still want to install.": "",
"These installs need about {need} on the Frame, and it has {free} free. Remove games you don't play (or use Free up space on the Steam Frame page), or install fewer at once.": "",
"These links open FramePort.": "",
"These links will open FramePort instead of {app}. You can switch back in {app}, or by turning this setting off.": "",
@@ -1185,12 +1212,14 @@
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
"This app looks like a website in an Android wrapper: it opens the site in Meta's browser, which the Frame doesn't have. If nothing opens, use the website in a browser instead.": "",
"This computer's keyboard works as a keyboard on your Frame while this tab is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
"This deletes the {what} on the Frame. It can't be undone.": "",
"This folder is empty. Upload files with the buttons above.": "",
"This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup command.": "",
"This game can't run on the Steam Frame.": "",
"This game is marked \"Can't run\" on the Steam Frame:": "",
"This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named {package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.": "",
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
"this PC": "",
"This PC (for PC VR games)": "",
@@ -1341,6 +1370,7 @@
"What your Frame is doing right now": "",
"What's new": "",
"Where it's installed": "",
"Where new games go on the Frame: its internal storage or another drive, such as a microSD card (in a FramePort folder on it). Games you installed already stay where they are; move one with right-click → Move to…. The launcher and Steam entry always stay on internal storage. Drives formatted as FAT, exFAT or NTFS can't hold games (Android and Proton need Linux file permissions): format the card in SteamOS first.": "",
"Where the power goes": "",
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
"Which program starts {get}?": "",
@@ -1402,6 +1432,7 @@
"{human} · {files} files": "",
"{int} h ago": "",
"{int} min ago": "",
"{label} (not inserted)": "",
"{label} ({host}) isn't reachable. Make sure it's switched on and on the same network.": "",
"{label} removed for {title}.": "",
"{label} updated for {title}.": "",
@@ -1477,6 +1508,7 @@
"{on} on your Frame": "",
"{rate}/s": "",
"{remaining} more after this": "",
"{size:.1f} GiB free": "",
"{title} couldn't be added to the Frame's Steam library: {why}": "",
"{title} ended.": "",
"{title} failed: {error}": "",
@@ -1484,6 +1516,7 @@
"{title} is already starting — put the headset on": "",
"{title} is still running.": "",
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
"{title} moved to {label}": "",
"{title} will be in the Frame's Steam library when the game that's running is closed": "",
"{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it from there": "",
"{title}: analyzed again": "",
@@ -0,0 +1,63 @@
"""Detects OVRPort's fix for the haptic-envelope bug, which makes `adapter.haptic_fix` unnecessary.
OVRPort's dispatcher (libopenxr_loader.so) turns XrHapticAmplitudeEnvelopeVibrationFB into 100 Hz samples. Runtimes
up to 3.4.3-23204ea used the envelope's duration (nanoseconds) as seconds when counting them: one vibration asked for
gigabytes (Lucky's Tale froze the Frame, BattleSisters was killed for 11 GB). Runtime 3.4.3-aa54c3f converts it first
(ovrport/app#73): right after the type check for the envelope (XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB =
1000173001) it builds the constant 1e9 (`count * 1e9 / duration` = samples per second), which the buggy code never
had. Verified in the headset with Lucky's Tale and haptic_fix off (2026-10-07). See patches/upstream.py.
"""
from __future__ import annotations
import struct
from ..upstream import UpstreamFix, code_words, constants, find_constant_load, lib_probe, register
DISPATCHER = "libopenxr_loader.so"
ENVELOPE_TYPE = 1000173001 # XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB
WINDOW = 48 # instructions after the type check that hold the sample count (the fix: 7 after it)
NS_PER_S = {struct.unpack("<Q", struct.pack("<d", v))[0] for v in (1e9, 1e-9)} | \
{struct.unpack("<I", struct.pack("<f", v))[0] for v in (1e9, 1e-9)}
def _literals(data: bytes, offsets: list[int], words: list[int]) -> list[int]:
"""Values loaded PC-relative (LDR literal: GP w/x, SIMD s/d) by the given instructions."""
out = []
for off, w in zip(offsets, words, strict=True):
if w & 0x3B000000 != 0x18000000: # LDR (literal), GP (V=0) or SIMD (V=1)
continue
size = {0: 4, 1: 8}.get(w >> 30) # opc: w / s = 4 bytes, x / d = 8 (q isn't a scalar constant)
if size is None:
continue
imm19 = (w >> 5) & 0x7FFFF
target = off + ((imm19 - (1 << 19)) if imm19 & (1 << 18) else imm19) * 4
if 0 <= target <= len(data) - size:
out.append(int.from_bytes(data[target:target + size], "little"))
return out
def envelope_duration_fixed(data: bytes) -> bool | None:
"""True when the dispatcher converts the envelope duration from nanoseconds (OVRPort's fix), False when its
envelope code has no such conversion (the bug), None when there's no envelope code to judge."""
code = list(code_words(data))
offsets, words = [o for o, _ in code], [w for _, w in code]
seen = False
start = 0
while (i := find_constant_load(words[start:], ENVELOPE_TYPE)) is not None:
i += start
seen = True
window = slice(i, i + WINDOW)
if NS_PER_S & set(constants(words[window])) or NS_PER_S & set(_literals(data, offsets[window], words[window])):
return True
start = i + 1
return False if seen else None
register(UpstreamFix(
id="ovrport.haptic_envelope",
workaround="adapter.haptic_fix",
title="OVRPort converts the vibration envelope's duration from nanoseconds",
upstream="ovrport/app#73 (runtime 3.4.3-aa54c3f)",
probe=lib_probe(DISPATCHER, envelope_duration_fixed),
tracker="#74",
))
+40 -7
View File
@@ -7,12 +7,16 @@ Walking Dead: Saints & Sinners Ch. 2) dereferences a NULL stream: SIGSEGV in lib
seconds after the logo. Meta's own library allows that order. The function is rewritten in place (same size) to return
0 while no stream is open and behave as before otherwise.
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73 (drop this patch once fixed upstream).
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73. OVRPort runtime 3.4.3-aa54c3f checks
the handle and the stream itself (returns 48000 without one): builds made with it leave this patch out
(`microphone_checked`, patches/upstream.py).
"""
from __future__ import annotations
from ...analysis import elf
from ..base import ApkContext, Patch, Suggestion, register
from ..upstream import UpstreamFix, lib_probe
from ..upstream import register as register_upstream_fix
LOADER = "libovrplatformloader.so"
FUNC = "ovr_Microphone_GetOutputBufferMaxSize"
@@ -31,9 +35,8 @@ def _words(data: bytes | bytearray, off: int, n: int) -> list[int]:
return [int.from_bytes(data[off + 4 * i:off + 4 * i + 4], "little") for i in range(n)]
def guard_microphone(data: bytes) -> bytes | None:
"""The loader with a NULL-stream check in ovr_Microphone_GetOutputBufferMaxSize, or None if the function isn't
OVRPort's unchecked 7-instruction version (already fixed, Meta's library, another OVRPort build)."""
def _function_offset(data: bytes, size: int) -> int | None:
"""File offset of FUNC in the loader (None: not an ELF, no such export, or shorter than `size` bytes)."""
try:
dynsym = elf._elf(data).get_section_by_name(".dynsym")
except Exception: # noqa: BLE001
@@ -41,9 +44,31 @@ def guard_microphone(data: bytes) -> bytes | None:
found = dynsym.get_symbol_by_name(FUNC) if dynsym is not None else None
if not found or not found[0]["st_value"]:
return None
vaddr = found[0]["st_value"]
off = elf.vaddr_to_offset(elf.load_segments(data), vaddr)
if off is None or off + 28 > len(data):
off = elf.vaddr_to_offset(elf.load_segments(data), found[0]["st_value"])
return off if off is not None and off + size <= len(data) else None
def microphone_checked(data: bytes) -> bool | None:
"""True when OVRPort's GetOutputBufferMaxSize tests something (a cbz/cbnz) before its first call, as the fixed
runtime does; False when it is the unchecked version this patch rewrites; None when it can't tell."""
if guard_microphone(data) is not None:
return False
off = _function_offset(data, 48)
if off is None:
return None
for word in _words(data, off, 12):
if word & BL_MASK == BL:
return None
if word & 0x7E000000 == 0x34000000: # CBZ / CBNZ (32 or 64 bit)
return True
return None
def guard_microphone(data: bytes) -> bytes | None:
"""The loader with a NULL-stream check in ovr_Microphone_GetOutputBufferMaxSize, or None if the function isn't
OVRPort's unchecked 7-instruction version (already fixed, Meta's library, another OVRPort build)."""
off = _function_offset(data, 28)
if off is None:
return None
w = _words(data, off, 7)
if w[:3] != [STP, MOV_FP, LDR_STREAM] or w[3] & BL_MASK != BL or w[4:] != [SXTW, LDP, RET]:
@@ -88,3 +113,11 @@ class OvrMicrophone(Patch):
register(OvrMicrophone)
register_upstream_fix(UpstreamFix(
id="ovrport.microphone_stream",
workaround=OvrMicrophone.id,
title="OVRPort checks for an unopened microphone stream",
upstream="ovrport/app#73 (runtime 3.4.3-aa54c3f)",
probe=lib_probe(LOADER, microphone_checked),
tracker="#73",
))
+152
View File
@@ -0,0 +1,152 @@
"""Upstream fixes that make one of our workarounds unnecessary, applied per build.
Several patches work around bugs in what OVRPort puts into a converted APK (its closed-source runtime libraries, its
CLI). When upstream fixes such a bug, users don't all get the fix at once: OVRPort downloads runtime builds into its
workspace and each conversion uses whichever one it picks, so one library can hold games built with the old and the
new runtime. A version number doesn't help either (runtime builds are named `<version>-<commit>`, the commit part is
unordered). So each fix is detected in the converted APK itself: a probe looks at the bytes OVRPort produced, and when
it finds the upstream fix, the build leaves the workaround out.
The recipe is never changed: it still asks for the fix (`adapter.haptic_fix` on), the build decides how to provide it.
A build made with an old runtime keeps the workaround; the next build with a fixed runtime drops it by itself, and a
build made with a later runtime that regressed would get the workaround back. Builds record what was left out
(`build.superseded`), the installer leaves those out of settings.conf too, and the build log says why.
Add one: write a probe (True = the upstream fix is there, False = the bug is there, None = can't tell, e.g. the library
is missing or looks different) and `register(UpstreamFix(...))` in the workaround's own module. Only True drops the
workaround, so a probe that can't recognise newer code errs on the safe side. Then update the workaround's tracker
issue (`upstream` label). FRAMEPORT_KEEP_WORKAROUNDS=1 keeps every workaround (to compare builds).
"""
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..apk.workspace import ApkWorkspace
from ..core.events import Reporter
Probe = Callable[["ApkWorkspace"], "bool | None"]
@dataclass(frozen=True)
class UpstreamFix:
id: str # e.g. "ovrport.haptic_envelope"
workaround: str # the patch id this fix makes unnecessary, e.g. "adapter.haptic_fix"
title: str # what upstream fixed, for the build log
upstream: str # where it was fixed, e.g. "ovrport/app#73 (runtime 3.4.3-aa54c3f)"
probe: Probe
tracker: str = "" # our tracking issue, e.g. "#74"
REGISTRY: dict[str, UpstreamFix] = {}
def register(fix: UpstreamFix) -> UpstreamFix:
if not fix.id or fix.id in REGISTRY:
raise ValueError(f"bad or duplicate upstream fix id {fix.id!r}")
REGISTRY[fix.id] = fix
return fix
def lib_probe(library: str, check: Callable[[bytes], bool | None]) -> Probe:
"""A probe that runs `check` on one of the APK's native libraries (None when the APK doesn't have it)."""
def probe(ws: ApkWorkspace) -> bool | None:
return check(ws.read_lib(library)) if ws.abi and ws.has(ws.lib(library)) else None
return probe
@dataclass
class Resolution:
patches: dict # the selection this build applies
superseded: dict[str, str] = field(default_factory=dict) # workaround patch id -> upstream fix id
found: dict[str, bool | None] = field(default_factory=dict) # upstream fix id -> probe result (for the log)
def resolve(ws: ApkWorkspace, patches: dict, reporter: Reporter | None = None) -> Resolution:
"""Leave out the workarounds whose upstream fix is in this (converted) APK."""
from . import base
base.load_all()
res = Resolution(dict(patches))
if os.environ.get("FRAMEPORT_KEEP_WORKAROUNDS"):
return res
for fix in REGISTRY.values():
if fix.workaround not in patches:
continue
try:
found = fix.probe(ws)
except Exception as e: # noqa: BLE001 (a probe that breaks must not break the build: keep the workaround)
found = None
if reporter:
reporter.log(f"upstream fix check {fix.id} failed ({e}); keeping {fix.workaround}")
res.found[fix.id] = found
if found:
res.patches.pop(fix.workaround, None)
res.superseded[fix.workaround] = fix.id
if reporter:
reporter.log(f"not needed: {fix.workaround} ({fix.title}, fixed upstream in {fix.upstream})")
return res
def without_superseded(patches: dict, superseded) -> dict:
"""A recipe's selection minus the workarounds a build left out (for the install of that build)."""
return {pid: params for pid, params in patches.items() if pid not in (superseded or ())}
# ---------------------------------------------------------------- arm64 helpers for probes
def code_words(data: bytes):
"""(file offset, instruction word) for every word of the library's executable sections."""
from ..analysis import elf
for sec in elf._elf(data).iter_sections():
if sec["sh_type"] == "SHT_PROGBITS" and sec["sh_flags"] & 0x4: # SHF_EXECINSTR
start, size = sec["sh_offset"], sec["sh_size"]
for off in range(start, start + size - 3, 4):
yield off, int.from_bytes(data[off:off + 4], "little")
def move_wide(word: int) -> tuple[str, int, int, int, int] | None:
"""Decode MOVZ/MOVK/MOVN: (kind, sf, rd, imm16, shift) or None."""
kinds = {0x52800000: "movz", 0x72800000: "movk", 0x12800000: "movn"}
kind = kinds.get(word & 0x7F800000)
if kind is None:
return None
return kind, word >> 31, word & 31, (word >> 5) & 0xFFFF, ((word >> 21) & 3) * 16
def constants(words) -> list[int]:
"""Every value a register holds after each MOVZ/MOVK/MOVN in a run of instruction words (how compilers build
64-bit constants such as doubles: movz + movk)."""
regs: dict[int, int] = {}
out = []
for w in words:
mw = move_wide(w)
if mw is None:
continue
kind, sf, rd, imm, shift = mw
mask = (1 << 64) - 1 if sf else (1 << 32) - 1
if kind == "movz":
regs[rd] = imm << shift
elif kind == "movn":
regs[rd] = ~(imm << shift) & mask
else:
regs[rd] = (regs.get(rd, 0) & ~(0xFFFF << shift) | imm << shift) & mask
out.append(regs[rd])
return out
def find_constant_load(words: list[int], value: int) -> int | None:
"""Index of the first MOVZ/MOVN that starts building `value` (32-bit) in its register, followed by the MOVK that
completes it within the next few instructions."""
for i, w in enumerate(words):
mw = move_wide(w)
if mw is None or mw[0] == "movk":
continue
if value in constants(words[i:i + 4]):
return i
return None
+36 -2
View File
@@ -9,6 +9,7 @@ PC (Revive + local Steam) or on the Frame (Proton + Revive).
"""
from __future__ import annotations
import dataclasses
import threading
import time
from pathlib import Path
@@ -21,6 +22,7 @@ from .core import library
from .core.events import Reporter
from .core.models import Recipe, SourceGame
from .core.paths import output_dir
from .patches import upstream
from .recommend import engine
from .sources import quest_dump, rift_dump
from .targets.base import Target
@@ -284,6 +286,30 @@ def is_linux(entry: dict) -> bool:
return entry.get("kind") == "linux"
def analysis_warnings(entry: dict) -> list[str]:
"""Blockers read from a Quest/Android game's APK, for the CLI (the game page shows them as callouts)."""
if is_rift(entry) or is_linux(entry) or not entry.get("analysis"):
return []
out = engine.blocker_notes(library.analysis_from_dict(entry["analysis"]))
if missing_obb(entry):
out.append(MISSING_OBB_NOTE.format(package=entry.get("package", "<package>")))
return out
MISSING_OBB_NOTE = ("This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named "
"{package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.")
def missing_obb(entry: dict) -> bool:
"""A Quest game whose APK expects an OBB (analysis expects_obb: Unreal's bHasOBBFiles) but no data folder was
found next to it (GitHub #85: TRIANGLE STRATEGY hung silently after OVRPlugin's JNI_OnLoad). Library fields
only: no disk access (used by the game page)."""
if is_rift(entry) or is_linux(entry):
return False
extra = (entry.get("analysis") or {}).get("extra") or {}
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:
"""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)."""
@@ -426,7 +452,8 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
extra = (entry.get("analysis") or {}).get("extra") or {}
result = target.install_linux(package, steam_title(entry), Path(entry["game_dir"]), entry["exe"],
extra.get("files"), bool(extra.get("appimage")), bool(extra.get("openxr")),
reporter, x86_64=bool(extra.get("x86_64")))
reporter, x86_64=bool(extra.get("x86_64")),
desktop_entry=entry.get("desktop_entry", True) is not False)
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)")
@@ -616,7 +643,8 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
art, store_title = artwork.fetch(package, res.apk)
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"])}
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"]),
"superseded": res.meta.get("superseded") or {}}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
return build_info
@@ -639,6 +667,8 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
build_game(package, reporter)
return install_game(package, target, reporter, apk_only, add_to_library)
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
title = steam_title(entry)
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
@@ -800,6 +830,10 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
def test_game(package: str, target: Target, reporter: Reporter, seconds: int = 45) -> dict:
result, log = target.launch_test(package, reporter, seconds)
if missing_obb(library.game(package) or {}):
from .validate.triage import add_missing_obb
add_missing_obb(result)
from .core.paths import user_data_dir
logs = user_data_dir() / "logs"
+34
View File
@@ -80,6 +80,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
recipe.status = "unsupported"
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
if not rift:
_static_blockers(analysis, recipe)
_non_quest(analysis, recipe)
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
@@ -177,6 +178,39 @@ def warnings(recipe: Recipe) -> list[str]:
return out
def android_version_note(analysis: Analysis) -> str | None:
"""'Its manifest asks for Android 14 (API 34)…' when the APK's minimum Android is newer than the Frame's."""
from ..analysis.detect import FRAME_API, android_version, too_new_android
min_sdk = (analysis.extra or {}).get("min_sdk")
if not too_new_android(min_sdk):
return None
return (f"Its manifest asks for Android {android_version(min_sdk)} (API {min_sdk}); the Frame's Android is "
f"{android_version(FRAME_API)} (API {FRAME_API}). Apps like this often crash at start on newer Android "
"parts, but not always: a launch test tells.")
def web_wrapper_note(analysis: Analysis) -> str | None:
"""A Trusted Web Activity: the APK only opens a website in Meta's browser (analysis.detect.web_wrapper)."""
ww = (analysis.extra or {}).get("web_wrapper")
if not ww:
return None
where = f"open {ww['url']} in a browser instead" if ww.get("url") else "open the website in a browser instead"
return (f"This app looks like a website in an Android wrapper (Trusted Web Activity): it opens the site in "
f"Meta's browser, which the Frame doesn't have. If nothing opens, {where}.")
def blocker_notes(analysis: Analysis) -> list[str]:
"""Warnings read from the APK's manifest that it may not run on the Frame (whatever the recipe says). Only
warnings: the manifest alone never marks a game unsupported (owner's rule); a launch test's triage decides."""
return [n for n in (android_version_note(analysis), web_wrapper_note(analysis)) if n]
def _static_blockers(analysis: Analysis, recipe: Recipe) -> None:
for note in blocker_notes(analysis):
recipe.notes = _add(recipe.notes, note)
def _non_quest(analysis: Analysis, recipe: Recipe) -> None:
"""Android apps that aren't Meta Quest apps (analysis.detect.vr_kind)."""
kind = analysis.vr_kind
+89 -8
View File
@@ -3,7 +3,10 @@
Accepted layouts:
<folder>/<something>.apk [+ <folder>/<package>/ (OBB or raw asset data)] e.g. common downloader layouts
<folder>/<package>.apk + <folder>/obb/ FramePort/PATCHED output layout
<folder>/x.apk + <folder>/obb/<package>/ or Android/obb/<package>/ backups with an obb folder
<game>/apk/x.apk + <game>/obb/<package>/ SideQuest-style backups (one game)
a single .apk file
(find_data_dir has the details.)
"""
from __future__ import annotations
@@ -39,11 +42,91 @@ def _is_apk(path: Path) -> bool:
return False
OBB_FOLDERS = ("obb", "obbs")
def _subdirs(d: Path) -> list[Path]:
try:
return sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith(("_", ".")))
except OSError:
return []
def _child(d: Path, name: str) -> Path | None:
"""d/name, matching the name's case loosely (backups come from Windows: 'OBB', a lower-case package folder)."""
exact = d / name
if exact.is_dir():
return exact
return next((p for p in _subdirs(d) if p.name.lower() == name.lower()), None)
def _nonempty(d: Path | None) -> bool:
try:
return bool(d) and d.is_dir() and any(d.iterdir())
except OSError:
return False
def _has_obb(d: Path) -> bool:
try:
return any(p.suffix.lower() == ".obb" and p.is_file() for p in d.iterdir())
except OSError:
return False
def _package_dir_below(d: Path, pkg: str, depth: int, skip: Path | None = None, other_games: bool = False) \
-> Path | None:
"""A folder named like the package holding .obb files, at most `depth` levels below d. other_games: skip
folders with APKs of their own (a neighbouring game folder, e.g. another version of the same game)."""
for sub in _subdirs(d):
if skip is not None and sub == skip:
continue
if sub.name.lower() == pkg.lower():
if _has_obb(sub):
return sub
continue
if depth > 1 and not (other_games and any(sub.glob("*.apk"))):
found = _package_dir_below(sub, pkg, depth - 1)
if found:
return found
return None
def find_data_dir(apk_dir: Path, pkg: str | None) -> Path | None:
"""The folder whose contents go to Android/obb/<package>/ (so the folder holding the .obb files themselves):
<apk dir>/<package>/ downloader layouts
<apk dir>/obb/<package>/ or <apk dir>/obb/ FramePort/PATCHED output, backups with an obb folder
a <package> folder with .obb files up to 3 levels below the APK's folder (e.g. Android/obb/<package>/) or 2
below its parent (SideQuest-style backups: <game>/apk/x.apk + <game>/obb/<package>/; the parent's folders
with APKs of their own are other games and are skipped)."""
own = _child(apk_dir, pkg) if pkg else None
if _nonempty(own):
return own
for obb in (_child(apk_dir, name) for name in OBB_FOLDERS):
if obb is None:
continue
inner = _child(obb, pkg) if pkg else None
if _nonempty(inner):
return inner
if _nonempty(obb):
return obb
if not pkg:
return None
found = _package_dir_below(apk_dir, pkg, 3)
if found is None and apk_dir.parent != apk_dir:
found = _package_dir_below(apk_dir.parent, pkg, 2, skip=apk_dir, other_games=True)
return found
# a folder that only holds the APK inside a game folder (<game>/apk/x.apk + <game>/obb/…): the game is the parent
APK_FOLDERS = ("apk", "apks")
def from_path(path: Path) -> SourceGame | None:
path = Path(path)
if path.is_file() and path.suffix.lower() == ".apk":
pkg = _package_of(path)
data = next((d for d in (path.parent / (pkg or ""), path.parent / "obb") if pkg and d.is_dir()), None)
data = find_data_dir(path.parent, pkg) if pkg else None
return SourceGame(display_name(path.stem), path, data, path.parent)
if not path.is_dir():
return None
@@ -53,12 +136,9 @@ def from_path(path: Path) -> SourceGame | None:
primary = [a for a in apks if ".alt-" not in a.name]
apk = primary[0] if primary else apks[0]
pkg = _package_of(apk)
data = None
for cand in ((path / pkg) if pkg else None, path / "obb"):
if cand and cand.is_dir() and any(cand.iterdir()):
data = cand
break
return SourceGame(display_name(path.name), apk, data, path, [a for a in apks if a != apk])
data = find_data_dir(path, pkg)
game_dir = path.parent if path.name.lower() in APK_FOLDERS else path
return SourceGame(display_name(game_dir.name), apk, data, game_dir, [a for a in apks if a != apk])
def _no_quest_games_below(d: Path) -> bool:
@@ -77,7 +157,8 @@ def _other_folders(d: Path, game: SourceGame) -> list[Path]:
except OSError:
return []
own = {game.data_dir.resolve()} if game.data_dir else set()
return [p for p in subs if p.resolve() not in own and p.name.lower() not in ("obb", "android")]
own |= set(game.data_dir.resolve().parents) if game.data_dir else set() # e.g. Android/ of Android/obb/<pkg>
return [p for p in subs if p.resolve() not in own and p.name.lower() not in (*OBB_FOLDERS, "android")]
def scan(root: Path, depth: int = 5) -> list[SourceGame]:
+2 -1
View File
@@ -33,7 +33,8 @@ class Target(ABC):
"""Start an installed game for playing, through the target's Steam (Steam library shortcut)."""
raise NotImplementedError(f"{self.label} can't launch games")
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False) -> dict:
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True) -> 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")
+25 -5
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from ..core.events import Reporter
from ..core.models import Recipe
from ..frame.connection import Frame, FrameTarget, sh_quote
from ..install import installer
from ..install import drives, installer
from ..validate import device
from .base import Target
@@ -19,6 +19,12 @@ class FrameLeptonTarget(Target):
self.target = target
self.frame = Frame(target, password)
self.label = target.label
self.dest: str | None = None # where new games go; None = the library setting (drives.install_dest)
def install_dest(self) -> str | None:
"""A drive's install dir for new installs (GitHub #90), None = internal storage. Reinstalls stay where the
game is (the agent keeps an installed game's folder)."""
return self.dest if self.dest is not None else drives.install_dest()
def connect(self) -> FrameLeptonTarget:
if self.frame.client is not None and not self.frame.alive():
@@ -34,7 +40,7 @@ class FrameLeptonTarget(Target):
return self.connect().frame.agent("list_installed")["games"]
def install(self, package, title, apk: Path, data_dir, recipe: Recipe, reporter: Reporter, apk_only=False):
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only)
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only, dest=self.install_dest())
return installer.install(self.connect().frame, plan, reporter)
def add_to_library(self, packages, reporter):
@@ -60,13 +66,27 @@ 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"))
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
dest=self.install_dest())
return installer.install_pcvr(self.connect().frame, plan, reporter)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
dest=self.install_dest(), desktop_entry=desktop_entry)
return installer.install_linux(self.connect().frame, plan, reporter)
def drives(self) -> list[dict]:
return drives.list_drives(self.connect().frame)
def move(self, package: str, dest: str, reporter: Reporter) -> dict:
"""Move an installed game's files to another drive (or back to internal storage: dest "internal")."""
return drives.move(self.connect().frame, package, dest, reporter)
def set_desktop_entry(self, package: str, enabled: bool) -> dict:
"""A Linux app's Desktop Mode entry on or off (GitHub #84)."""
return self.connect().frame.agent("desktop_entry", package=package, enabled=enabled)
def proton_status(self, tool: str | None = None) -> dict:
return self.connect().frame.agent("proton_status", tool=tool)
+101 -3
View File
@@ -818,6 +818,8 @@ class FramePortApp:
C.install_state(g, self.frame_info) in ("installed", "outdated"):
out.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED,
lambda e: self.update_steam_art(pkg)))
if C.frame_drive(g, self.frame_info) is not None: # agent v63+: games can live on a microSD card
out.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_OUTLINED, lambda e: self.move_game(pkg)))
out.append((tr("Refresh store details"), ft.Icons.SYNC_ROUNDED, lambda e: self.refresh_details(pkg)))
if not job and not linux:
out.append((tr("Rebuild only (no install)") if not rift else tr("Check game files"), ft.Icons.BUILD_ROUNDED,
@@ -914,11 +916,11 @@ class FramePortApp:
def ask_frame_oculus():
# Installing an Oculus/LibOVR Rift game on the Frame: warn that it needs Revive (which can't run there)
if to != "frame":
return ask_license()
return ask_obb()
oculus = [g for g in games if g.get("kind") == "rift"
and "pcvr.revive" in (g.get("recipe") or {}).get("patches", {})]
if not oculus:
return ask_license()
return ask_obb()
boxes = {g["package"]: ft.Checkbox(label=self._title(g["package"]), value=False, active_color=T.ACCENT)
for g in oculus}
pick = C.one_choice()
@@ -933,7 +935,7 @@ class FramePortApp:
"those Oculus games need PC mode (SteamVR + Revive)."))
finished()
return
ask_license()
ask_obb()
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("These games can't run on the Steam Frame"), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([
@@ -947,6 +949,39 @@ class FramePortApp:
actions=[C.ghost(tr("Cancel"), on_click=pick(cancel)),
C.primary(tr("Continue"), on_click=pick(ok))]))
def ask_obb():
# the game expects a data file (.obb) that wasn't found next to its APK: it would hang at start (#85)
missing = [g for g in games if pipeline.missing_obb(g)]
if not missing:
return ask_license()
boxes = {g["package"]: ft.Checkbox(label=self._title(g["package"]), value=False, active_color=T.ACCENT)
for g in missing}
pick = C.one_choice()
def ok(e):
nonlocal games
self.page.pop_dialog()
keep = {p for p, b in boxes.items() if b.value}
games = [g for g in games if g not in missing or g["package"] in keep]
if not games:
self.toast(tr("Nothing to install: add the games' .obb files first"))
finished()
return
ask_license()
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Game data (.obb) not found"), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([
C.body(tr("These games keep their content in a data file (.obb), and none was found next to "
"their APK. Without it they hang at start. Put the .obb files in a folder named like "
"the game's package (or obb/) next to the APK and add the folder again. Tick any you "
"still want to install.")),
*boxes.values()], spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO), width=T.px(520),
height=T.px(min(150 + 36 * len(boxes), 480))), # fits the list; scrolls when long
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
modal=True, on_dismiss=pick(closed),
actions=[C.ghost(tr("Cancel"), on_click=pick(cancel)),
C.primary(tr("Continue"), on_click=pick(ok))]))
def ask_license():
from ..core import winhost
@@ -1235,6 +1270,69 @@ 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")
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)."""
from ..install import drives
title = self._title(pkg)
here = C.frame_drive(library.game(pkg) or {"package": pkg}, self.frame_info)
group = ft.RadioGroup(content=ft.Column([C.meta(tr("Looking for drives…"))], spacing=T.S2))
go = C.primary(tr("Move"), ft.Icons.DRIVE_FILE_MOVE_OUTLINED, disabled=True)
found: dict[str, dict] = {}
def picked(e):
go.disabled = not group.value
C.update(go)
group.on_change = picked
def fill():
try:
listed = self._target_for("frame").drives()
except Exception as exc: # noqa: BLE001
group.content.controls = [C.meta(tr("Couldn't list the Frame's drives: {error}")
.format(error=explain(exc)))]
C.update(group)
return
rows = []
for d in listed:
key = drives.INTERNAL if d["internal"] else d["install_dir"]
current = drives.is_on({"drive": here}, d) if here else d["internal"]
found[key] = d
note = tr("it's here now") if current else \
(d.get("reason") or "") if not d.get("usable") else drives.free_text(d)
rows.append(ft.Row([
ft.Radio(value=key, active_color=T.ACCENT, disabled=current or not d.get("usable")),
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_ROUNDED,
size=T.px(18), color=T.TEXT_2),
ft.Column([C.body(tr("Internal storage") if d["internal"] else d["label"], T.TEXT),
C.meta(note)], spacing=T.px(2), expand=True),
], spacing=T.S2))
group.content.controls = rows or [C.meta(tr("Only internal storage"))]
C.update(group)
def start(e):
dest = group.value
if not dest:
return
self.page.pop_dialog()
label = tr("Internal storage") if dest == drives.INTERNAL else found.get(dest, {}).get("label", dest)
def run(job: Job):
self._target_for("frame").move(pkg, dest, job.reporter)
return tr("{title} moved to {label}").format(title=title, label=label)
self.submit(tr("Move {title} to {label}").format(title=title, label=label), run, pkg, "tool-frame")
go.on_click = start
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Move {title} to…").format(title=title), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([C.body(C.HELP["move_game"]), group], spacing=T.S3, tight=True),
width=T.px(460)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()), go]))
self.run_bg(fill)
def build_game(self, pkg: str) -> Job:
def run(job: Job):
info = pipeline.build_game(pkg, job.reporter)
+19 -1
View File
@@ -29,6 +29,22 @@ def _deployment(game: dict, frame_info: dict | None) -> dict | None:
return next((d for d in (frame_info or {}).get("installed", []) if d.get("package") == game.get("package")), None)
def frame_drive(game: dict, frame_info: dict | None) -> dict | None:
"""The drive the game is installed on ({internal, path, label, missing}), None if not installed / old agent."""
from ..install.drives import game_drive
return game_drive(_deployment(game, frame_info))
def drive_note(drive: dict | None) -> str:
"""" · on SD Card" / " · SD Card not inserted" for a game on another drive, "" on internal storage."""
if not drive or drive.get("internal"):
return ""
if drive.get("missing"):
return tr(" · {label} not inserted").format(label=drive.get("label") or tr("its drive"))
return tr(" · on {label}").format(label=drive.get("label") or "")
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
unknown: installs from before FramePort recorded their recipe)."""
@@ -87,7 +103,9 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
if frame_info is None:
return None
dep = _deployment(game, frame_info)
if not dep or dep.get("apk_present") is False: # no record, or the game files are gone (half uninstalled)
# no record, or the game files are gone (half uninstalled); a game on a microSD card that isn't inserted now is
# still installed (GitHub #90)
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
b = game.get("build") or {}
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
+11
View File
@@ -129,6 +129,17 @@ HELP: dict[str, str] = _Translated({
"Storage = 4XPlayer): upload into that folder in the game's storage.",
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
"games and saves stay.",
"install_drive": "Where new games go on the Frame: its internal storage or another drive, such as a microSD card "
"(in a FramePort folder on it). Games you installed already stay where they are; move one with "
"right-click → Move to…. The launcher and Steam entry always stay on internal storage. Drives "
"formatted as FAT, exFAT or NTFS can't hold games (Android and Proton need Linux file "
"permissions): format the card in SteamOS first.",
"move_game": "Copies the game's files, data and saves to the other drive, checks the copy and then removes the "
"old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a "
"microSD card only starts while the card is inserted.",
"desktop_entry": "Adds the app to Desktop Mode's application menu and puts an icon on its desktop. Some apps work "
"better there, with a mouse and keyboard, than in Gaming Mode, where Steam Input turns the "
"controllers into a gamepad.",
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the "
"next time it starts.",
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
+65 -3
View File
@@ -7,7 +7,8 @@ from typing import TYPE_CHECKING
import flet as ft
from ...core import library
from ...i18n import tr
from ...errors import explain
from ...i18n import tr, tr_n
from .. import components as C
from .. import theme as T
from ..battery import label as battery_label
@@ -82,6 +83,64 @@ class FrameView:
return C.section(tr("Ready to play"), C.card(ft.Column(rows, spacing=0),
padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
def storage(self, info: dict) -> ft.Control:
"""Drives games can go on (GitHub #90) and where new games go. The list comes from the Frame in the
background."""
from ...install import drives
app = self.app
current = drives.install_dest()
pick = ft.Dropdown(label=tr("Install new games to"), value=current or drives.INTERNAL, width=T.px(380),
dense=True, border_color=T.BORDER, text_size=T.T_BODY, disabled=True,
options=[ft.DropdownOption(key=drives.INTERNAL, text=tr("Internal storage"))])
rows = ft.Column([C.meta(tr("Looking for drives…"))], spacing=T.px(6))
def choose(e):
value = e.control.value or drives.INTERNAL
drives.set_install_dest(None if value == drives.INTERNAL else value)
label = next((o.text for o in pick.options if o.key == value), value)
app.toast(tr("New games go to {label}").format(label=label))
pick.on_select = choose
def name(d: dict) -> str:
return tr("Internal storage") if d["internal"] else d["label"]
def fill():
try:
found = app.target.drives() if app.target else []
except Exception as exc: # noqa: BLE001 - shown in place of the list
rows.controls = [C.meta(tr("Couldn't list the Frame's drives: {error}").format(error=explain(exc)))]
C.update(rows)
return
opts, lines = [], []
for d in found:
free = drives.free_text(d)
if d.get("usable"):
opts.append(ft.DropdownOption(key=drives.INTERNAL if d["internal"] else d["install_dir"],
text=" · ".join(filter(None, [name(d), free]))))
state = tr_n("{n} game", "{n} games", d.get("games", 0)) if d.get("usable") else \
tr("can't hold games: {reason}").format(reason=d.get("reason") or "")
lines.append(ft.Row([
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_ROUNDED,
size=T.px(18), color=T.TEXT_2 if d.get("usable") else T.TEXT_3),
C.body(name(d), T.TEXT if d.get("usable") else T.TEXT_3, expand=True),
C.meta(" · ".join(filter(None, [d.get("fstype"), free, state]))),
], spacing=T.S2))
if current and not any(o.key == current for o in opts): # the chosen card isn't inserted now
label = current.rstrip("/").rsplit("/", 2)[-2] if current.rstrip("/").endswith("/FramePort") \
else current
opts.append(ft.DropdownOption(key=current, text=tr("{label} (not inserted)").format(label=label)))
pick.options = opts or pick.options
pick.disabled = len(opts) < 2
rows.controls = lines or [C.meta(tr("Only internal storage"))]
C.update(pick)
C.update(rows)
app.run_bg(fill)
return C.section(tr("Storage"), C.card(ft.Column([pick, rows], spacing=T.S3), padding=T.S4),
help="install_drive")
def installed(self, info: dict) -> ft.Control:
"""The list fills in the background (icon thumbnails may need creating the first time)."""
items = info.get("installed") or []
@@ -105,6 +164,7 @@ class FrameView:
return spacer
from ...artwork import thumbs
from ...install.drives import game_drive as drive_of
from .files_dialog import show_files_dialog
from .library import display_title, twins
@@ -121,7 +181,9 @@ class FrameView:
sub = (tr("PC VR · Proton") + (tr(" + Revive") if d.get("revive") else "")) if pcvr else \
(C.platform(games[pkg])[0] if in_lib else tr("Linux") if d.get("kind") == "linux" else tr("Quest"))
sub += (tr(" · {value:.1f} GiB").format(value=size / 2**30) if size >= 2**30
else tr(" · {value:.0f} MiB").format(value=size / 2**20))
else tr(" · {value:.0f} MiB").format(value=size / 2**20)) if size or not d.get("drive_missing") \
else ""
sub += C.drive_note(drive_of(d))
title = display_title(games[pkg], tw) if in_lib else (d.get("title") or pkg)
rows.append(ft.Container(ft.Row([
C.art_fill(art, radius=T.px(8), width=T.px(44), height=T.px(44)),
@@ -391,5 +453,5 @@ class FrameView:
return self.wizard()
return ft.Column([
app.top_bar(tr("Steam Frame"), tr("Your headset, what it's ready for and what's installed")),
self.device_card(info), self.readiness(info), self.installed(info),
self.device_card(info), self.readiness(info), self.storage(info), self.installed(info),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
+57 -2
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
import flet as ft
from ... import pipeline
from ...analysis.detect import android_version, too_new_android
from ...artwork import thumbs
from ...core import library
from ...i18n import tr, tr_n
@@ -168,6 +169,10 @@ class GameView:
rift_platform = self.rift and (g["analysis"].get("extra") or {}).get("platform_sdk")
frame_color = (T.TEXT_3 if (rift_oculus or rift_platform) and not frame_ok else
T.OK if st == "installed" else T.WARN if st == "outdated" else T.TEXT_3)
drive = C.frame_drive(g, app.frame_info) if frame_ok else None # a microSD card (GitHub #90)
frame_line += C.drive_note(drive)
if drive and drive.get("missing"):
frame_color = T.WARN
frame_sub = (tr("Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).")
if rift_oculus else
tr("Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't "
@@ -269,7 +274,32 @@ class GameView:
C.body(tr("The Frame's Steam library still shows the old artwork."), T.TEXT, expand=True),
C.secondary(tr("Update Steam art on Frame"), ft.Icons.IMAGE_OUTLINED,
lambda e: self.app.update_steam_art(pkg))]), "info", ft.Icons.IMAGE_OUTLINED))
if recipe.status == "unsupported":
min_sdk = extra.get("min_sdk")
too_new = not self.rift and not self.linux and too_new_android(min_sdk)
if too_new:
# whatever the recipe says: the manifest asks for a newer Android (a warning only, a launch test decides)
out.append(C.callout(tr("Its manifest asks for Android {version} (API {api}); the Frame's Android is 11 "
"(API 30). Apps like this often crash at start, but not always: try it, a "
"launch test tells.").format(version=android_version(min_sdk), api=min_sdk),
"warn", ft.Icons.PHONELINK_ERASE_ROUNDED))
if pipeline.missing_obb(g):
out.append(C.callout(tr("This game's data file (.obb) wasn't found next to the APK. Put the .obb files "
"in a folder named {package} (or obb/) next to the APK and add the folder again; "
"without it the game hangs at start.").format(package=pkg), "error",
ft.Icons.FOLDER_OFF_ROUNDED))
web = (extra.get("web_wrapper") or None) if not self.rift and not self.linux else None
if web:
out.append(C.callout(ft.Column([
C.body(tr("This app looks like a website in an Android wrapper: it opens the site in Meta's "
"browser, which the Frame doesn't have. If nothing opens, use the website in a browser "
"instead."), T.TEXT)]
+ ([C.meta(web["url"], T.TEXT_2, selectable=True)] if web.get("url") else []), spacing=T.px(4)),
"warn", ft.Icons.LANGUAGE_ROUNDED))
static = " ".join(engine.blocker_notes(library.analysis_from_dict(g["analysis"]))) \
if (too_new or web) else ""
if static and recipe.notes.strip() == static:
pass # the recipe's note says the same (shown above)
elif recipe.status == "unsupported":
out.append(C.callout(recipe.notes or tr("This game can't run on the Steam Frame."), "error"))
elif recipe.notes and not (self.rift and extra.get("platform_sdk")):
out.append(C.callout(recipe.notes, "info"))
@@ -436,12 +466,28 @@ class GameView:
C.kv(tr("VR (OpenXR)"), tr("Yes: uses the Frame's OpenXR runtime") if vr else
tr("No: a 2D app")),
C.kv(tr("Source"), extra.get("source") or g.get("game_dir") or ""),
C.kv(tr("Desktop Mode"), C.switch(tr("In the menu and on the desktop"),
value=g.get("desktop_entry", True) is not False,
on_change=self.set_desktop_entry), "desktop_entry"),
]
return C.section(tr("What FramePort will do"), C.card(ft.Column([
ft.Row([ft.Icon(ft.Icons.TERMINAL_ROUNDED, color=T.PC, size=T.px(18)),
C.body(lead, T.TEXT, weight=ft.FontWeight.W_500, expand=True)], spacing=T.S2),
*rows], spacing=T.S3)), help="linux_app")
def set_desktop_entry(self, e) -> None:
"""A Linux app's Desktop Mode entry (GitHub #84): kept with the game, applied on the Frame at once when it's
installed there (else at the next install)."""
app, pkg, on = self.app, self.package, bool(e.control.value)
def work():
library.upsert_game(pkg, desktop_entry=on)
if app.frame_state == "connected" and app.target and \
C.install_state(self.g, app.frame_info) in ("installed", "outdated"):
app.target.set_desktop_entry(pkg, on)
app.toast(tr("Added to Desktop Mode") if on else tr("Removed from Desktop Mode"))
app.run_bg(work)
def recipe_summary(self) -> ft.Control:
if self.linux:
return self.linux_summary()
@@ -515,6 +561,9 @@ class GameView:
state = {"recipe": recipe}
warn = C.body("", T.WARN)
technical = bool(library.setting("ui.patch_details", False)) # remembered for every game
# workarounds the last build left out because OVRPort's output already had the fix (patches/upstream.py)
superseded = (g.get("build") or {}).get("superseded") or {}
upstream_note = tr("Not needed in the last build: OVRPort fixed this itself.")
def save(r):
r.source = "user"
@@ -574,12 +623,16 @@ class GameView:
vals = recipe_values(g)
changed = sum(1 for k, v in vals.items() if v != default(k))
fixed = [base.get(pid).title for pid in superseded if pid.startswith("adapter.")]
fixed_note = [C.meta(tr("Not needed in the last build, OVRPort fixed it itself: {names}").format(
names=", ".join(tr(t) for t in fixed)), T.TEXT_3)] if fixed else []
sections.append(C.card(ft.Row([
ft.Column([ft.Row([C.body(CATEGORY_TITLES[cat], T.TEXT, weight=ft.FontWeight.W_600),
C.help_icon("cat_adapter")], spacing=T.S2),
C.meta(tr_n("{n} setting changed from the default",
"{n} settings changed from the default", changed) if changed
else tr("All at their defaults"))], spacing=T.px(2), expand=True),
else tr("All at their defaults")), *fixed_note], spacing=T.px(2),
expand=True),
C.secondary(tr("Change settings…"), ft.Icons.TUNE_ROUNDED,
lambda e: app.settings_dialog(package)),
], spacing=T.S3), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)))
@@ -596,6 +649,8 @@ class GameView:
shown = tr(reason).replace("overport", "OVRPort") if technical else \
plain_reason(reason, p.default_on)
sub.insert(0, C.meta(shown, T.ACCENT))
if on and p.id in superseded:
sub.append(C.meta(upstream_note, T.TEXT_3))
extra = None
choices = getattr(p, "CHOICES", None)
if choices: # a plain choice instead of a switch + text field (e.g. Proton: Experimental / Stable)
+15
View File
@@ -119,6 +119,21 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
return res
MISSING_OBB = ("The game keeps its content in a data file (.obb) and none was installed: FramePort found no .obb "
"files next to its APK. Unreal games then stop right after start without an error (GitHub #85). Put "
"the .obb files in a folder named like the package (or obb/) next to the APK, add the folder again "
"and reinstall.")
def add_missing_obb(res: TriageResult) -> TriageResult:
"""A launch test of a game whose APK expects an OBB that wasn't installed (from the library, not the log: the
game logs nothing about it). Only when it sent no frames: an OBB from an earlier install may still be there."""
if res.fps is None and not any(f.id == "missing-obb" for f in res.findings):
res.findings.insert(0, Finding("missing-obb", "fatal", MISSING_OBB, [],
"analysis: the APK expects an OBB (Unreal bHasOBBFiles); no data folder"))
return res
LOGCAT_TIME = re.compile(r"^\d\d-\d\d (\d\d):(\d\d):(\d\d)\.(\d{3})")
+4 -1
View File
@@ -24,7 +24,7 @@ def isolated_home(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------------------------- AXML builder
def build_axml(elements, strings_extra=()):
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool".
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool" | "int" | "ref".
Produces a UTF-16 string-pool binary XML like aapt does (enough for our editor)."""
ns = "http://schemas.android.com/apk/res/android"
pool = []
@@ -61,6 +61,9 @@ def build_axml(elements, strings_extra=()):
for a, kind, v in e[2]:
if kind == "str":
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), idx(v), 8, 0, 0x03, idx(v))
elif kind in ("int", "ref"): # decimal integer / resource reference (@string/…)
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), 0xFFFFFFFF, 8, 0, 0x10 if kind == "int" else 0x01,
v)
else:
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), 0xFFFFFFFF, 8, 0, 0x12, 0xFFFFFFFF if v else 0)
ext = struct.pack("<IIHHHHHH", 0xFFFFFFFF, idx(e[1]), 20, 20, len(e[2]), 0, 0, 0)
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
// Test fixture for patches/upstream.py probes: the two OVRPort runtime functions ovrport/app#73 fixed, as runtime
// 3.4.3-23204ea has them (default) and as 3.4.3-aa54c3f does (-DFIXED); same instruction sequences as those builds.
// Build: aarch64-linux-android29-clang -shared -fPIC -nostdlib fakeruntime.S -o ../libfakeruntime_old_arm64.so
// aarch64-linux-android29-clang -shared -fPIC -nostdlib -DFIXED fakeruntime.S -o ../libfakeruntime_fixed_arm64.so
.text
.globl AAudioStream_getFramesPerBurst
.type AAudioStream_getFramesPerBurst, %function
AAudioStream_getFramesPerBurst:
ldr w0, [x0]
ret
.size AAudioStream_getFramesPerBurst, .-AAudioStream_getFramesPerBurst
// libopenxr_loader.so: the dispatcher's XrHapticAmplitudeEnvelopeVibrationFB -> 100 Hz samples (x20 = the struct:
// +16 duration in ns, +24 amplitudeCount)
.globl fake_envelope_samples
.type fake_envelope_samples, %function
fake_envelope_samples:
ldr w8, [x20]
mov w9, #28105
movk w9, #15261, lsl #16 // XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB
cmp w8, w9
b.ne 1f
ldr w9, [x20, #24]
#ifdef FIXED
mov x8, #225833675390976
ldr d1, [x20, #16]
movk x8, #16845, lsl #48 // 1e9
ucvtf d0, w9
fmov d2, x8
scvtf d1, d1
mov w8, #1120403456 // 100.0f
fmul d0, d0, d2
fmov s2, w8
fdiv d0, d0, d1
ucvtf s1, w9
fmul s1, s1, s2
fcvt s0, d0
fdiv s0, s1, s0
#else
ldr x8, [x20, #16]
ucvtf s0, w9
scvtf s1, x8
mov w8, #1120403456
fmov s2, w8
fdiv s1, s0, s1
fmul s0, s0, s2
fdiv s0, s0, s1
#endif
fcvtzu w21, s0
1:
ret
.size fake_envelope_samples, .-fake_envelope_samples
// libovrplatformloader.so
.globl ovr_Microphone_GetOutputBufferMaxSize
.type ovr_Microphone_GetOutputBufferMaxSize, %function
ovr_Microphone_GetOutputBufferMaxSize:
#ifdef FIXED
stp x29, x30, [sp, #-32]!
stp x20, x19, [sp, #16]
mov x29, sp
cbz x0, 3f
ldr x8, [x0, #24]
mov w19, #48000
cbz x8, 2f
mov x20, x0
mov x0, x8
bl AAudioStream_getFramesPerBurst
cmp w0, w19
b.hi 2f
ldr x0, [x20, #24]
bl AAudioStream_getFramesPerBurst
sxtw x19, w0
2:
mov x0, x19
ldp x20, x19, [sp, #16]
ldp x29, x30, [sp], #32
ret
3:
mov w19, #48000
mov x0, x19
ldp x20, x19, [sp, #16]
ldp x29, x30, [sp], #32
ret
#else
stp x29, x30, [sp, #-16]!
mov x29, sp
ldr x0, [x0, #0x18]
bl AAudioStream_getFramesPerBurst
sxtw x0, w0
ldp x29, x30, [sp], #16
ret
#endif
.size ovr_Microphone_GetOutputBufferMaxSize, .-ovr_Microphone_GetOutputBufferMaxSize
+60
View File
@@ -76,3 +76,63 @@ def test_start_activity_moves_launcher_to_the_vr_activity():
# only those two attribute values changed: the rest parses as before
assert axml.Axml(fixed).attr_str(next(e for e in axml.Axml(fixed).elements() if e.name == "manifest"),
"package") == "org.x.game"
def _sdk_manifest(min_sdk):
from conftest import build_axml
return build_axml([
("start", "manifest", [("package", "str", "org.x.app")]),
("start", "uses-sdk", [("minSdkVersion", "int", min_sdk), ("targetSdkVersion", "int", 34)]),
("end", "uses-sdk"),
("end", "manifest"),
])
def test_min_sdk(quest_manifest):
assert axml.min_sdk(_sdk_manifest(34)) == 34
assert axml.min_sdk(_sdk_manifest(29)) == 29
assert axml.min_sdk(quest_manifest) is None # no uses-sdk element
def _twa_manifest(value_kind, value):
from conftest import build_axml
return build_axml([
("start", "manifest", [("package", "str", "dev.pages.mahjong_vr.twa")]),
("start", "uses-sdk", [("minSdkVersion", "int", 23)]),
("end", "uses-sdk"),
("start", "application", []),
("start", "meta-data", [("name", "str", "asset_statements"), ("resource", "ref", 0x7F0F0001)]),
("end", "meta-data"),
("start", "activity", [("name", "str", "com.google.androidbrowserhelper.trusted.LauncherActivity")]),
("start", "meta-data", [("name", "str", "android.support.customtabs.trusted.DEFAULT_URL"),
("value", value_kind, value)]),
("end", "meta-data"),
("start", "meta-data", [("name", "str", "com.example.flag"), ("value", "bool", True)]),
("end", "meta-data"),
("end", "activity"),
("end", "application"),
("end", "manifest"),
])
def test_meta_data_and_web_wrapper(tmp_path):
import zipfile
from frameport.analysis.detect import analyze, web_wrapper
m = _twa_manifest("str", "https://mahjong-vr.pages.dev/")
meta = axml.meta_data(m)
assert meta == {"asset_statements": 0x7F0F0001, "android.support.customtabs.trusted.DEFAULT_URL":
"https://mahjong-vr.pages.dev/", "com.example.flag": True}
assert web_wrapper(meta, axml.Axml(m).strings()) == {"url": "https://mahjong-vr.pages.dev/"}
# @string/launchUrl (Bubblewrap) without a resource table: still a wrapper, URL unknown
ref = _twa_manifest("ref", 0x7F0F0002)
assert web_wrapper(axml.meta_data(ref), axml.Axml(ref).strings()) == {"url": None}
assert web_wrapper({}, ["com.example.Main"]) is None
apk = tmp_path / "twa.apk"
with zipfile.ZipFile(apk, "w") as z:
z.writestr("AndroidManifest.xml", m)
a = analyze(apk)
assert a.extra["web_wrapper"] == {"url": "https://mahjong-vr.pages.dev/"} and a.extra["min_sdk"] == 23
+416
View File
@@ -0,0 +1,416 @@
"""Agent: games on another drive (microSD, GitHub #90) and Desktop Mode entries for Linux apps (GitHub #84)."""
import importlib.util
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
def load(monkeypatch, tmp_path, extra_mounts=()):
"""The agent with HOME = <tmp>/home, removable drives under <tmp>/media and a fake /proc/mounts."""
home = tmp_path / "home"
home.mkdir(exist_ok=True)
monkeypatch.setenv("HOME", str(home))
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
a = importlib.util.module_from_spec(spec)
spec.loader.exec_module(a)
media = tmp_path / "media"
(media / "steamos" / "SD Card").mkdir(parents=True, exist_ok=True)
(media / "steamos" / "STICK").mkdir(parents=True, exist_ok=True)
(media / "steamos" / "RO").mkdir(parents=True, exist_ok=True)
sd = str(media / "steamos" / "SD Card").replace(" ", "\\040")
table = tmp_path / "mounts"
table.write_text("/dev/nvme0n1p8 / btrfs rw,relatime 0 0\n"
f"/dev/mmcblk0p1 {sd} ext4 rw,nosuid,nodev,relatime 0 0\n"
f"/dev/sda1 {media}/steamos/STICK exfat rw,nosuid,nodev 0 0\n"
f"/dev/sdb1 {media}/steamos/RO ext4 ro,nosuid 0 0\n"
"proc /proc proc rw 0 0\n" + "".join(extra_mounts))
monkeypatch.setattr(a, "PROC_MOUNTS", str(table))
monkeypatch.setattr(a, "MEDIA_ROOT", str(media))
monkeypatch.setattr(a, "container_running", lambda appid: False)
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
monkeypatch.setattr(a, "as_owner", lambda: [])
return a
def sd_dir(tmp_path):
return tmp_path / "media" / "steamos" / "SD Card"
def quest_install(a, pkg="com.x.y", dest=None):
"""A Quest game as finalize leaves it (no Lepton needed: written by hand like the agent would)."""
prep = a.cmd_prepare({"package": pkg, "title": "Game", "dest": dest})
base = Path(prep["base"])
(base / "lepton-app" / "obb").mkdir(parents=True)
(base / "lepton-app" / "game.apk").write_bytes(b"apk" * 100)
(base / "lepton-app" / "obb" / "main.obb").write_bytes(b"o" * 5000)
saves = base / "lepton-data" / "external" / "Android" / "data" / pkg / "files"
saves.mkdir(parents=True)
(saves / "save.dat").write_text("progress")
shutil.rmtree(base / "incoming")
anchor = Path(prep["anchor"])
anchor.mkdir(parents=True, exist_ok=True)
(anchor / "artwork").mkdir(exist_ok=True)
(anchor / "artwork" / "icon.png").write_bytes(b"png")
a.write_launcher(str(anchor), str(base), pkg, "Game", prep["appid"], "/lepton/lepton", {"X": "1"})
(anchor / "deployment.json").write_text(json.dumps({"package": pkg, "appid": int(prep["appid"]),
"base": str(base), "title": "Game"}))
return prep
def test_drives_lists_internal_and_removable_drives(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
drives = {d["label"]: d for d in a.cmd_drives({})["drives"]}
assert set(drives) == {"Internal storage", "SD Card", "STICK", "RO"}
internal = drives["Internal storage"]
assert internal["internal"] and internal["install_dir"] == a.ANCHORS and internal["usable"]
assert internal["fstype"] == "btrfs" and internal["free_bytes"] > 0
sd = drives["SD Card"]
assert sd["usable"] and sd["removable"] and sd["fstype"] == "ext4"
assert sd["path"] == str(sd_dir(tmp_path)) and sd["install_dir"] == str(sd_dir(tmp_path) / "FramePort")
assert not drives["STICK"]["usable"] and "exfat" in drives["STICK"]["reason"]
assert not drives["RO"]["usable"] and "read-only" in drives["RO"]["reason"]
def test_steam_library_on_another_drive_is_listed(monkeypatch, tmp_path):
lib = tmp_path / "games"
(lib / "steamapps").mkdir(parents=True)
a = load(monkeypatch, tmp_path, [f"/dev/sdc1 {lib} ext4 rw 0 0\n"])
steamapps = Path(a.STEAM) / "steamapps"
steamapps.mkdir(parents=True)
(steamapps / "libraryfolders.vdf").write_text(f'"libraryfolders" {{ "1" {{ "path" "{lib}" }} }}')
drives = {d["path"]: d for d in a.list_drives()}
assert drives[str(lib)]["steam_library"] and drives[str(lib)]["install_dir"] == str(lib / "FramePort")
def test_resolve_dest_never_falls_back_to_internal_storage(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
for internal in (None, "", "internal", a.ANCHORS):
assert a.resolve_dest(internal) == a.ANCHORS
assert a.resolve_dest(str(sd_dir(tmp_path))) == str(sd_dir(tmp_path) / "FramePort") # the mount point
assert (sd_dir(tmp_path) / "FramePort").is_dir()
with pytest.raises(a.AgentError, match="exfat"):
a.resolve_dest(str(tmp_path / "media/steamos/STICK/FramePort"))
with pytest.raises(a.AgentError, match="read-only"):
a.resolve_dest(str(tmp_path / "media/steamos/RO"))
with pytest.raises(a.AgentError, match="isn't inserted"): # an SD card that was taken out
a.resolve_dest(str(tmp_path / "media/steamos/OLDCARD/FramePort"))
assert not (tmp_path / "media/steamos/OLDCARD").exists()
def test_new_games_go_to_the_chosen_drive_and_keep_their_base(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
dest = str(sd_dir(tmp_path) / "FramePort")
prep = quest_install(a, dest=dest)
assert prep["base"] == os.path.join(dest, "com.x.y") and prep["anchor"].startswith(a.ANCHORS)
assert prep["free_bytes"] > 0
# a reinstall keeps the game where it is, whatever the default is now
again = a.cmd_prepare({"package": "com.x.y", "title": "Game", "dest": None})
assert again["base"] == prep["base"] and again["installed"]
games = a.cmd_list_installed({})["games"]
assert games[0]["drive"] == {"internal": False, "path": str(sd_dir(tmp_path)), "label": "SD Card"}
assert games[0]["drive_missing"] is False and games[0]["apk_present"]
assert {d["label"]: d["games"] for d in a.cmd_drives({})["drives"]}["SD Card"] == 1
# Linux apps and PC VR games take dest too
p = a.cmd_prepare_linux({"package": "linux.tool", "title": "Tool", "dest": dest})
assert p["base"] == os.path.join(dest, "linux.tool")
def test_missing_sd_card(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
prep = quest_install(a, dest=str(sd_dir(tmp_path) / "FramePort"))
(tmp_path / "mounts").write_text("/dev/nvme0n1p8 / btrfs rw 0 0\n") # card taken out
game = a.cmd_list_installed({})["games"][0]
assert game["drive_missing"] is True and game["drive"]["label"] == "SD Card"
with pytest.raises(a.AgentError, match="isn't inserted"):
a.cmd_prepare({"package": "com.x.y", "title": "Game"})
with pytest.raises(a.AgentError, match="isn't inserted"):
a.cmd_launch({"package": "com.x.y"})
with pytest.raises(a.AgentError, match="isn't inserted"):
a.cmd_move({"package": "com.x.y", "dest": "internal", "detach": False})
assert Path(prep["base"]).exists()
@pytest.mark.parametrize("same_drive", [True, False])
def test_move_quest_game_to_sd_and_back(monkeypatch, tmp_path, same_drive):
a = load(monkeypatch, tmp_path)
monkeypatch.setattr(a, "same_device", lambda x, y: same_drive)
prep = quest_install(a)
anchor = Path(prep["anchor"])
assert prep["base"] == str(anchor) # internal storage: the files live next to the launcher
os.symlink("/nonexistent/elsewhere", anchor / "lepton-app" / "outside-link")
dest = str(sd_dir(tmp_path))
r = a.cmd_move({"package": "com.x.y", "dest": dest, "detach": False})
assert r["state"] == "done", r
new = sd_dir(tmp_path) / "FramePort" / "com.x.y"
assert (new / "lepton-app" / "obb" / "main.obb").stat().st_size == 5000
assert (new / "lepton-data/external/Android/data/com.x.y/files/save.dat").read_text() == "progress"
assert os.readlink(new / "lepton-app" / "outside-link") == "/nonexistent/elsewhere"
# the anchor keeps its own files only
assert sorted(os.listdir(anchor)) == ["artwork", "deployment.json", "launch.sh"]
dep = a.deployment("com.x.y")
assert dep["base"] == str(new) and dep["moved"]["from"] == str(anchor)
text = (anchor / "launch.sh").read_text()
assert f"app_dir='{new}'" in text and "export X=1" in text
assert subprocess.run(["bash", "-n", str(anchor / "launch.sh")]).returncode == 0
assert a.cmd_move_status({})["state"] == "done"
with pytest.raises(a.AgentError, match="already there"):
a.cmd_move({"package": "com.x.y", "dest": dest, "detach": False})
# and back to internal storage: the drive's empty FramePort folder goes
r = a.cmd_move({"package": "com.x.y", "dest": "internal", "detach": False})
assert r["state"] == "done", r
assert (anchor / "lepton-app" / "game.apk").exists() and not (sd_dir(tmp_path) / "FramePort").exists()
assert a.deployment("com.x.y")["base"] == str(anchor)
assert f"app_dir={anchor}\n" in (anchor / "launch.sh").read_text()
def test_move_refusals(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
quest_install(a)
with pytest.raises(a.AgentError, match="exfat"):
a.cmd_move({"package": "com.x.y", "dest": str(tmp_path / "media/steamos/STICK"), "detach": False})
monkeypatch.setattr(a, "container_running", lambda appid: True)
with pytest.raises(a.AgentError, match="running"):
a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path)), "detach": False})
monkeypatch.setattr(a, "container_running", lambda appid: False)
monkeypatch.setattr(a, "same_device", lambda x, y: False)
monkeypatch.setattr(a, "_space", lambda path: (100 << 20, 1 << 40)) # 100 MiB free < headroom
with pytest.raises(a.AgentError, match="not enough space"):
a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path)), "detach": False})
with pytest.raises(a.AgentError, match="not installed"):
a.cmd_move({"package": "com.not.there", "dest": "internal", "detach": False})
assert a.deployment("com.x.y")["base"] == os.path.join(a.ANCHORS, "com.x.y")
def test_failed_copy_leaves_the_game_where_it_was(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
monkeypatch.setattr(a, "same_device", lambda x, y: False)
prep = quest_install(a)
real = a.tree_stats
monkeypatch.setattr(a, "tree_stats", lambda root, names: (real(root, names)[0], 1) if "SD" in root
else real(root, names)) # the copy comes up short
r = a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path)), "detach": False})
assert r["state"] == "failed" and "doesn't match" in r["error"]
assert a.deployment("com.x.y")["base"] == prep["base"]
assert (Path(prep["base"]) / "lepton-app" / "game.apk").exists()
assert not (sd_dir(tmp_path) / "FramePort" / "com.x.y").exists()
assert f"app_dir={prep['base']}\n" in (Path(prep["anchor"]) / "launch.sh").read_text()
def test_move_detaches_through_systemd_run(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
quest_install(a)
calls = []
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd) or
subprocess.CompletedProcess(cmd, 0, "", "") if cmd[0] == "systemd-run" else
subprocess.run(cmd, capture_output=True, text=True))
r = a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path))})
assert r["started"] and r["to"].endswith("FramePort/com.x.y")
cmd = next(c for c in calls if c[0] == "systemd-run")
assert "_move_worker" in cmd and json.loads(cmd[-1])["package"] == "com.x.y"
assert a.cmd_move_status({})["state"] == "running"
def test_replace_path_keeps_shell_quoting_valid(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
old, new = "/home/steamos/Applications/quest-frame/rift.x", "/run/media/steamos/SD/FramePort/rift.x"
text = (f"base={old}\ncd {old}/game\nexec proton run {old}/revive/ReviveInjector.exe /openxr "
f"'Z:\\home\\steamos\\Applications\\quest-frame\\rift.x\\game\\G.exe' >>\"$base/launch.log\"\n"
f"other={old}2/x\n")
out = a.replace_path(text, old, new)
assert f"base={new}\n" in out and f"cd {new}/game" in out and f"{new}/revive/ReviveInjector.exe" in out
assert "'Z:\\run\\media\\steamos\\SD\\FramePort\\rift.x\\game\\G.exe'" in out
assert f"other={old}2/x" in out # another folder that only starts the same
with pytest.raises(a.AgentError): # an unquoted path can't become one that needs quotes
a.replace_path(text, old, "/run/media/steamos/SD Card/FramePort/rift.x")
spaced = "/run/media/steamos/SD Card/FramePort/rift.x"
assert a.replace_path(f"base='{spaced}'\n", spaced, old) == f"base='{old}'\n"
def test_symlinks_into_the_old_folder_follow_the_move(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
old, new = tmp_path / "old", tmp_path / "new"
(new / "game").mkdir(parents=True)
os.symlink(str(old / "revive" / "LibReviveXR64.dll"), new / "game" / "LibOVRRT64_1.dll")
os.symlink("../drive_c", new / "game" / "relative")
assert a.retarget_symlinks(str(new), ["game"], str(old), str(new)) == 1
assert os.readlink(new / "game" / "LibOVRRT64_1.dll") == str(new / "revive" / "LibReviveXR64.dll")
assert os.readlink(new / "game" / "relative") == "../drive_c"
def linux_install(a, tmp_path, pkg="linux.true", title="True Tool", tags=None, dest=None, **extra):
prep = a.cmd_prepare_linux({"package": pkg, "title": title, "dest": dest})
app_in = os.path.join(prep["incoming"], "app", "bin")
os.makedirs(app_in)
shutil.copy("/bin/true", os.path.join(app_in, "true"))
size = os.path.getsize(os.path.join(app_in, "true"))
os.makedirs(os.path.join(prep["base"], "incoming-artwork"))
Path(prep["base"], "incoming-artwork", "icon.png").write_bytes(b"png")
res = a.cmd_finalize_linux({"package": pkg, "title": title, "exe": "bin/true", "tags": tags or [],
"manifests": {"app": {"bin/true": size}}, "env": {"APP_MODE": "1"}, "dest": dest,
**extra})
return prep, res
def test_linux_app_moves_with_its_launcher(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
monkeypatch.setattr(a, "same_device", lambda x, y: False)
prep, _ = linux_install(a, tmp_path)
r = a.cmd_move({"package": "linux.true", "dest": str(sd_dir(tmp_path)), "detach": False})
assert r["state"] == "done", r
new = sd_dir(tmp_path) / "FramePort" / "linux.true"
text = (Path(prep["anchor"]) / "launch.sh").read_text()
assert f"'{new}/app/bin/true'" in text and "export APP_MODE=1" in text and prep["base"] not in text
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
assert (new / "app" / "bin" / "true").exists() and not (Path(prep["anchor"]) / "app").exists()
def test_pc_vr_game_moves_with_its_prefix_and_launcher(monkeypatch, tmp_path):
"""PC VR: the launcher is written again from the install record (env + game args kept since agent v63), the
Proton prefix moves with the game and the LibOVRRT redirect follows."""
from test_agent import fake_steam_tools
a = load(monkeypatch, tmp_path)
fake_steam_tools(a, Path(a.HOME))
monkeypatch.setattr(a, "same_device", lambda x, y: False)
prep = a.cmd_prepare_pcvr({"package": "rift.game", "title": "Rift Game"})
inc = Path(prep["incoming"])
(inc / "game/UEGame.exe").write_bytes(b"MZexe")
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
(inc / "revive/LibReviveXR64.dll").write_bytes(b"MZdll")
a.cmd_finalize_pcvr({"package": "rift.game", "title": "Rift Game", "exe": "UEGame.exe", "libovr_redirect": True,
"manifests": {"game": {"UEGame.exe": 5},
"revive": {"ReviveInjector.exe": 2, "LibReviveXR64.dll": 5}},
"env": {"FOO": "bar"}, "game_args": ["-vrmode"]})
base = Path(prep["base"])
(base / "compatdata/pfx/drive_c").mkdir(parents=True)
(base / "compatdata/pfx/drive_c/save.sav").write_text("saved")
assert os.path.islink(base / "game/LibOVRRT64_1.dll")
r = a.cmd_move({"package": "rift.game", "dest": str(sd_dir(tmp_path)), "detach": False})
assert r["state"] == "done", r
new = sd_dir(tmp_path) / "FramePort" / "rift.game"
assert (new / "compatdata/pfx/drive_c/save.sav").read_text() == "saved"
assert os.readlink(new / "game/LibOVRRT64_1.dll") == str(new / "revive/LibReviveXR64.dll")
text = (Path(prep["anchor"]) / "launch.sh").read_text()
assert f"{base}/game" not in text and f"{base}/revive" not in text # (the play log stays in the anchor)
assert "export FOO=bar" in text and "-vrmode" in text
assert f"base='{new}'" in text
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
def test_cleanup_and_purge_on_a_drive(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
monkeypatch.setattr(a, "stop_steam", lambda: True)
monkeypatch.setattr(a, "start_steam", lambda s: None)
fp = sd_dir(tmp_path) / "FramePort"
prep = quest_install(a, dest=str(fp))
(fp / "com.old.game").mkdir() # left by an earlier install
(fp / "com.old.game" / "x").write_bytes(b"1234")
assert a.cmd_cleanup({"paths": [str(fp / "com.old.game")]})["freed_bytes"] == 4
for bad in (prep["base"], str(fp), str(fp / "com.x.y" / "lepton-data"), str(sd_dir(tmp_path))):
with pytest.raises(a.AgentError):
a.cmd_cleanup({"paths": [bad]})
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
assert json.loads((tmp_path / "st.json").read_text())["state"] == "done"
assert not fp.exists() and sd_dir(tmp_path).exists()
# ------------------------------------------------------------------------------------------ Desktop Mode (#84)
def test_linux_app_gets_a_desktop_mode_entry(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
(Path(a.HOME) / "Desktop").mkdir()
prep, res = linux_install(a, tmp_path, title='Odd "Name" 100%', tags=["Linux", "Puzzle"])
menu = Path(a.HOME) / ".local/share/applications/frameport-true.desktop"
desk = Path(a.HOME) / "Desktop/frameport-true.desktop"
assert res["desktop_entry"]["menu"] == str(menu) and res["desktop_entry"]["desktop"] == str(desk)
text = menu.read_text()
assert text == desk.read_text() and os.access(desk, os.X_OK)
lines = dict(line.split("=", 1) for line in text.splitlines()[1:])
assert lines["Name"] == 'Odd "Name" 100%' and lines["Categories"] == "Game;"
assert lines["Exec"] == f'env FRAMEPORT_DESKTOP=1 "{prep["anchor"]}/launch.sh"'
assert lines["Icon"] == f"{prep['anchor']}/artwork/icon.png" and lines["X-FramePort-Package"] == "linux.true"
assert lines["Terminal"] == "false"
# nothing changes when it is refreshed again; an app without game tags is a utility
assert a.refresh_desktop_entries() == []
linux_install(a, tmp_path, pkg="linux.editor", title="Editor")
assert "Categories=Utility;" in (Path(a.HOME) / ".local/share/applications/frameport-editor.desktop").read_text()
# uninstall removes both files
a.cmd_uninstall({"package": "linux.true", "keep_data": False})
assert not menu.exists() and not desk.exists()
def test_exec_quoting():
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
a = importlib.util.module_from_spec(spec)
spec.loader.exec_module(a)
assert a.desktop_exec_arg('/a b/$x`"\\%') == '"/a b/\\\\$x\\\\`\\\\"\\\\\\\\%%"'
assert a.desktop_value("two\nlines\\") == "two lines\\\\"
assert a.desktop_file_name("linux.My App!") == "frameport-My-App.desktop"
def test_desktop_entry_switch_and_refresh(monkeypatch, tmp_path):
a = load(monkeypatch, tmp_path)
linux_install(a, tmp_path, desktop_entry=False)
menu = Path(a.HOME) / ".local/share/applications/frameport-true.desktop"
assert not menu.exists() and a.deployment("linux.true")["desktop_entry"] is False
assert a.refresh_desktop_entries() == [] # off stays off
r = a.cmd_desktop_entry({"package": "linux.true", "enabled": True})
assert r["enabled"] and menu.exists()
a.cmd_desktop_entry({"package": "linux.true", "enabled": False})
assert not menu.exists()
# an install from before agent v63 (no desktop_entry field) gets one with the host fixes; stale ones go
dep_path = Path(a.ANCHORS) / "linux.true" / "deployment.json"
raw = json.loads(dep_path.read_text())
raw.pop("desktop_entry")
dep_path.write_text(json.dumps(raw))
stale = menu.parent / "frameport-gone.desktop"
stale.write_text("[Desktop Entry]\nX-FramePort-Package=linux.gone\n")
(menu.parent / "frameport-links.desktop").write_text("[Desktop Entry]\nName=not an app entry\n")
assert "Desktop Mode entries (2)" in a.ensure_host_fixes()
assert menu.exists() and not stale.exists() and (menu.parent / "frameport-links.desktop").exists()
with pytest.raises(a.AgentError):
a.cmd_desktop_entry({"package": "com.not.linux", "enabled": True})
def test_linux_launcher_from_desktop_mode(monkeypatch, tmp_path):
"""Started from Desktop Mode (FRAMEPORT_DESKTOP=1) the launcher's parent exits at once: the app must keep running,
and the desktop's display is used instead of Steam's."""
a = load(monkeypatch, tmp_path)
prep, _ = linux_install(a, tmp_path)
text = (Path(prep["anchor"]) / "launch.sh").read_text()
assert '[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1' in text
assert 'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then' in text
# older launchers are upgraded in place
old = text.replace('[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1\n', "").replace(
'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then', 'if [[ -z "${DISPLAY:-}" ]]; then')
old = old.replace("# Started from Desktop Mode's menu (FRAMEPORT_DESKTOP=1, GitHub #84) the desktop's own session "
"is used.\n", "")
assert "FRAMEPORT_DESKTOP" not in old
(Path(prep["anchor"]) / "launch.sh").write_text(old)
assert "linux.true" in a.upgrade_launchers()
new = (Path(prep["anchor"]) / "launch.sh").read_text()
assert '[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1' in new and "FRAMEPORT_DESKTOP:-}\" && -z" in new
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
# the watchdog: a parent that exits ends the app, unless started from the desktop
script = tmp_path / "app.sh"
script.write_text("#!/bin/sh\nsleep 6\necho finished >" + str(tmp_path / "out") + "\n")
script.chmod(0o755)
program = os.path.join(prep["base"], "app", "bin", "true")
assert program in new
body = new.replace(program, str(script))
launcher = tmp_path / "l.sh"
launcher.write_text(body)
launcher.chmod(0o755)
env = dict(os.environ, FRAMEPORT_DESKTOP="1", DISPLAY=":9")
subprocess.run(["bash", "-c", f"{launcher} & exit 0"], env=env, timeout=20)
import time
deadline = time.time() + 15
while time.time() < deadline and not (tmp_path / "out").exists():
time.sleep(0.5)
assert (tmp_path / "out").read_text().strip() == "finished"
+19
View File
@@ -73,3 +73,22 @@ def test_enabled_patch_is_never_hidden():
r.patches["frame.vrapi_bridge"] = {}
shown, _ = engine.visible_patches(a, r)
assert "frame.vrapi_bridge" in {p.id for p in shown}
def test_newer_android_than_the_frames_only_warns():
"""GitHub #71/#72: minSdk 34 apps crash at start on Android 13+ classes; Lepton runs Android 11."""
_, r = suggest(extra={"size": 1, "min_sdk": 34})
assert r.status != "unsupported" # the manifest alone never decides (a launch test does)
assert "asks for Android 14 (API 34)" in r.notes and "Android is 11 (API 30)" in r.notes
# Quest games declare up to 32 (Quest's Android 12L) and run (16 catalog games do)
for ok in (None, 23, 29, 32):
_, r = suggest(extra={"size": 1, "min_sdk": ok})
assert r.status != "unsupported" and "asks for Android" not in r.notes
def test_web_wrapper_only_warns():
"""GitHub #86: a Trusted Web Activity opens a website in Meta's browser; there's nothing to port."""
_, r = suggest(extra={"size": 1, "web_wrapper": {"url": "https://mahjong-vr.pages.dev/"}})
assert r.status != "unsupported" and "open https://mahjong-vr.pages.dev/ in a browser" in r.notes
_, r = suggest(extra={"size": 1, "web_wrapper": {"url": None}})
assert r.status != "unsupported" and "website" in r.notes
+138
View File
@@ -0,0 +1,138 @@
"""PC side of games on another drive (GitHub #90) and Linux apps' Desktop Mode entries (GitHub #84)."""
from pathlib import Path
import pytest
from frameport.core import library
from frameport.core.events import Cancelled, Reporter
from frameport.frame.connection import FrameTarget
from frameport.install import drives, installer
from frameport.targets.frame_lepton import FrameLeptonTarget
class FakeFrame:
"""Answers agent commands from a script: {command: reply or [replies] or callable(args)}."""
def __init__(self, replies):
self.replies, self.calls = replies, []
def agent(self, command, timeout=None, **args):
self.calls.append((command, args))
r = self.replies[command]
if callable(r):
return r(args)
if isinstance(r, list):
return r.pop(0) if len(r) > 1 else r[0]
return r
def test_install_dest_setting():
assert drives.install_dest() is None
drives.set_install_dest("/run/media/steamos/SD/FramePort")
assert drives.install_dest() == "/run/media/steamos/SD/FramePort"
assert library.setting(drives.SETTING) == "/run/media/steamos/SD/FramePort"
drives.set_install_dest(None)
assert drives.install_dest() is None and library.setting(drives.SETTING) == "internal"
t = FrameLeptonTarget(FrameTarget("frame.local", "steamos", 22))
drives.set_install_dest("/run/media/steamos/SD/FramePort")
assert t.install_dest() == "/run/media/steamos/SD/FramePort"
t.dest = "" # frameport install --dest internal
assert t.install_dest() == ""
def test_linux_install_passes_dest_and_desktop_entry(monkeypatch, tmp_path):
(tmp_path / "app").mkdir()
(tmp_path / "app" / "tool").write_bytes(b"#!/bin/sh\n")
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10}}, "free_bytes": 1 << 40,
"incoming": "/x/incoming", "base": "/sd/FramePort/linux.tool"},
"finalize_linux": {"base": "/sd/FramePort/linux.tool", "appid": 1,
"desktop_entry": {"menu": "/m", "desktop": "/d"}}})
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", dest="/sd/FramePort",
desktop_entry=False)
rep = Reporter()
checks = []
rep.subscribe(lambda ev: ev.kind == "check" and checks.append(ev.data["name"]))
installer.install_linux(frame, plan, rep)
assert frame.calls[0] == ("prepare_linux", {"package": "linux.tool", "title": "Tool", "dest": "/sd/FramePort"})
fin = dict(frame.calls)["finalize_linux"]
assert fin["dest"] == "/sd/FramePort" and fin["desktop_entry"] is False
assert "Desktop Mode entry" in checks
def test_move_follows_the_agents_progress():
status = [{"state": "none"},
{"state": "running", "package": "com.x.y", "phase": "copying", "done_bytes": 1 << 30,
"total_bytes": 2 << 30},
{"state": "running", "package": "com.x.y", "phase": "verifying", "done_bytes": 2 << 30,
"total_bytes": 2 << 30},
{"state": "done", "package": "com.x.y", "base": "/sd/FramePort/com.x.y"}]
frame = FakeFrame({"move_status": status,
"move": {"started": True, "from": "/home/a", "to": "/sd/FramePort/com.x.y",
"total_bytes": 2 << 30}})
rep = Reporter()
seen = []
rep.subscribe(lambda ev: ev.kind == "progress" and seen.append((round(ev.fraction, 2), ev.message)))
st = drives.move(frame, "com.x.y", "/sd/FramePort", rep, sleep=lambda s: None)
assert st["state"] == "done"
assert ("move", {"package": "com.x.y", "dest": "/sd/FramePort"}) in frame.calls
assert seen[0] == (0.5, "Copying 1.0/2.0 GiB") and seen[1][1].startswith("Checking the copy")
def test_move_picks_up_a_running_move_and_reports_failures():
frame = FakeFrame({"move_status": [{"state": "running", "package": "com.x.y", "total_bytes": 0},
{"state": "failed", "error": "the copy doesn't match"}],
"move": lambda a: pytest.fail("a running move of the same game isn't started again")})
with pytest.raises(RuntimeError, match="doesn't match"):
drives.move(frame, "com.x.y", "internal", Reporter(), sleep=lambda s: None)
# a retried job (the Frame was lost) whose move finished meanwhile
from frameport.frame.connection import AgentFailed
def already(a):
raise AgentFailed("Game is already there")
frame = FakeFrame({"move_status": {"state": "done", "package": "com.x.y"}, "move": already})
assert drives.move(frame, "com.x.y", "internal", Reporter(), sleep=lambda s: None)["state"] == "done"
# cancelled before the copy started: nothing is asked of the Frame
frame = FakeFrame({"move_status": {"state": "none"}, "move": lambda a: pytest.fail("started")})
rep = Reporter()
rep.cancelled.set()
with pytest.raises(Cancelled):
drives.move(frame, "com.x.y", "internal", rep, sleep=lambda s: None)
def test_drive_helpers():
d_int = {"internal": True, "path": "/home/steamos", "label": "Internal storage", "free_bytes": 12.5 * 2**30}
d_sd = {"internal": False, "path": "/run/media/steamos/SD", "label": "SD", "free_bytes": None}
assert drives.drive_text(d_int) == "Internal storage · 12.5 GiB free" and drives.drive_text(d_sd) == "SD"
on_sd = {"drive": {"internal": False, "path": "/run/media/steamos/SD", "label": "SD"}, "drive_missing": True}
assert drives.game_drive(on_sd)["missing"] is True and drives.game_drive({"package": "x"}) is None
assert drives.is_on(on_sd, d_sd) and not drives.is_on(on_sd, d_int)
assert drives.is_on({"package": "old agent"}, d_int)
def test_target_switches_a_desktop_entry(monkeypatch):
t = FrameLeptonTarget(FrameTarget("frame.local", "steamos", 22))
t.frame = FakeFrame({"desktop_entry": lambda a: {"enabled": a["enabled"]}})
monkeypatch.setattr(t, "connect", lambda: t)
assert t.set_desktop_entry("linux.tool", False) == {"enabled": False}
assert t.frame.calls == [("desktop_entry", {"package": "linux.tool", "enabled": False})]
def test_pipeline_passes_the_desktop_entry_choice(monkeypatch):
from frameport import pipeline
library.upsert_game("linux.tool", kind="linux", title="Tool", game_dir=str(Path("/apps/tool")), exe="tool",
desktop_entry=False, analysis={"extra": {}})
got = {}
class T:
label = "Frame"
def install_linux(self, *a, **k):
got.update(k)
return {}
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
pipeline.install_linux("linux.tool", T(), Reporter(), add_to_library=False)
assert got["desktop_entry"] is False
+1 -1
View File
@@ -489,7 +489,7 @@ def test_an_already_converted_apk_is_not_converted_again(tmp_path, monkeypatch):
used = []
monkeypatch.setattr(build.overport_tool, "patch", lambda *a, **k: (_ for _ in ()).throw(AssertionError("OVRPort")))
monkeypatch.setattr(build, "apply_frame_fixes",
lambda src, out, *a: (used.append(src.read_bytes()), out.write_bytes(b"u"), ([], []))[2])
lambda src, out, *a: (used.append(src.read_bytes()), out.write_bytes(b"u"), ([], [], {}))[2])
monkeypatch.setattr(build.sign, "sign", lambda src, dst, pkg: dst.write_bytes(b"signed"))
monkeypatch.setattr(build, "check_apk", lambda *a, **k: [])
analysis = _analysis(package="com.x", is_overport_output=True)
+140
View File
@@ -0,0 +1,140 @@
"""Finding Quest games and their data (OBB) folders on disk (sources/quest_dump.py)."""
import zipfile
from pathlib import Path
import pytest
from frameport.sources import quest_dump
@pytest.fixture(autouse=True)
def package_from_file_name(monkeypatch):
monkeypatch.setattr(quest_dump, "_package_of", lambda apk: Path(apk).stem.split("_")[0])
def apk(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", b"x")
return path
def obb(folder: Path, name: str = "main.1.com.x.game.obb") -> Path:
folder.mkdir(parents=True, exist_ok=True)
(folder / name).write_bytes(b"o")
return folder
def test_existing_layouts(tmp_path):
a = tmp_path / "Game A v1 -TAG"
apk(a / "com.x.game.apk")
obb(a / "com.x.game")
g = quest_dump.from_path(a)
assert (g.name, g.data_dir, g.origin) == ("Game A v1", a / "com.x.game", a)
b = tmp_path / "patched"
apk(b / "com.x.game.apk")
obb(b / "obb")
assert quest_dump.from_path(b).data_dir == b / "obb"
assert quest_dump.from_path(b / "com.x.game.apk").data_dir == b / "obb" # a single APK file
c = tmp_path / "no data"
apk(c / "com.x.game.apk")
assert quest_dump.from_path(c).data_dir is None
@pytest.mark.parametrize("data", ["obb/com.x.game", "OBB/com.x.game", "obbs/com.x.game", "Android/obb/com.x.game",
"backup/com.x.game"])
def test_package_folder_below_the_apk(tmp_path, data):
"""The data folder is the one holding the .obb files (its contents go to Android/obb/<package>/)."""
game = tmp_path / "Game"
apk(game / "com.x.game.apk")
obb(game / data)
found = quest_dump.scan(tmp_path)
assert len(found) == 1 and found[0].data_dir == game / data and found[0].name == "Game"
@pytest.mark.parametrize("apk_folder", ["apk", "APKs"])
def test_sidequest_style_backup_is_one_game(tmp_path, apk_folder):
game = tmp_path / "Backups" / "Some Game"
apk(game / apk_folder / "com.x.game.apk")
obb(game / "obb" / "com.x.game")
found = quest_dump.scan(tmp_path / "Backups")
assert len(found) == 1
g = found[0]
assert (g.name, g.origin, g.data_dir) == ("Some Game", game, game / "obb" / "com.x.game")
assert quest_dump.from_path(g.apk).data_dir == game / "obb" / "com.x.game" # the APK file alone too
def test_other_games_obb_is_never_taken(tmp_path):
"""A neighbouring folder with its own APK is another game (e.g. another version): its data stays its own."""
v1, v2 = tmp_path / "Game v1", tmp_path / "Game v2"
apk(v1 / "com.x.game.apk")
apk(v2 / "com.x.game.apk")
obb(v2 / "data" / "com.x.game")
found = {g.origin.name: g for g in quest_dump.scan(tmp_path)}
assert found["Game v1"].data_dir is None
assert found["Game v2"].data_dir == v2 / "data" / "com.x.game"
def test_package_folder_without_obb_files_is_ignored_when_searched(tmp_path):
game = tmp_path / "Game"
apk(game / "apk" / "com.x.game.apk")
(game / "saves" / "com.x.game").mkdir(parents=True)
(game / "saves" / "com.x.game" / "save.dat").write_bytes(b"s")
assert quest_dump.from_path(game / "apk").data_dir is None
# ------------------------------------------------------------------ a game that expects an OBB (GitHub #85)
def test_expects_obb_from_unreals_manifest_flag():
from frameport.analysis.detect import expects_obb
assert expects_obb({"com.epicgames.ue4.GameActivity.bHasOBBFiles": True})
assert expects_obb({"com.epicgames.unreal.GameActivity.bHasOBBFiles": "true"}) # UE5's name
assert not expects_obb({"com.epicgames.ue4.GameActivity.bHasOBBFiles": False})
assert not expects_obb({"com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp": True})
def test_analysis_reads_the_obb_flag(tmp_path):
from conftest import build_axml
from frameport.analysis.detect import analyze
manifest = build_axml([
("start", "manifest", [("package", "str", "com.x.game")]),
("start", "application", []),
("start", "meta-data", [("name", "str", "com.epicgames.ue4.GameActivity.bHasOBBFiles"),
("value", "bool", True)]),
("end", "meta-data"),
("end", "application"),
("end", "manifest"),
])
p = tmp_path / "game.apk"
with zipfile.ZipFile(p, "w") as z:
z.writestr("AndroidManifest.xml", manifest)
assert analyze(p).extra["expects_obb"] is True
def _entry(**kw):
e = {"package": "com.x.game", "analysis": {"extra": {"expects_obb": True}}, "data_dir": None, "data_bytes": 0}
e.update(kw)
return e
def test_missing_obb_is_flagged_before_install_and_in_launch_tests():
from frameport import pipeline
from frameport.validate.triage import TriageResult, add_missing_obb
assert pipeline.missing_obb(_entry())
assert not pipeline.missing_obb(_entry(data_dir="/games/x/com.x.game", data_bytes=10))
assert not pipeline.missing_obb(_entry(data_dir="/games/x/com.x.game", data_bytes=None)) # older entries
assert not pipeline.missing_obb(_entry(analysis={"extra": {"expects_obb": False}}))
assert not pipeline.missing_obb(_entry(kind="rift"))
from test_patches import _analysis
analysis = _analysis(package="com.x.game", engine="Unreal", extra={"size": 1, "expects_obb": True}).to_dict()
[note] = pipeline.analysis_warnings(_entry(analysis=analysis))
assert "folder named com.x.game" in note and "hangs at start" in note
# the launch test: the game logs nothing about it, so the finding comes from the library
r = add_missing_obb(TriageResult("RUNNING", "Lepton started"))
assert [f.id for f in r.findings] == ["missing-obb"] and r.verdict == "fail"
running = TriageResult("RUNNING", "Submitting frames", fps=72.0) # an OBB from an earlier install is there
assert not add_missing_obb(running).findings and running.verdict == "pass"
+24
View File
@@ -163,3 +163,27 @@ def test_x86_linux_program_without_fex():
log = "launch.sh: line 20: /home/steamos/Applications/quest-frame/linux.x/app/x: cannot execute binary file: " \
"Exec format error\n"
assert "linux-x86-no-fex" in [f.id for f in triage.triage(log).findings]
def test_android_too_new_signatures():
"""GitHub #71 (XTADIUM) and #72 (NBA): minSdk 34 apps on Lepton's Android 11."""
for line in (
"10-06 20:11:02.123 4211 4211 E AndroidRuntime: java.lang.NoClassDefFoundError: Failed resolution of: "
"Landroid/window/OnBackInvokedCallback;",
"10-06 20:11:02.123 4211 4230 E AndroidRuntime: java.lang.NoSuchMethodError: No static method "
"storeStoreFence()V in class Ljava/lang/invoke/VarHandle; or its super classes",
):
r = triage(line + "\n", "EXITED")
assert [f.id for f in r.findings] == ["android-too-new"] and r.verdict == "fail"
assert not triage("E AndroidRuntime: java.lang.NoClassDefFoundError: Lcom/example/Foo;\n", "EXITED").findings
def test_web_wrapper_signature():
"""GitHub #86 (Mahjong Table VR): a TWA looking for Meta's browser."""
log = ("10-06 18:02:11.100 3301 3301 D TWALauncherActivity: Using URL from Manifest "
"(https://mahjong-vr.pages.dev/).\n"
"10-06 18:02:11.120 3301 3301 D TwaLauncher: Creating TwaLauncher for com.oculus.browser\n"
"10-06 18:02:11.130 3301 3301 W PackageIdentity: android.content.pm.PackageManager$"
"NameNotFoundException: com.oculus.browser\n")
r = triage(log, "EXITED")
assert {f.id for f in r.findings} == {"web-wrapper"} and r.verdict == "fail"
+17
View File
@@ -174,6 +174,23 @@ def test_install_state_tracks_patch_settings():
assert C.install_state(g, {"installed": []}) == "missing"
def test_game_on_a_missing_sd_card_is_still_installed():
"""GitHub #90: a game whose microSD card is out has no files to see, but it is installed (not "Not installed")."""
from frameport.ui import components as C
sd = {"internal": False, "path": "/run/media/steamos/SD", "label": "SD"}
gone = frame({"package": "com.x", "sha256": "a", "apk_present": False, "drive": sd, "drive_missing": True})
assert install_state(game("a"), gone) == "installed"
assert C.drive_note(C.frame_drive(game("a"), gone)) == " · SD not inserted"
here = frame({"package": "com.x", "sha256": "a", "apk_present": True, "drive": sd, "drive_missing": False})
assert C.drive_note(C.frame_drive(game("a"), here)) == " · on SD"
internal = frame({"package": "com.x", "sha256": "a", "drive": {"internal": True, "path": "/home/steamos",
"label": "Internal storage"}})
assert C.drive_note(C.frame_drive(game("a"), internal)) == ""
assert C.frame_drive(game("a"), frame({"package": "com.x"})) is None # an agent before v63
assert install_state(game("a"), frame({"package": "com.x", "sha256": "a", "apk_present": False})) == "missing"
def test_library_writes_from_many_threads_are_not_lost():
"""Every thread's change survives (library.json read-modify-write used to race between jobs and the UI)."""
import threading
+145
View File
@@ -0,0 +1,145 @@
"""Upstream fixes replacing our workarounds per build (patches/upstream.py)."""
import struct
import zipfile
from pathlib import Path
import pytest
from frameport import build
from frameport.core.events import Reporter
from frameport.core.models import Analysis, Recipe
from frameport.patches import base, upstream
from frameport.patches.frame.haptic_envelope import envelope_duration_fixed
from frameport.patches.frame.ovr_microphone import microphone_checked
FIXTURES = Path(__file__).with_name("fixtures")
OLD = (FIXTURES / "libfakeruntime_old_arm64.so").read_bytes() # OVRPort runtime 3.4.3-23204ea's code
FIXED = (FIXTURES / "libfakeruntime_fixed_arm64.so").read_bytes() # 3.4.3-aa54c3f's
class FakeWs:
abi = "arm64-v8a"
def __init__(self, libs):
self.libs = libs
def lib(self, name):
return f"lib/{self.abi}/{name}"
def has(self, path):
return path.rsplit("/", 1)[-1] in self.libs
def read_lib(self, name):
return self.libs[name]
class Log(Reporter):
def __init__(self):
super().__init__()
self.lines = []
def log(self, msg, *a, **k):
self.lines.append(msg)
@pytest.fixture
def temp_fix():
added = []
def add(**kw):
fix = upstream.register(upstream.UpstreamFix(**{"title": "t", "upstream": "u", **kw}))
added.append(fix.id)
return fix
yield add
for fid in added:
upstream.REGISTRY.pop(fid, None)
def test_move_wide_constants():
# mov x8, #0xcd6500000000 ; movk x8, #0x41cd, lsl #48 (1e9 as a double)
words = [0xD2800000 | 2 << 21 | 0xCD65 << 5 | 8, 0xF2800000 | 3 << 21 | 0x41CD << 5 | 8]
assert struct.pack("<Q", upstream.constants(words)[-1]) == struct.pack("<d", 1e9)
assert upstream.move_wide(0xD503201F) is None # nop
# mov w9, #0x6dc9 ; nop ; movk w9, #0x3b9d, lsl #16
words = [0x52800000 | 0x6DC9 << 5 | 9, 0xD503201F, 0x72A00000 | 0x3B9D << 5 | 9]
assert upstream.find_constant_load(words, 1000173001) == 0
assert upstream.find_constant_load(words[1:], 1000173001) is None # the movk alone isn't a load
def test_haptic_probe_tells_old_and_fixed_runtime_apart():
assert envelope_duration_fixed(OLD) is False
assert envelope_duration_fixed(FIXED) is True
assert envelope_duration_fixed((FIXTURES / "libfakemicrophone_arm64.so").read_bytes()) is None # no envelope code
def test_microphone_probe_tells_old_and_fixed_runtime_apart():
assert microphone_checked(OLD) is False
assert microphone_checked(FIXED) is True
assert microphone_checked((FIXTURES / "libfakeoverport_arm64.so").read_bytes()) is None # no such function
def test_known_fixes_are_registered_for_existing_workarounds():
base.load_all()
fixes = {f.id: f for f in upstream.REGISTRY.values()}
assert fixes["ovrport.haptic_envelope"].workaround == "adapter.haptic_fix"
assert fixes["ovrport.microphone_stream"].workaround == "frame.ovr_microphone"
for fix in fixes.values():
assert fix.workaround in base.REGISTRY, fix.id
def test_resolve_drops_workarounds_only_when_the_fix_is_found():
recipe = {"frame.adapter": {}, "adapter.haptic_fix": {"value": 1}, "frame.ovr_microphone": {}}
log = Log()
res = upstream.resolve(FakeWs({"libopenxr_loader.so": FIXED, "libovrplatformloader.so": OLD}), recipe, log)
assert res.superseded == {"adapter.haptic_fix": "ovrport.haptic_envelope"}
assert "adapter.haptic_fix" not in res.patches and "frame.ovr_microphone" in res.patches
assert "adapter.haptic_fix" in recipe # the recipe itself is unchanged
assert any("not needed: adapter.haptic_fix" in line for line in log.lines)
# libraries missing (can't tell) or the old runtime: every workaround stays
assert upstream.resolve(FakeWs({}), recipe).patches == recipe
assert upstream.resolve(FakeWs({"libopenxr_loader.so": OLD, "libovrplatformloader.so": OLD}),
recipe).superseded == {}
def test_resolve_keeps_the_workaround_when_a_probe_breaks(temp_fix):
temp_fix(id="test.broken", workaround="adapter.swap_eyes", probe=lambda ws: 1 / 0)
log = Log()
res = upstream.resolve(FakeWs({}), {"adapter.swap_eyes": {"value": 1}}, log)
assert "adapter.swap_eyes" in res.patches and not res.superseded
assert any("test.broken failed" in line for line in log.lines)
def test_resolve_can_be_switched_off(temp_fix, monkeypatch):
temp_fix(id="test.always", workaround="adapter.swap_eyes", probe=lambda ws: True)
assert upstream.resolve(FakeWs({}), {"adapter.swap_eyes": {}}).superseded == {"adapter.swap_eyes": "test.always"}
monkeypatch.setenv("FRAMEPORT_KEEP_WORKAROUNDS", "1")
assert upstream.resolve(FakeWs({}), {"adapter.swap_eyes": {}}).superseded == {}
def test_without_superseded():
assert upstream.without_superseded({"a": {}, "b": {"x": 1}}, {"a": "fix"}) == {"b": {"x": 1}}
assert upstream.without_superseded({"a": {}}, None) == {"a": {}}
@pytest.mark.parametrize("runtime, xrshim", [(OLD, True), (FIXED, False)])
def test_build_leaves_out_the_haptic_workaround_with_a_fixed_runtime(tmp_path, monkeypatch, runtime, xrshim):
from frameport.patches.frame import adapter
apk = tmp_path / "in.apk"
with zipfile.ZipFile(apk, "w") as z:
z.writestr("lib/arm64-v8a/libopenxr_loader.so", runtime)
z.writestr("lib/arm64-v8a/libopenxr_loader_generic.so", b"loader")
monkeypatch.setattr(adapter, "artifact", lambda abi, name: b"artifact " + name.encode())
recipe = Recipe("com.x", patches={"frame.adapter": {}, "adapter.haptic_fix": {"value": 1}})
analysis = Analysis(package="com.x", version="1", label="X", abis=["arm64-v8a"], engine="Unity", xr="OpenXR",
graphics="Vulkan", direct_vrapi=False, libs=["libOVRPlugin.so"], launcher_activity=None,
has_info_category=True, meta_permissions=[], uses_glad_gl=False, unity_msaa_levels=0,
oculus_os_classes=False, is_overport_output=False, debuggable=False, extra={})
applied, _checks, superseded = build.apply_frame_fixes(apk, tmp_path / "out.apk", analysis, recipe, Log())
assert applied == ["frame.adapter"]
assert superseded == ({} if xrshim else {"adapter.haptic_fix": "ovrport.haptic_envelope"})
with zipfile.ZipFile(tmp_path / "out.apk") as z:
settings = z.read("lib/arm64-v8a/libframe_settings.so").decode()
assert ("lib/arm64-v8a/libframe_xrshim.so" in z.namelist()) == xrshim
assert ("haptic_fix=1" in settings) == xrshim