mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-08 07:00:43 +02:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e8a480ea6 | ||
|
|
ea2cd893b4 | ||
|
|
a90a8c27dc | ||
|
|
9d0d72c40a | ||
|
|
56d2ac117f | ||
|
|
cb8202d891 | ||
|
|
029b0674d1 | ||
|
|
c852473a43 | ||
|
|
1043eb1d1b | ||
|
|
0f444da595 | ||
|
|
fda644d036 | ||
|
|
fa0fbbd29a | ||
|
|
3446999109 |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+94
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user