mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-25 20:02:00 +01:00
Compare commits
27
Commits
docs-sweep
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ad73a485 | ||
|
|
4d40b1c5bb | ||
|
|
87e34bd9ef | ||
|
|
af142439ba | ||
|
|
6988d54d5c | ||
|
|
1eb7f655ba | ||
|
|
fe8b106560 | ||
|
|
a8ab024b26 | ||
|
|
dc98903fd6 | ||
|
|
6f307f0fe8 | ||
|
|
b440656560 | ||
|
|
5429ae5c2b | ||
|
|
48367c7cf8 | ||
|
|
a6b37f8331 | ||
|
|
f2297a4ac1 | ||
|
|
89a501d5d8 | ||
|
|
8359856609 | ||
|
|
dfdd6cf0ca | ||
|
|
59de1da268 | ||
|
|
75a016b4ff | ||
|
|
3170b3acc9 | ||
|
|
bd242be4a7 | ||
|
|
3dcede3ee3 | ||
|
|
68aab29e85 | ||
|
|
2e86f97905 | ||
|
|
439cc6c213 | ||
|
|
26b2ea16fd |
No files matched your search
@@ -336,7 +336,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
reinstall of the release's `.whl`); `prepare()` → `<data>/updates/<ver>/` (SHA256SUMS.txt required + checked,
|
||||
layout checked, Windows: the new FramePort.exe must have the running exe's Authenticode signer when that one is
|
||||
signed, `ready.json`); `apply()` writes + starts detached `apply.ps1`/`apply.sh` (wait for pid → Windows: copy over,
|
||||
replaced files kept in `updates/<ver>/previous` because the zip has no top folder; macOS/Linux: mv to `.old` + swap,
|
||||
replaced files kept in `updates/<ver>/previous` because the zip has no top folder; nothing touched while a program file
|
||||
is in use, old-only files of app/Lib/DLLs/site-packages/data moved to `previous`, a failed copy deletes what it
|
||||
added (#476: the old rollback kept new modules → old version crashed at start); bundles carry
|
||||
`frameport/_bundle_files.txt` (package.py) → `core/bundle_files.check` at GUI start removes/ignores leftovers and
|
||||
reports a mixed install; macOS/Linux: mv to `.old` + swap,
|
||||
rollback, `xattr -dr` quarantine → relaunch; `<data>/logs/update.log`). `ui/updater.py`: background check (10 s,
|
||||
then 6 h), sidebar card (hidden control, toggled), Library bar (`library_bar`), changelog dialog, job
|
||||
`app-update` that restarts only when no other job is active, `apply_pending_at_start()` in `main()` for
|
||||
@@ -635,7 +639,7 @@ Toy Master doesn't request a model after that (it uses its own), so the glb load
|
||||
**Hardware video decoding:** `native/hevc` OMX plugin (`OMX.frameport.{avc,hevc,vp9}.decoder`), installed once per Frame (`ensure_video_codec`, agent `video_codec_status`/`install_video_codec`/`video_codec_switch`); **bump `revision` in native/hevc/build.py with every artifact change**; per game `frame.hw_video_decode` (analysis `media_codec`), Settings `video.hw_decode`, `FRAMEPORT_NO_HW_VIDEO=1`; Batman migration `batman_video_patches` + `surface_native`. → docs/DEVICE_NOTES.md#hardware-video-decoding
|
||||
**Vulkan shader dump:** adapter `vk_shader_dump=1` (`native/vkshim/shader_dump.h`) writes SPIR-V modules + `index.txt` to `files/fp_vk_shaders/`; `collect_diag` returns the newest session's modules (`shader_dumps`); triage `gpu-hang`, `triage.graphics_api`. → docs/DEVICE_NOTES.md#vulkan-shader-dump
|
||||
**Foveation centre (#394):** Valve's FDM layer centres its density map on the whole swapchain (not the eye's rect or optical axis) and Turnip picks density per GMEM bin (the sharp area snaps to bins, so its size shifts with render resolution: driver design); `VK_LAYER_FRAMEPORT_fdm_centre` (native/foveation/centre.h, below Valve's layer, views from FrameBridge `fdm_views.c`) rewrites the per-eye density-map offsets; FrameBridge `snapshot=N` reads Vulkan eyes back. → docs/DEVICE_NOTES.md#foveation-sharp-area-centre-and-size-394
|
||||
**Expansion file names (#450/#481):** OBBs with a download's copy suffix (`main.203.com.Armature.VR4-003.obb`) or another case are installed under the game's name (`quest_dump.obb_names`/`frame_manifest`, PC files untouched); another app's name (#129), duplicates and unclear cases stay warnings. → docs/DEVICE_NOTES.md#expansion-file-names-450-481
|
||||
**Expansion file names (#450/#481):** OBBs with a download's copy suffix (`main.203.com.Armature.VR4-003.obb`) or another case are installed under the game's name (`quest_dump.obb_names`/`frame_manifest`, PC files untouched), other kinds too (#415: Crisis Brigade 2's `office.222.<pkg>.obb`, kind spelling kept, never an "another app" warning); another app's name (#129), duplicates and unclear cases stay warnings. → docs/DEVICE_NOTES.md#expansion-file-names-450-481
|
||||
**Unreal OBB check (#159):** `frame.unreal_skip_obb_check` (analysis `unreal_verify_obb`) turns off Epic's OBB CRC pass; triage `unreal-obb-check-stuck`. → docs/DEVICE_NOTES.md#unreal-obb-check
|
||||
**Issue round 2026-10-12:** Lepton breaks on unquoted paths with spaces → launchers give Lepton a `~/.local/share/frameport/lepton-paths/<pkg>` link (`app_dir_lines`/`set_app_dir`/`launcher_base`); `frame.eos_keychain_loop`, `frame.unity_tls_key_pool` (`libfp_tkeys.so`, `elf.rename_imports`), `frame.vermillion_scene_canvas`; `tools/cpp2il.py` falls back to Cpp2IL's dev build for metadata v107; Linux `desktop_icon`; `scripts/upload_bench.py`. → docs/DEVICE_NOTES.md#issue-round-2026-10-12
|
||||
**OpenSSH uploads (#324/#242):** `native_upload.quote` escapes only backslash and `"`, Win32 local paths go as `C:/…`; `Frame.auth` records the login that worked (app_key / user_key from `ssh.extra_keys`, `frame ssh-keys`, agent, password); strict IdentitiesOnly first, relaxed retry. → docs/DEVICE_NOTES.md#openssh-uploads
|
||||
@@ -814,3 +818,9 @@ asks before ending a running game, and closes local FramePort/Desktop Mode. Purg
|
||||
Hook hardening: both lines `-` + `/usr/bin/timeout -k 5 20`; ExecStartPost only `systemd-run --no-block`s the
|
||||
verify (unit frameport-library-verify) so Steam's start never waits; drop-in removed once mode != later and nothing
|
||||
pending (`drop_library_hooks_if_idle`). Steam = static user unit steam.service (Type=simple, Restart=always, 90 s).
|
||||
Agent v124: a waiting `now` worker holds the shortcut lock (`shortcut_lock`) for up to 12 h, so nothing an SSH command
|
||||
reaches blocks on it (an install's `library_mode` hung until the 600 s agent timeout): `library_mode` tries once and
|
||||
skips the hook cleanup; `cmd_shortcuts` journals into the waiting worker (`pending_lock`, a short separate lock;
|
||||
the worker writes "running" under it before merging the journal after stop_steam, one restart for all; the PC
|
||||
shows the same "waiting" reason); `queue_library` waits ≤30 s, else journal only; runs drop what they applied
|
||||
(`consume_pending`), never later entries.
|
||||
+225
-55
@@ -24,6 +24,7 @@ PC VR (Oculus Rift) games packed for the Frame (id "rift.<slug>"), run by Proton
|
||||
<dest>/<id>/compatdata/ Proton prefix = saves (kept across reinstalls), launch.log
|
||||
"""
|
||||
import base64
|
||||
import contextlib
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
@@ -48,7 +49,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 123
|
||||
AGENT_VERSION = 125
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -100,6 +101,13 @@ GAMEPAD_LINE = ('[[ "${FRAMEPORT_NO_GAMEPAD:-0}" != 0 ]] || { export FRAMEPORT_G
|
||||
'python3 "$HOME/.local/share/frameport/agent/frameport_agent.py" _gamepad_links "$SteamAppId" $$ '
|
||||
'>"$app_dir/gamepad.log" 2>&1 9>&- & }')
|
||||
PAD_DIR = "/dev/fp-input" # the host's /dev/input inside the container
|
||||
# System features for a game's Android (patch device.hybrid_app, per game, GitHub #484): Horizon OS "hybrid apps"
|
||||
# (YouTube) only leave their 2D panel for VR when the system has oculus.software.vr.app.hybrid. The launcher exports
|
||||
# FRAMEPORT_FEATURES (the patch's env) and puts the Podman wrapper on PATH; the wrapper mounts a permissions file that
|
||||
# declares them (Android reads /system/etc/permissions at every container start).
|
||||
FEATURES_LINE = ('[[ -z "${FRAMEPORT_FEATURES:-}" || ! -x "$HOME/.local/share/frameport/agent/bin/podman" ]] || '
|
||||
'export PATH="$HOME/.local/share/frameport/agent/bin:$PATH"')
|
||||
FEATURES_TARGET = "/system/etc/permissions/frameport-features.xml"
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
@@ -1189,18 +1197,91 @@ def library_mode(args=None):
|
||||
return mode
|
||||
|
||||
|
||||
def cmd_library_mode(args):
|
||||
mode = library_mode(args)
|
||||
_write_json(LIBRARY_MODE_FILE, {"mode": mode})
|
||||
@contextlib.contextmanager
|
||||
def shortcut_lock(wait=None):
|
||||
"""The shortcut lock: one library writer at a time (the worker, Steam's start hooks, a live `later` update).
|
||||
A `now` worker holds it while it waits for FramePort/Desktop Mode/a game to close (up to 12 h), so only the
|
||||
detached worker and Steam's hooks may block on it (wait=None). What an SSH command reaches passes `wait`
|
||||
(seconds, 0 = one try) and gets False while it stays busy: an install waited for it until the PC's 600 s agent
|
||||
timeout (discussion #305)."""
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
got = True
|
||||
if wait is None:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
else:
|
||||
end = time.monotonic() + wait
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= end:
|
||||
got = False
|
||||
break
|
||||
time.sleep(0.5)
|
||||
yield got
|
||||
|
||||
|
||||
def shortcut_lock_busy():
|
||||
"""Some library writer (most of the time a waiting `now` worker) holds the shortcut lock right now."""
|
||||
with shortcut_lock(wait=0) as got:
|
||||
return not got
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def pending_lock():
|
||||
"""Guards read-modify-write of the pending journal only: never held across a wait or a Steam restart, so it is
|
||||
always short. Lock order: the shortcut lock first, then this one."""
|
||||
os.makedirs(os.path.dirname(PENDING_FILE), exist_ok=True)
|
||||
with open(PENDING_FILE + ".lock", "a") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
if mode == "off":
|
||||
yield
|
||||
|
||||
|
||||
def add_pending(packages, remove):
|
||||
"""Journal games to add / shortcuts to remove (merged with what already waits). Returns the journal."""
|
||||
with pending_lock():
|
||||
return _add_pending(packages, remove)
|
||||
|
||||
|
||||
def _add_pending(packages, remove):
|
||||
"""Caller holds the pending lock (flock isn't reentrant across file descriptors)."""
|
||||
pending = pending_library()
|
||||
pending["packages"] = sorted(set(pending["packages"]) | set(packages))
|
||||
pending["remove"].extend(r for r in remove if r not in pending["remove"])
|
||||
_write_json(PENDING_FILE, pending)
|
||||
return pending
|
||||
|
||||
|
||||
def consume_pending(applied):
|
||||
"""Drop what a worker/hook has applied (`applied`, a journal snapshot) from the journal; entries journaled since
|
||||
that snapshot stay for the next run."""
|
||||
with pending_lock():
|
||||
pending = pending_library()
|
||||
pending["packages"] = [p for p in pending["packages"] if p not in applied["packages"]]
|
||||
pending["remove"] = [r for r in pending["remove"] if r not in applied["remove"]]
|
||||
if not pending["packages"] and not pending["remove"]:
|
||||
pending = {"packages": [], "remove": []}
|
||||
_write_json(PENDING_FILE, pending)
|
||||
|
||||
|
||||
def cmd_library_mode(args):
|
||||
"""Every install calls this first. The mode is written at once; the hook cleanup needs the shortcut lock and is
|
||||
skipped while another writer holds it (never waited for, discussion #305): a waiting `now` worker re-reads
|
||||
library_mode() every 10 s (later: journals its games and keeps the hooks; off: drops its games) and drops idle
|
||||
hooks when it ends; Steam's post-start check drops them once the journal is empty."""
|
||||
mode = library_mode(args)
|
||||
_write_json(LIBRARY_MODE_FILE, {"mode": mode})
|
||||
if mode == "off":
|
||||
with pending_lock():
|
||||
pending = pending_library()
|
||||
pending["packages"] = []
|
||||
_write_json(PENDING_FILE, pending)
|
||||
drop_library_hooks_if_idle()
|
||||
return {"mode": mode}
|
||||
with shortcut_lock(wait=0) as got:
|
||||
if got:
|
||||
drop_library_hooks_if_idle()
|
||||
return {"mode": mode} if got else {"mode": mode, "worker_active": True}
|
||||
|
||||
|
||||
def pending_library():
|
||||
@@ -1216,19 +1297,18 @@ def pending_library():
|
||||
|
||||
|
||||
def queue_library(packages, remove):
|
||||
# Serialize with the worker and Steam's startup hooks; persist before touching the live VDF.
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
return _queue_library(packages, remove)
|
||||
# Serialize with the worker and Steam's startup hooks; persist before touching the live VDF. A `now` worker lets
|
||||
# go within 10 s of the switch to `later`; a lock that stays busy longer (a Steam restart in progress) leaves the
|
||||
# live update to the hook at Steam's next start (bounded: never the PC's 600 s agent timeout).
|
||||
with shortcut_lock(wait=30) as got:
|
||||
if got:
|
||||
return _queue_library(packages, remove)
|
||||
return {"state": "pending", **add_pending(packages, remove), "added": [], "errors": []}
|
||||
|
||||
|
||||
def _queue_library(packages, remove):
|
||||
"""Caller holds the shortcut lock."""
|
||||
pending = pending_library()
|
||||
pending["packages"] = sorted(set(pending["packages"]) | set(packages))
|
||||
pending["remove"].extend(remove)
|
||||
_write_json(PENDING_FILE, pending)
|
||||
pending = add_pending(packages, remove)
|
||||
result = {"state": "pending", **pending, "added": [], "errors": []}
|
||||
for user in library_users():
|
||||
update_library(user, packages, result)
|
||||
@@ -1327,8 +1407,10 @@ def cmd_library_pending(args):
|
||||
remove_from_library(user, pending["remove"], result)
|
||||
for user in library_users():
|
||||
update_library(user, packages, result)
|
||||
pending["errors"] = result["errors"]
|
||||
_write_json(PENDING_FILE, pending)
|
||||
with pending_lock(): # only the errors: what was journaled meanwhile stays
|
||||
current = pending_library()
|
||||
current["errors"] = result["errors"]
|
||||
_write_json(PENDING_FILE, current)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1344,13 +1426,13 @@ def cmd_library_pending_verify(args):
|
||||
result = {"state": "done", "added": [], "errors": list(pending.get("errors", []))}
|
||||
if not packages and not pending["remove"]:
|
||||
if pending["packages"]: # only uninstalled games were left
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
consume_pending(pending)
|
||||
drop_library_hooks_if_idle()
|
||||
return result
|
||||
for pkg in shortcuts_lost(library_users(), packages):
|
||||
add_devkit_entry(pkg, result)
|
||||
if not result["errors"]:
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
consume_pending(pending)
|
||||
drop_library_hooks_if_idle()
|
||||
_write_json(STATUS_FILE, result)
|
||||
return result
|
||||
@@ -1399,11 +1481,18 @@ def cmd_shortcuts(args):
|
||||
return queue_library(packages, remove)
|
||||
if not packages and not remove and not args.get("restart_now"):
|
||||
return {"started": False}
|
||||
current = cmd_shortcut_status({})
|
||||
alive = time.time() - float(current.get("started") or 0) < 60 # a waiting worker rewrites its status every 10 s
|
||||
if (not args.get("restart_now") and current.get("state") == "waiting" and alive
|
||||
and set(packages) <= set(current.get("packages") or []) and not remove):
|
||||
return {"started": False, "waiting": True} # the update for these games already waits (Gaming Mode / game)
|
||||
if not args.get("restart_now"):
|
||||
# A worker waiting for FramePort/Desktop Mode/a game to close holds the shortcut lock: a second worker would
|
||||
# only queue behind it for a second Steam restart. Journal these changes instead; the waiting worker merges
|
||||
# the journal once Steam is closed. Under the journal lock: the worker leaves "waiting" under it too, so a
|
||||
# change journaled here is never missed.
|
||||
with pending_lock():
|
||||
current = cmd_shortcut_status({})
|
||||
alive = time.time() - float(current.get("started") or 0) < 60 # rewritten every 10 s while waiting
|
||||
if current.get("state") == "waiting" and alive and shortcut_lock_busy():
|
||||
if not set(packages) <= set(current.get("packages") or []) or remove:
|
||||
_add_pending(packages, remove)
|
||||
return {**current, "started": False, "waiting": True}
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump({"state": "running", "packages": packages, "started": time.time()}, f)
|
||||
@@ -1497,9 +1586,22 @@ def shortcuts_worker(payload):
|
||||
_shortcuts_worker(payload)
|
||||
|
||||
|
||||
def _split_devkit(packages):
|
||||
"""Games Steam only knows through their devkit entry (this Frame's Steam drops FramePort's shortcuts.vdf
|
||||
entries, GitHub #41/#42): rewriting the vdf changes nothing for them, and the Steam restart would drop the live
|
||||
devkit entry too. They get their art only; Play re-adds a lost devkit entry live. Returns (vdf games, devkit)."""
|
||||
devkit = [p for p in packages if devkit_gameid(p)]
|
||||
for pkg in devkit:
|
||||
appid = devkit_appid(devkit_gameid(pkg))
|
||||
if appid:
|
||||
copy_grid_art(pkg, appid)
|
||||
return [p for p in packages if p not in devkit], devkit
|
||||
|
||||
|
||||
def _shortcuts_worker(payload):
|
||||
args = json.loads(payload)
|
||||
result = {"state": "done", "added": [], "errors": [], "finished": None}
|
||||
applied = None # the journal entries merged into this run
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
try: # new art (e.g. the user's own icon) also reaches Linux apps' Desktop Mode entries
|
||||
refresh_desktop_entries()
|
||||
@@ -1507,16 +1609,7 @@ def _shortcuts_worker(payload):
|
||||
pass
|
||||
try:
|
||||
users = library_users()
|
||||
# games Steam only knows through their devkit entry (this Frame's Steam drops FramePort's shortcuts.vdf
|
||||
# entries, GitHub #41/#42): rewriting the vdf changes nothing for them, and the Steam restart would drop the
|
||||
# live devkit entry too. They get their art only; Play re-adds a lost devkit entry live.
|
||||
devkit = [p for p in args["packages"] if devkit_gameid(p)]
|
||||
for pkg in devkit:
|
||||
appid = devkit_appid(devkit_gameid(pkg))
|
||||
if appid:
|
||||
copy_grid_art(pkg, appid)
|
||||
args["packages"] = [p for p in args["packages"] if p not in devkit]
|
||||
result["devkit"] = devkit
|
||||
args["packages"], result["devkit"] = _split_devkit(args["packages"])
|
||||
if not args.get("restart_now") and not args.get("remove") and not library_changes(users, args["packages"]):
|
||||
# nothing to change in shortcuts.vdf (e.g. a reinstall): only the artwork, no Steam restart
|
||||
for user in users:
|
||||
@@ -1540,11 +1633,16 @@ def _shortcuts_worker(payload):
|
||||
ensure_library_hooks()
|
||||
_queue_library(args["packages"], args.get("remove", []))
|
||||
return
|
||||
if mode == "off" and not args.get("remove"):
|
||||
_write_json(STATUS_FILE, {"state": "done", "disabled": True, "added": [], "errors": []})
|
||||
return
|
||||
if mode == "off":
|
||||
if mode == "off": # games journaled meanwhile are dropped too; removals still happen
|
||||
args["packages"] = []
|
||||
with pending_lock():
|
||||
pending = pending_library()
|
||||
pending["packages"] = []
|
||||
_write_json(PENDING_FILE, pending)
|
||||
if not args.get("remove") and not pending["remove"]:
|
||||
_write_json(STATUS_FILE, {"state": "done", "disabled": True, "added": [], "errors": []})
|
||||
drop_library_hooks_if_idle()
|
||||
return
|
||||
reason = restart_hold()
|
||||
if not reason:
|
||||
break
|
||||
@@ -1552,13 +1650,20 @@ def _shortcuts_worker(payload):
|
||||
json.dump({"state": "waiting", "reason": reason, "packages": args["packages"],
|
||||
"started": time.time()}, f)
|
||||
time.sleep(10)
|
||||
with pending_lock(): # cmd_shortcuts journals into a waiting worker under this lock: not after this point
|
||||
_write_json(STATUS_FILE, {"state": "running", "packages": args["packages"], "started": time.time()})
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError:
|
||||
raise AgentError("Steam did not close; library not modified") from None
|
||||
pending = pending_library()
|
||||
args["packages"] = sorted(set(args["packages"]) | {p for p in pending["packages"] if deployment(p)})
|
||||
args.setdefault("remove", []).extend(pending["remove"])
|
||||
# Everything journaled for Steam's restart (later installs/uninstalls while this worker waited, discussion
|
||||
# #305): one restart covers it all. An uninstall queued after an install wins: deployments are checked here.
|
||||
with pending_lock():
|
||||
applied = pending_library()
|
||||
extra, devkit = _split_devkit([p for p in applied["packages"] if p not in args["packages"] and deployment(p)])
|
||||
result["devkit"] += devkit
|
||||
args["packages"] = sorted(p for p in set(args["packages"]) | set(extra) if deployment(p))
|
||||
args.setdefault("remove", []).extend(r for r in applied["remove"] if r not in args["remove"])
|
||||
for user in steam_users(): # uninstalled games leave every account's library
|
||||
remove_from_library(user, args.get("remove", []), result)
|
||||
for user in users:
|
||||
@@ -1580,7 +1685,8 @@ def _shortcuts_worker(payload):
|
||||
result["errors"].append(str(exc))
|
||||
run(["systemctl", "--user", "start", "steam.service"])
|
||||
if result["state"] == "done" and not result["errors"]:
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
if applied is not None:
|
||||
consume_pending(applied)
|
||||
drop_library_hooks_if_idle()
|
||||
try:
|
||||
os.remove(RESTART_REQUEST_FILE)
|
||||
@@ -3675,6 +3781,7 @@ def upgrade_launchers():
|
||||
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
|
||||
new = set_tz_line(new)
|
||||
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
|
||||
new = set_features_line(new, "\nexport FRAMEPORT_FEATURES=" in new)
|
||||
pkg = os.path.basename(os.path.dirname(path))
|
||||
dep = deployment(pkg) or {}
|
||||
new = set_openxr_manifest_line(new, pkg, dep.get("static_openxr_loader", False))
|
||||
@@ -3806,11 +3913,32 @@ def wants_gamepad(anchor):
|
||||
return GAMEPAD_PATCH in (recipe.get("patches") or [])
|
||||
|
||||
|
||||
def wrapper_lines(hw_video, gamepad):
|
||||
"""The launcher's Podman wrapper lines (empty, one or both). The gamepad line comes last, so FramePort's wrapper is
|
||||
first on PATH and hands on to the codec wrapper."""
|
||||
def wrapper_lines(hw_video, gamepad, features=False):
|
||||
"""The launcher's Podman wrapper lines (none, some or all). FramePort's lines (gamepad, features) come last, so
|
||||
its wrapper is first on PATH and hands on to the codec wrapper."""
|
||||
return "\n".join(line for line, want in ((VIDEO_CODEC_LINE, hw_video), (DECODER_USERS_LINE, hw_video),
|
||||
(GAMEPAD_LINE, gamepad)) if want)
|
||||
(GAMEPAD_LINE, gamepad), (FEATURES_LINE, features)) if want)
|
||||
|
||||
|
||||
def set_features_line(text, want):
|
||||
"""A Lepton launcher with the features line (want) or without it: after the gamepad line, else after the codec
|
||||
lines, else where they would be. A launcher that has it after those lines keeps it where it is."""
|
||||
here = text.find(FEATURES_LINE + "\n")
|
||||
if want and here >= 0 and all(text.find(line + "\n") < here
|
||||
for line in (GAMEPAD_LINE, DECODER_USERS_LINE, VIDEO_CODEC_LINE)):
|
||||
return text
|
||||
text = text.replace(FEATURES_LINE + "\n", "")
|
||||
if not want:
|
||||
return text
|
||||
at = -1
|
||||
for line in (GAMEPAD_LINE, DECODER_USERS_LINE, VIDEO_CODEC_LINE):
|
||||
i = text.find(line + "\n")
|
||||
if i >= 0:
|
||||
at = i + len(line) + 1
|
||||
break
|
||||
if at < 0:
|
||||
at = next((text.index(a) + 1 for a in ("\nchild=''\n", "\nsetsid ") if a in text), -1)
|
||||
return text if at < 0 else text[:at] + FEATURES_LINE + "\n" + text[at:]
|
||||
|
||||
|
||||
def set_gamepad_line(text, want):
|
||||
@@ -3940,9 +4068,13 @@ def podman_run_args(args, env, sys_root="/sys", dev_root="/dev"):
|
||||
when its launcher asked for them (FRAMEPORT_GAMEPAD=1, the gamepad line); _gamepad_links then links Steam's pads
|
||||
into the container's /dev/input. Anything else: the arguments unchanged. Lepton mounts a tmpfs over /dev, so a
|
||||
`--device` node would vanish under it, and its /dev/input holds its own Wayland input pipes: a bind mount next to
|
||||
it, like Lepton's own GPU nodes."""
|
||||
if args[:1] != ["run"] or env.get("FRAMEPORT_GAMEPAD") != "1" or \
|
||||
env.get("FRAMEPORT_NO_GAMEPAD", "0") not in ("", "0"):
|
||||
it, like Lepton's own GPU nodes. System features the launcher names (FRAMEPORT_FEATURES, the features line) are
|
||||
added as a permissions file in the container's /system/etc/permissions (device.hybrid_app)."""
|
||||
if args[:1] != ["run"]:
|
||||
return args
|
||||
gamepad = env.get("FRAMEPORT_GAMEPAD") == "1" and env.get("FRAMEPORT_NO_GAMEPAD", "0") in ("", "0")
|
||||
features = system_features(env.get("FRAMEPORT_FEATURES", ""))
|
||||
if not gamepad and not features:
|
||||
return args
|
||||
appid = env.get("SteamAppId", "")
|
||||
name = None
|
||||
@@ -3961,6 +4093,17 @@ def podman_run_args(args, env, sys_root="/sys", dev_root="/dev"):
|
||||
key, _, value = item.partition("=")
|
||||
if key in ("destination", "target", "dst"):
|
||||
taken.add(value)
|
||||
extra = []
|
||||
if gamepad:
|
||||
extra += gamepad_mounts(env, taken, sys_root, dev_root)
|
||||
if features and FEATURES_TARGET not in taken:
|
||||
extra += ["--mount", f"type=bind,source={features_file(features)},destination={FEATURES_TARGET},ro"]
|
||||
print(f"FramePort: system features for this container: {', '.join(features)}", file=sys.stderr)
|
||||
return [args[0], *extra, *args[1:]] if extra else args
|
||||
|
||||
|
||||
def gamepad_mounts(env, taken, sys_root="/sys", dev_root="/dev"):
|
||||
"""podman_run_args' mounts for Steam Input's virtual gamepad: the host's /dev/input and the key layout."""
|
||||
pads = steam_gamepads(sys_root, dev_root)
|
||||
extra = []
|
||||
if PAD_DIR not in taken:
|
||||
@@ -3978,7 +4121,33 @@ def podman_run_args(args, env, sys_root="/sys", dev_root="/dev"):
|
||||
extra += ["--mount", f"type=bind,source={layout},destination={target},ro"]
|
||||
names = ", ".join(f"{p['event']} ({p['name']}, 28de:{p['product']})" for p in pads) or "none"
|
||||
print(f"FramePort gamepad: Steam Input virtual gamepads for this container: {names}", file=sys.stderr)
|
||||
return [args[0], *extra, *args[1:]] if extra else args
|
||||
return extra
|
||||
|
||||
|
||||
def system_features(value):
|
||||
"""The feature names in FRAMEPORT_FEATURES (comma-separated); anything that isn't a feature name is left out."""
|
||||
return sorted({f for f in (part.strip() for part in str(value or "").split(","))
|
||||
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+", f) and len(f) <= 128})
|
||||
|
||||
|
||||
def features_file(features):
|
||||
"""The permissions file that declares `features` (one per distinct set, next to the Podman wrapper)."""
|
||||
text = ('<?xml version="1.0" encoding="utf-8"?>\n<!-- Written by FramePort (device.hybrid_app). -->\n'
|
||||
"<permissions>\n" + "".join(f' <feature name="{f}" />\n' for f in features) + "</permissions>\n")
|
||||
path = os.path.join(os.path.dirname(PODMAN_BIN),
|
||||
f"features-{hashlib.sha256(text.encode()).hexdigest()[:12]}.xml")
|
||||
try:
|
||||
with open(path) as f:
|
||||
if f.read() == text:
|
||||
return path
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
|
||||
def pid_alive(pid):
|
||||
@@ -4161,14 +4330,14 @@ def set_openxr_manifest_line(text, pkg, enabled):
|
||||
|
||||
|
||||
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False,
|
||||
config_sync=False, static_openxr=False, scene_room=False):
|
||||
config_sync=False, static_openxr=False, scene_room=False, features=False):
|
||||
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))
|
||||
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, app_dir=app_dir_lines(base, pkg), appid=appid,
|
||||
lepton_q=shlex.quote(lepton), extra_env=extra, watchdog=WATCHDOG,
|
||||
dashboard=dashboard_line(), logcat=logcat_line(), single=SINGLE_LINE,
|
||||
cloud_dirs=cloud_dirs_line(pkg),
|
||||
video_codec=wrapper_lines(hw_video, gamepad),
|
||||
video_codec=wrapper_lines(hw_video, gamepad, features),
|
||||
plays_start=plays_lines(anchor)[0],
|
||||
plays_end=plays_lines(anchor)[1], save_snapshot=snapshot_line(pkg),
|
||||
restart=restart_block(pkg, lepton))
|
||||
@@ -6133,14 +6302,15 @@ def configure_install(args, prep, moved):
|
||||
write_openxr_runtime_manifest(base, static_openxr)
|
||||
hw_video = HW_VIDEO_PATCH in (recipe.get("patches") or []) # the shared codec (install_video_codec)
|
||||
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
|
||||
if gamepad:
|
||||
features = bool(system_features((args.get("env") or {}).get("FRAMEPORT_FEATURES"))) # device.hybrid_app
|
||||
if gamepad or features:
|
||||
ensure_podman_wrapper()
|
||||
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
|
||||
scene_value = settings.get("scene_emul", (recipe.get("adapter") or {}).get(
|
||||
"scene_emul", int("adapter.scene_emul" in (recipe.get("patches") or []))))
|
||||
scene_room = float(scene_value) != 0
|
||||
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync),
|
||||
static_openxr, scene_room)
|
||||
static_openxr, scene_room, features)
|
||||
art_in = os.path.join(base, "incoming-artwork")
|
||||
art_moved = False
|
||||
if args.get("consume_artwork") and os.path.isdir(art_in):
|
||||
|
||||
@@ -14,11 +14,11 @@ e63d93e086df3b7b29501188b80fb113879e2fd325d7659f68a31ac5bc87b561 ./arm64-v8a/li
|
||||
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
dc8b8dd017934077ad181da0cd08b03581561822b189b580faa2b495e20eec7d ./arm64-v8a/libopenxr_loader_generic.so
|
||||
be863bb0001bc60e9bbd691753f5c5968664ae7917b29258dd15b7a9dd1c3ade ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
c7a14d34e4ddf20ec943641f6431525867d6f030cd01160df1b24afd441af39f ./arm64-v8a/libvrapi.so
|
||||
c7a14d34e4ddf20ec943641f6431525867d6f030cd01160df1b24afd441af39f ./arm64-v8a/libvrapi_foveation.so
|
||||
200525569cd37551e606be93fc76e8ac41e9516b3ffa922a3d4ebe40ba6a3824 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
2f978a24bc78a33a4162f5d35ec13d722a5a965a67772bd6128cbd6f8b0b4b17 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
6fd59e6c6e533c0a17204e4f12adaf992ffa6cf58d6280aa9b37ca7ba20299d4 ./dex/browser-handoff.dex
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
@@ -27,10 +27,10 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
|
||||
46c3dcad2c43cc30bea3b3680b362ed84a99c15d60714bbbbb422c80ecf90405 ./hevc/media_codecs_frameport.xml
|
||||
529ea35d5d82474afe9e09ad1216d269317f56dd24a05fb100fced52b7b4dbca ./hevc/podman.py.txt
|
||||
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
|
||||
2cc16b90abcd3d359f1658c581172bd650017bf2725e24268915f50904c000ec ./linux-arm64-bin/fp_roomscan
|
||||
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
|
||||
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
|
||||
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
|
||||
2cc16b90abcd3d359f1658c581172bd650017bf2725e24268915f50904c000ec ./linux-arm64-bin/fp_roomscan
|
||||
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
|
||||
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
|
||||
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
|
||||
0dfaea4c68979b0a45a9fef06fdf1a92468ffc8f46f80a3a014fce1d805972d5 ./roomscan/failed.png
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
package: com.Oculus.FirstContact
|
||||
title: First Contact
|
||||
status: works
|
||||
notes: '32-bit only, runs through FramePort''s 32-bit bridge (experimental). Played in the headset
|
||||
(GitHub #489); the headless launch test ends early, which is expected for the bridge.'
|
||||
tested_version: '1.12'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.a32_bridge
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 7b971d70af24ec554994e59f65b2568c5b86a94df04e962699051f267d6358b7
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261022.6153909'
|
||||
agent: 109
|
||||
issue: 489
|
||||
source_hint: com.Oculus.FirstContact
|
||||
@@ -9,9 +9,9 @@ notes: 'FrameBridge turns its upside-down UI panels upright (flip emulation, in
|
||||
login may not work. frame.unity_text_input added from detection (not headset-verified); no device.text_input_window:
|
||||
its Android window took the controllers'' input (owner, 2026-10-23).
|
||||
|
||||
Keeps the Quest 2 headset identity for now: as a Quest 3 it switches to larger eye images after the intro, which
|
||||
the Frame''s swapchain limit refused (GitHub #424). With the Vulkan panel pool (swapchain_pool) it runs as a Quest
|
||||
3 (checked headless 2026-10-23); the Quest 3 identity comes back with the FramePort release that has it.'
|
||||
Runs as a Quest 3 with the Vulkan panel pool (swapchain_pool): as a Quest 3 it switches to larger eye images after
|
||||
the intro, which the Frame''s swapchain limit refused before the pool (GitHub #424; checked headless 2026-10-23,
|
||||
FramePort 1.4.0).'
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
@@ -19,7 +19,6 @@ overport_extra:
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
frame_remove:
|
||||
- device.headset_model
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
@@ -28,7 +27,7 @@ verified:
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
|
||||
updated: '2026-10-23'
|
||||
min_app: 1.3.0
|
||||
updated: '2026-10-25'
|
||||
min_app: 1.4.0
|
||||
adapter:
|
||||
swapchain_pool: 1
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.XrealGames.Zc15
|
||||
title: Zero Caliber 2
|
||||
status: works
|
||||
tested_version: 2.0.1173-S-80237
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unreal_noreserve
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
fixed_resolution: 1
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 48a6fc11f64aba9a9ee95863cb601d64ad8b54eba9034d5b05097f5d3ee00b57
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261022.6153909'
|
||||
agent: 109
|
||||
issue: 492
|
||||
source_hint: Zero Caliber 2
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.baobab.invasion
|
||||
title: Invasion
|
||||
status: works
|
||||
tested_version: 1.0.250
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_oculus_check
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 06c8f94556eb8d8d6679e089d2429e70b45148aba7ad0d47c42bd1de932cbdc1
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261022.6153909'
|
||||
agent: 109
|
||||
issue: 490
|
||||
source_hint: com.baobab.invasion
|
||||
@@ -1,9 +1,9 @@
|
||||
package: com.drbeef.questzdoom
|
||||
title: QuestZDoom
|
||||
status: issues
|
||||
notes: 'Needs a WAD: put it into QuestZDoom/wads in the game''s storage (Files tab) and set QuestZDoom/commandline.txt
|
||||
to "qzdoom -iwad /sdcard/QuestZDoom/wads/<name>.wad". Slowed to a crawl after 5-10 minutes before
|
||||
gl_error_clear (issue 237); not yet confirmed in the headset with it.'
|
||||
status: works
|
||||
notes: 'Needs a WAD: put it into QuestZDoom/wads in the game''s storage (Files tab) and create QuestZDoom/qzdoom.ini
|
||||
with the lines "[IWADSearch.Directories]" and "Path=/sdcard/QuestZDoom/wads" (commandline.txt alone is not
|
||||
enough). Smooth past 15 minutes in the headset with gl_error_clear (issue 237).'
|
||||
details: GZDoom on OpenGL ES with Team Beef's OpenXR code. The game leaves GL_INVALID_ENUM pending every frame; the
|
||||
runtime's GL check failed on it at the first swapchain and its GL interop then created about 100 GPU objects a
|
||||
second that were never freed (8,000 after 75 s headless). Frames slowed down over the minutes, and in a player's
|
||||
@@ -17,7 +17,7 @@ frame:
|
||||
adapter:
|
||||
gl_error_clear: 1
|
||||
verified:
|
||||
date: '2026-10-24'
|
||||
date: '2026-10-25'
|
||||
issue: 237
|
||||
updated: '2026-10-24'
|
||||
updated: '2026-10-25'
|
||||
source_hint: questzdoom-1.6.2
|
||||
@@ -0,0 +1,26 @@
|
||||
package: com.mrf.kluge.SynthRiders
|
||||
title: SynthRiders
|
||||
status: works
|
||||
tested_version: 3.6.7a2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.meta_permissions
|
||||
- frame.bhaptics_permission
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
controller_models: 1
|
||||
haptic_fix: 1
|
||||
scene_emul: 1
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: ef1792f577697c5fa355a61adfc02e9d8a50fe4ddfec0357398bd5867df42f17
|
||||
app: 1.3.1.dev703
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261022.6153909'
|
||||
agent: 125
|
||||
issue: 494
|
||||
source_hint: com.mrf.kluge.SynthRiders
|
||||
@@ -0,0 +1,16 @@
|
||||
package: io.port0r.houseofthedead
|
||||
title: 'Port0r: House of the Dead VR'
|
||||
status: works
|
||||
tested_version: 0.1.1
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
as_is: true
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 699a25d696df8af8669feee11037a2c1c56f15a8f9359b1f8d17897aa841b197
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261020.6124238'
|
||||
agent: 109
|
||||
issue: 496
|
||||
source_hint: Port0r-HouseOfTheDeadVR-v0.1.1-quest3
|
||||
@@ -0,0 +1,16 @@
|
||||
package: io.port0r.segarally
|
||||
title: 'Port0r: Rally VR'
|
||||
status: works
|
||||
tested_version: 0.2.6
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
as_is: true
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 186018b96550c3881e4659293d07a2fbe6bb7a962798de1dbf6bcba3a99b9025
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261020.6124238'
|
||||
agent: 109
|
||||
issue: 498
|
||||
source_hint: Port0r-RallyVR-v0.2.6-quest3
|
||||
@@ -0,0 +1,20 @@
|
||||
package: io.port0r.timecrisis
|
||||
title: 'Port0r: Time Crisis VR'
|
||||
status: issues
|
||||
notes: Immersive mode shows a black screen (only the arm and gun); the flat and 3D screen modes work
|
||||
(game menu, setup button).
|
||||
tested_version: 0.2.2
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
adapter:
|
||||
refresh_rate: 90.0
|
||||
as_is: true
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 4208f5bf16ecc2cfcf3fbb6cb5abf2d632eeffdc3a74115c0b03e861c1bdee43
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261020.6124238'
|
||||
agent: 109
|
||||
issue: 497
|
||||
source_hint: Port0r-TimeCrisisVR-v0.2.2-quest3
|
||||
@@ -0,0 +1,18 @@
|
||||
package: io.port0r.timecrisis2
|
||||
title: 'Port0r: Time Crisis II VR'
|
||||
status: issues
|
||||
notes: Gameplay runs well; cut scenes and the title and game-selection screens after inserting coins
|
||||
don't display properly.
|
||||
tested_version: 0.1.0
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
as_is: true
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 3277d77f81fc6ec7e686d2bd74050234dbfb4c90bf3009a1feb027147372aecb
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261020.6124238'
|
||||
agent: 109
|
||||
issue: 495
|
||||
source_hint: Port0r-TimeCrisisIIVR-v0.1.0-quest3
|
||||
@@ -0,0 +1,16 @@
|
||||
package: io.port0r.virtuacop
|
||||
title: 'Port0r: Cop VR'
|
||||
status: works
|
||||
tested_version: 0.2.2
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
as_is: true
|
||||
verified:
|
||||
date: '2026-10-25'
|
||||
known_good_sha256: 67e0e5676054b8c7a05d9e835d312d29716437e2166c2ce558b6b9b1782286bf
|
||||
app: 1.3.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261020.6124238'
|
||||
agent: 109
|
||||
issue: 493
|
||||
source_hint: Port0r-CopVR-v0.2.1-quest3
|
||||
@@ -172,6 +172,27 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: The game needs a newer Android than the Frame has, so it can't run yet. It calls Android 12/13+ classes (android.window.*, VarHandle fences) that the Frame's Android 11 (API 30) container lacks (for example minSdk 34 apps); nothing to patch until Valve updates Lepton's Android.
|
||||
suggest: []
|
||||
- id: system-library-missing
|
||||
# GitHub #484 (YouTube VR): OVRPort's patch_remove_uses_library also drops <uses-library> entries for libraries
|
||||
# Lepton has; the classes of patches/frame/system_libraries.LEPTON_LIBRARIES then can't be found
|
||||
pattern: '(ClassNotFoundException|NoClassDefFoundError)\b.*\bL?(org[./]apache[./]http[./]|android[./]test[./]|javax[./]obex[./]|com[./]android[./](location[./]provider|mediadrm[./]signer|media[./]remotedisplay|future[./]usb)[./]|android[./]net[./]ipsec[./]ike[./])'
|
||||
severity: fatal
|
||||
diagnosis: The app closed at start because it can't find classes from one of Android's optional libraries (for example org.apache.http.legacy in YouTube VR). The Frame's Android has the library, but OVRPort removed the app's request for it from the manifest; "Keep Android's shared libraries the app uses" puts it back. Turn it on and rebuild the game ("Update on Frame").
|
||||
suggest: [frame.keep_system_libraries]
|
||||
- id: refresh-rate-refused
|
||||
# GitHub #336 (OutRun): the game quits when its xrRequestDisplayRefreshRateFB is refused; FrameBridge now answers
|
||||
# success, so the game's own error line only shows in older builds
|
||||
pattern: 'request display Hz: -1000101000'
|
||||
severity: error
|
||||
diagnosis: The game asked for a refresh rate the Frame's runtime refuses and may quit on that error. A newer FramePort keeps the current rate instead; press Update on Frame. Setting Game settings → Refresh rate to 90 Hz also avoids the error.
|
||||
suggest: [adapter.refresh_rate=90]
|
||||
- id: hybrid-feature-missing
|
||||
# GitHub #484 (YouTube VR): a Horizon OS hybrid app checks for the system feature oculus.software.vr.app.hybrid
|
||||
# before it leaves its 2D panel for VR
|
||||
pattern: 'Missing hybrid app system feature'
|
||||
severity: warning
|
||||
diagnosis: 'The app stays in its 2D panel because it can''t find a Quest system feature it checks before switching into VR (360° videos, full screen). "Hybrid app: allow the switch into VR" declares that feature for the app. Turn it on and press Update on Frame; whether the VR part then works depends on the app.'
|
||||
suggest: [device.hybrid_app]
|
||||
- id: web-wrapper
|
||||
pattern: '(Creating TwaLauncher for com\.oculus\.browser|NameNotFoundException:? com\.oculus\.browser)'
|
||||
severity: fatal
|
||||
|
||||
+10
-3
@@ -75,9 +75,16 @@ directly, x86_64 builds through Valve's FEX), a Desktop Mode menu entry and, for
|
||||
release's wheel).
|
||||
- `prepare()` (bundle): `<data>/updates/<ver>/`: download → SHA256SUMS.txt check → extract to `staged/` (ditto on
|
||||
macOS) → layout check → on Windows the same Authenticode signer as the running exe → `ready.json`.
|
||||
- `apply()`: writes `<data>/updates/apply.{ps1,sh}` and starts the script detached; FramePort quits. The script waits for the
|
||||
pid. Windows: backs up the files it replaces (`updates/<ver>/previous`) and copies over the folder (the zip has no
|
||||
folder of its own). macOS/Linux: `mv` target → `.old`, staged → target, rollback on failure, clears the quarantine.
|
||||
- `apply()`: writes `<data>/updates/apply.{ps1,sh}` and starts it detached; FramePort quits. The script waits for the
|
||||
pid. Windows: changes nothing while FramePort or a program file (.exe/.dll/.pyd) is still in use (waits 60 s), backs
|
||||
up the files it replaces (`updates/<ver>/previous`), moves the old version's files the new one doesn't have out of
|
||||
the bundle's own folders (app, Lib, DLLs, site-packages, data) into `previous` too, and copies over the folder (the
|
||||
zip has no folder of its own); a failure deletes the files the update added and restores the rest (GitHub #476: a
|
||||
rollback that kept the new version's added modules made the old version crash at start). Bundles carry
|
||||
`frameport/_bundle_files.txt` (written by `scripts/package.py`, `core/bundle_files.py`): at start the app deletes
|
||||
(or at least never imports) package files the list doesn't have and tells the user to reinstall when compiled
|
||||
modules don't match it — updates made by an older version's script still copy over without cleaning.
|
||||
macOS/Linux: `mv` target → `.old`, staged → target, rollback on failure, clears the quarantine.
|
||||
Then the script relaunches FramePort. Log: `<data>/logs/update.log`.
|
||||
GUI: background check 10 s after start + every 6 h → sidebar card + Library bar → dialog with notes → job
|
||||
`app-update` (waits for other jobs) → restart. "Install updates automatically": prepare in the background, apply in
|
||||
|
||||
@@ -1382,6 +1382,14 @@ all the game's own (162 Asgard's Wrath 2 chunks, Phantom's `.partN`, Metro's `ch
|
||||
DLC, Espire 2's `overflowN`). The old #129 check flagged Asgard's Wrath 2's chunks and Phantom's parts as "named for
|
||||
another app"; they're left alone now.
|
||||
|
||||
**Other kinds (#415, 2026-10-24):** Crisis Brigade 2 reloaded ships seven `<kind>.222.com.sumalab.CrisisBrigade2reloaded.obb`
|
||||
files (office, alleys, challenges, mansion, ta1, ta2, ta3); a user's copies were lower case in the package part, so they
|
||||
reached the Frame (`NAMED_OBB` scan) under names the game can't open. `obb_names` now also renames any other
|
||||
`<kind>.<vc>.<package>.obb` whose package part is the game's in another case or with a copy suffix, keeping the kind's
|
||||
own spelling (an unclear versionless copy only warns, as for main/patch). These kinds never get the "another app"
|
||||
warning: any `<word>.<n>.<x>.obb` may be unrelated data. `NAMED_OBB` kinds may contain digits now (`ta1`: the scan
|
||||
skipped them before), and `_named_obbs` takes copy-suffixed files of every kind.
|
||||
|
||||
## App bundles (.xapk / .apks / .apkm / .apkx, discussion #482)
|
||||
|
||||
**Formats (2026-10-24):** `.xapk` (APKPure: `manifest.json` with `split_apks` [{file, id}] and `expansions`
|
||||
|
||||
+11
-1
@@ -63,6 +63,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Epic Roller Coasters | Quest | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Eye of the Temple | Quest | ✅ Works | |
|
||||
| First Contact | Quest | ✅ Works | |
|
||||
| FrenzyVR | Quest | ✅ Works | |
|
||||
| Fruit Ninja | Quest | ✅ Works | |
|
||||
| Fruit Salon | Quest | ✅ Works | |
|
||||
@@ -74,12 +75,14 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Gunjack | Quest | ✅ Works | |
|
||||
| H.U.N.T | Quest | ✅ Works | |
|
||||
| Hitstream | Quest | ✅ Works | |
|
||||
| Hungry Shark Evolution | Quest | ✅ Works | |
|
||||
| I Am Cat | Quest | ✅ Works | |
|
||||
| I Am Monkey | Quest | ✅ Works | |
|
||||
| I Expect You To Die 2 | Quest | ✅ Works | |
|
||||
| IEYTD3 | Quest | ✅ Works | |
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Into The Radius 2 | Quest | ✅ Works | |
|
||||
| Invasion | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| Journey of the Gods | Quest | ✅ Works | |
|
||||
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
|
||||
@@ -119,9 +122,13 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
| Pistol Whip | PC VR | ✅ Works | |
|
||||
| Please Don't Touch Anything | Quest | ✅ Works | |
|
||||
| Port0r: Cop VR | Quest | ✅ Works | |
|
||||
| Port0r: House of the Dead VR | Quest | ✅ Works | |
|
||||
| Port0r: Rally VR | Quest | ✅ Works | |
|
||||
| PowerWash Simulator VR | Quest | ✅ Works | |
|
||||
| Punch Fit | Quest | ✅ Works | |
|
||||
| QuestCraft | Quest | ✅ Works | |
|
||||
| QuestZDoom | Quest | ✅ Works | |
|
||||
| Racket: Nx | Quest | ✅ Works | |
|
||||
| RacketPinball | Quest | ✅ Works | |
|
||||
| RC Pilot Trainer | Quest | ✅ Works | |
|
||||
@@ -157,6 +164,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Survival Nation | Quest | ✅ Works | |
|
||||
| Surviving Mars: Pioneer | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Table Troopers | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
@@ -192,6 +200,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Wallace & Gromit in The Grand Getaway | Quest | ✅ Works | |
|
||||
| Waltz of the Wizard: Extended Edition | Quest | ✅ Works | |
|
||||
| Wander | Quest | ✅ Works | |
|
||||
| Zero Caliber 2 | Quest | ✅ Works | |
|
||||
| ZombielandVR | Quest | ✅ Works | |
|
||||
| AFFECTED: The Manor | Quest | ⚠️ Works with issues | Gear VR game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Ancient_Dungeon | Quest | ⚠️ Works with issues | Multiplayer isn't available (offline). |
|
||||
@@ -216,10 +225,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; space warp off (fewer stutters). |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Port0r: Time Crisis II VR | Quest | ⚠️ Works with issues | Gameplay runs well; cut scenes and the title and game-selection screens after inserting coins don't display properly. |
|
||||
| Port0r: Time Crisis VR | Quest | ⚠️ Works with issues | Immersive mode shows a black screen (only the arm and gun); the flat and 3D screen modes work (game menu, setup button). |
|
||||
| PrimedGun v2 | Quest | ⚠️ Works with issues | Vulkan via a statically linked Khronos OpenXR loader, which bypasses FrameBridge (the picture jumps). |
|
||||
| Puzzling Places | Quest | ⚠️ Works with issues | Works fine, but pointers dont show well on main menu, easy to work around. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| QuestZDoom | Quest | ⚠️ Works with issues | Needs a WAD: put it into QuestZDoom/wads in the game's storage (Files tab) and set QuestZDoom/commandline.txt to "qzdoom -iwad /sdcard/QuestZDoom/wads/<name>… |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Real VR Fishing | Quest | ⚠️ Works with issues | Seems to be almost 100% functional with meta permissions enabled. |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
|
||||
@@ -206,6 +206,10 @@ FramePort checks for a new version at start and every 6 hours. **Update now** in
|
||||
version, checks it and restarts FramePort; your games and settings stay. **Later** skips that version.
|
||||
|
||||
- Settings → **Updates** turns the check off or on, or turns on **Install updates automatically**.
|
||||
- **FramePort doesn't start after an update** (Windows; an error such as `cannot import name …`): the update didn't
|
||||
finish, and the program folder holds files of two versions. Close FramePort (check Task Manager), delete the
|
||||
program folder, download `FramePort-windows-x64.zip` again and unpack the zip into an empty folder. Games,
|
||||
settings and signing keys are stored in `%LOCALAPPDATA%\FramePort` and stay.
|
||||
- **Recipes** update by themselves. A game whose recipe changed shows **Update on Frame**.
|
||||
- **Moved your games?** Scan the new folder or click **Rescan folders**. Moved games keep their name, artwork, tags
|
||||
and settings. [FAQ](FAQ.md#i-moved-my-games-to-another-folder-or-drive-how-do-i-update-the-library).
|
||||
|
||||
+63
-32
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 201 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 203 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -11,9 +11,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
## Groups
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (70)
|
||||
- [Steam Frame patches](#group-frame) (71)
|
||||
- [Game settings](#group-adapter) (82)
|
||||
- [Files and settings on the Frame](#group-device) (11)
|
||||
- [Files and settings on the Frame](#group-device) (12)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
## Index
|
||||
@@ -39,18 +39,18 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
|
||||
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
|
||||
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 49 |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 50 |
|
||||
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
|
||||
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
|
||||
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
|
||||
| [Pico/YVR/Quest metadata](#patch_vr_metadata) | `patch_vr_metadata` | Patches from OVRPort | default |
|
||||
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
|
||||
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
|
||||
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 7 |
|
||||
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
|
||||
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 2 |
|
||||
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
|
||||
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
|
||||
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 11 |
|
||||
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 12 |
|
||||
| [BlazeRush: controllers without Meta avatars, own shader cache](#frame-blazerush) | `frame.blazerush` | Steam Frame patches | 1 |
|
||||
| [Browser for sign-ins: open pages in Steam's browser](#frame-browser_handoff) | `frame.browser_handoff` | Steam Frame patches | |
|
||||
| [Cortopia games: don't wait for Meta's org-scoped id](#frame-cortopia_org_id) | `frame.cortopia_org_id` | Steam Frame patches | 1 |
|
||||
@@ -60,15 +60,16 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Play services: Android 11 version checks](#frame-gms_platform_version) | `frame.gms_platform_version` | Steam Frame patches | 2 |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | 1 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 42 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 43 |
|
||||
| [Just Dance VR: keep progress in the local save](#frame-justdance_local_save) | `frame.justdance_local_save` | Steam Frame patches | |
|
||||
| [Keep Android's shared libraries the app uses](#frame-keep_system_libraries) | `frame.keep_system_libraries` | Steam Frame patches | default |
|
||||
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
|
||||
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
|
||||
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
|
||||
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | 1 |
|
||||
| [Start the game in the app's main process](#frame-main_process) | `frame.main_process` | Steam Frame patches | default |
|
||||
| [Unity: disable Meta depth occlusion](#frame-meta_depth_occlusion) | `frame.meta_depth_occlusion` | Steam Frame patches | 1 |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 9 |
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 10 |
|
||||
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
|
||||
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 18 |
|
||||
@@ -88,19 +89,19 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
|
||||
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 3 |
|
||||
| [Unity App UI: no Android 12 vibrator](#frame-unity_appui_vibrator) | `frame.unity_appui_vibrator` | Steam Frame patches | |
|
||||
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 27 |
|
||||
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 28 |
|
||||
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 2 |
|
||||
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 11 |
|
||||
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 14 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 33 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 15 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 34 |
|
||||
| [Unity: skip Meta's player-in-room check](#frame-unity_scene_active_rooms) | `frame.unity_scene_active_rooms` | Steam Frame patches | 1 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 135 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 136 |
|
||||
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
| [Unreal: match multiview depth to the eye size](#frame-unreal_multiview_depth) | `frame.unreal_multiview_depth` | Steam Frame patches | 1 |
|
||||
| [Unreal: reserve memory like on Quest](#frame-unreal_noreserve) | `frame.unreal_noreserve` | Steam Frame patches | |
|
||||
| [Unreal: reserve memory like on Quest](#frame-unreal_noreserve) | `frame.unreal_noreserve` | Steam Frame patches | 1 |
|
||||
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 6 |
|
||||
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
|
||||
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
|
||||
@@ -134,7 +135,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Per-eye diagnostics (log)](#adapter-eye_debug) | `adapter.eye_debug` | Game settings | |
|
||||
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | 2 |
|
||||
| [Colour space support](#adapter-fb_color_space) | `adapter.fb_color_space` | Game settings | 1 |
|
||||
| [Keep full resolution (no dynamic resolution)](#adapter-fixed_resolution) | `adapter.fixed_resolution` | Game settings | 1 |
|
||||
| [Keep full resolution (no dynamic resolution)](#adapter-fixed_resolution) | `adapter.fixed_resolution` | Game settings | 2 |
|
||||
| [Upside-down panel images (diagnostics)](#adapter-flip_dump) | `adapter.flip_dump` | Game settings | |
|
||||
| [Fix upside-down menus](#adapter-flip_emul) | `adapter.flip_emul` | Game settings | |
|
||||
| [Rotate panels 180°](#adapter-flip_quads) | `adapter.flip_quads` | Game settings | |
|
||||
@@ -147,7 +148,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
|
||||
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
|
||||
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 120 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 122 |
|
||||
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
|
||||
| [Turn off Quest Vulkan foveation](#adapter-hide_fb_foveation) | `adapter.hide_fb_foveation` | Game settings | |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
|
||||
@@ -165,12 +166,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Animate fingers from touch](#adapter-proximity_emul) | `adapter.proximity_emul` | Game settings | |
|
||||
| [Controller buttons](#adapter-quest_buttons) | `adapter.quest_buttons` | Game settings | |
|
||||
| [Keep pictures in bounds](#adapter-rect_clamp) | `adapter.rect_clamp` | Game settings | |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 5 |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 6 |
|
||||
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
|
||||
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
|
||||
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 18 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Use room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 20 |
|
||||
| [Use room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 21 |
|
||||
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
|
||||
| [Hide play-area size](#adapter-scene_hide_bounds) | `adapter.scene_hide_bounds` | Game settings | 1 |
|
||||
| [Scan when requested](#adapter-scene_scan_request) | `adapter.scene_scan_request` | Game settings | |
|
||||
@@ -203,11 +204,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 6 |
|
||||
| [Headset model](#device-headset_model) | `device.headset_model` | Files and settings on the Frame | default |
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Hybrid app: allow the switch into VR](#device-hybrid_app) | `device.hybrid_app` | Files and settings on the Frame | |
|
||||
| [Game language](#device-language) | `device.language` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Share a folder with another game](#device-shared_folder) | `device.shared_folder` | Files and settings on the Frame | |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 134 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 135 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -452,7 +454,7 @@ Stops some Unreal games from closing themselves right after starting.
|
||||
- `patch_remove_unreal_force_quit`
|
||||
- **Changes:** Changes the APK (during the OVRPort conversion).
|
||||
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
|
||||
- **Catalog games (49):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Darksword, Espire 1: VR Operative (Quest Edition) and 41 more.
|
||||
- **Catalog games (50):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Darksword, Espire 1: VR Operative (Quest Edition) and 42 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `force-quit` (error): The game quit itself right after starting.
|
||||
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
|
||||
@@ -527,7 +529,7 @@ Runs a 32-bit game (Gear VR, Oculus Go, early Quest) on the Frame, which only ru
|
||||
- `frame.a32_bridge` · experimental · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** 32-bit-only game: the Frame can't run 32-bit ARM code, the bridge translates it.
|
||||
- **Catalog games (6):** AFFECTED: The Manor, Gunjack, Journey of the Gods, Shadow Point, Sports Scramble (Santa Cruz), Spunky Dungeon.
|
||||
- **Catalog games (7):** AFFECTED: The Manor, First Contact, Gunjack, Journey of the Gods, Shadow Point, Sports Scramble (Santa Cruz), Spunky Dungeon.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `a32-jit-overflow` (fatal): The 32-bit bridge's translator stopped the game on a long run of instructions it hands to its interpreter, for example OpenSSL's ARMv8 SHA-1 code in Pinball FX2 VR.
|
||||
- `a32-video-ndk` (error): A 32-bit game on the bridge can't play its videos through Unity's video player (Ghost Giant's intro stays black).
|
||||
@@ -600,7 +602,7 @@ Stops bHaptics from asking for a Bluetooth permission the Frame doesn't have (th
|
||||
- `frame.bhaptics_permission` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** bHaptics asks for an Android 12 Bluetooth permission at start; the permission screen interrupts the game on the Frame.
|
||||
- **Catalog games (11):** Darksword, Deisim, Epic Roller Coasters, Eternal Starlight, Quantum Void, Ragnarock, Smash Drums, Tea For God and 3 more.
|
||||
- **Catalog games (12):** Darksword, Deisim, Epic Roller Coasters, Eternal Starlight, Quantum Void, Ragnarock, Smash Drums, SynthRiders and 4 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `bhaptics-permission-prompt` (error): The game asked for Android 12's Bluetooth permission (bHaptics' vest SDK, for example Yupitergrad).
|
||||
- **Source:** [patches/frame/bhaptics_permission.py](../src/frameport/patches/frame/bhaptics_permission.py)
|
||||
@@ -734,7 +736,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
- `frame.hw_video_decode` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
|
||||
- **Catalog games (42):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Darksword and 34 more.
|
||||
- **Catalog games (43):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Darksword and 35 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.
|
||||
@@ -752,6 +754,19 @@ Makes Just Dance VR keep your progress between sessions (it only kept it online,
|
||||
|
||||
> Just Dance VR (GitHub #480) loads its progress from a local save at start, but the method that writes it (LocalSaveEvents.RequestSave) is empty: on a Quest the progress is kept by Ubisoft's online backend after a Meta sign-in, which the Frame doesn't have, so progress, unlocks and the finished tutorial were lost at every restart. The method gets the body it is declared with (raise the SaveValueRequested event the game's own save system listens to), built from its neighbour SaveSystemReady and placed in a new code segment of libil2cpp.so; everything is found per build with Cpp2IL (downloaded on first use). A build that already writes its save is left unchanged. No sign-in or online data is touched.
|
||||
|
||||
### <a id="frame-keep_system_libraries"></a>Keep Android's shared libraries the app uses
|
||||
|
||||
Lets apps start that need one of Android's optional libraries (for example YouTube VR).
|
||||
|
||||
- `frame.keep_system_libraries` · on by default
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `system-library-missing` (fatal): The app closed at start because it can't find classes from one of Android's optional libraries (for example org.apache.http.legacy in YouTube VR).
|
||||
- **Source:** [patches/frame/system_libraries.py](../src/frameport/patches/frame/system_libraries.py)
|
||||
|
||||
> OVRPort removes every <uses-library> entry from the manifest (Quest-only libraries would stop the install), including ones the Frame's Android has. An app that needs one of those then can't find its classes and closes at start (for example YouTube VR: ClassNotFoundException org.apache.http.protocol.HttpRequestHandler, from org.apache.http.legacy). This puts back the entries for libraries the Frame provides, marked optional.
|
||||
|
||||
### <a id="frame-langpacks"></a>Language packs from the game's files
|
||||
|
||||
Lets the game use language files already in its data instead of waiting for a download that never comes.
|
||||
@@ -833,7 +848,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
|
||||
- **Suggested (on):** Uses Meta's room or anchor APIs: their permissions must be granted, or the game never asks for the room (for example NOPE's MR mode).
|
||||
- **Catalog games (9):** Arkio, BattleTalent, Demeter, Laser Dance, Movie Deck for Plex, Punch Fit, Real VR Fishing, Test Quest MR and 1 more.
|
||||
- **Catalog games (10):** Arkio, BattleTalent, Demeter, Laser Dance, Movie Deck for Plex, Punch Fit, Real VR Fishing, SynthRiders and 2 more.
|
||||
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
|
||||
|
||||
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
|
||||
@@ -1101,7 +1116,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (27):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 19 more.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -1156,7 +1171,7 @@ Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D
|
||||
- `frame.unity_oculus_check` · revision 5
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity with built-in Oculus support: it checks for Meta's system apps before starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).
|
||||
- **Catalog games (14):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, I Expect You To Die 2, Jurassic World Aftermath Collection and 6 more.
|
||||
- **Catalog games (15):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, I Expect You To Die 2, Invasion and 7 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-vr-device-none` (fatal): Unity didn't start VR, so the game runs as a hidden 2D app.
|
||||
- **Source:** [patches/frame/unity_oculus_check.py](../src/frameport/patches/frame/unity_oculus_check.py)
|
||||
@@ -1170,7 +1185,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (33):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 25 more.
|
||||
- **Catalog games (34):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 26 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -1198,7 +1213,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (135):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 127 more.
|
||||
- **Catalog games (136):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 128 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1265,6 +1280,7 @@ Lets the game load: it reserves more memory at start than the Frame grants in on
|
||||
- `frame.unreal_noreserve` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unreal's Binned3 allocator reserves more memory at start than the Frame's kernel allows in one piece; without this the game crashes while it loads.
|
||||
- **Catalog games (1):** Zero Caliber 2.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-reserve-refused` (fatal): The game crashed while Unreal was still loading, before its first frame.
|
||||
- **Source:** [patches/frame/unreal_noreserve.py](../src/frameport/patches/frame/unreal_noreserve.py)
|
||||
@@ -1750,7 +1766,7 @@ Prevents dynamic resolution from moving Valve foveation off-centre or leaving a
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Picture → "Keep full resolution (no dynamic resolution)" (advanced).
|
||||
- **Catalog games (1):** Batman: Arkham Shadow.
|
||||
- **Catalog games (2):** Batman: Arkham Shadow, Zero Caliber 2.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `dynamic-resolution-foveation` (info): The game submits an offset projection viewport.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
@@ -1944,7 +1960,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
|
||||
- **Catalog games (120):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Barbaria, Battlemarked, BattleSisters and 112 more.
|
||||
- **Catalog games (122):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Barbaria, Battlemarked, BattleSisters and 114 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
|
||||
@@ -2210,7 +2226,9 @@ How often the picture updates. Higher is smoother if the game can keep up.
|
||||
- **Options:**
|
||||
- Value: float, default `0.0`.
|
||||
- Game settings → Picture → "Refresh rate": Game's choice (`0`), 72 Hz (`72`), 80 Hz (`80`), 90 Hz (`90`), 96 Hz (`96`), 108 Hz (`108`), 120 Hz (`120`), 144 Hz (`144`).
|
||||
- **Catalog games (5):** Astro VR Host, Does it Stack?, Majora's Mask VR, Simpsons Hit and Run VR, Vacation Simulator.
|
||||
- **Catalog games (6):** Astro VR Host, Does it Stack?, Majora's Mask VR, Port0r: Time Crisis VR, Simpsons Hit and Run VR, Vacation Simulator.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `refresh-rate-refused` (error): The game asked for a refresh rate the Frame's runtime refuses and may quit on that error.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). Applied through SteamVR before the game starts. A video's frame rate that divides the refresh rate plays smoothest (for example 30 fps at 90 Hz, 24 fps at 72 Hz).
|
||||
@@ -2282,7 +2300,7 @@ Scene games get your saved room. Without one, they get a simple room from the pl
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Mixed reality → "Use room layout".
|
||||
- **Catalog games (20):** Arkio, AugmentedEmpire, Battlemarked, BattleTalent, cubism, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord and 12 more.
|
||||
- **Catalog games (21):** Arkio, AugmentedEmpire, Battlemarked, BattleTalent, cubism, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord and 13 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `mr-discovery-unsupported` (warning): The game cannot discover a Meta room.
|
||||
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
|
||||
@@ -2770,6 +2788,19 @@ Hides Android's back, home and recents buttons, which cover the app's own button
|
||||
|
||||
> Removes Android's back/home/recents buttons from a 2D app's window, where they cover the app's own controls. Turn it off for an app that needs the on-screen back button.
|
||||
|
||||
### <a id="device-hybrid_app"></a>Hybrid app: allow the switch into VR
|
||||
|
||||
Lets apps that start as a 2D panel (e.g. YouTube) switch into VR for 360° videos and full screen.
|
||||
|
||||
- `device.hybrid_app` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `hybrid-feature-missing` (warning): The app stays in its 2D panel because it can't find a Quest system feature it checks before switching into VR (360° videos, full screen). "Hybrid app: allow the switch into VR" declares that feature for the app.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Horizon OS "hybrid apps" start as a 2D panel and switch into VR for some content (e.g. YouTube's 360° videos and full screen). Before the switch the app checks that the system has the feature oculus.software.vr.app.hybrid; the Frame's Android doesn't, so the app stays in its panel ("Missing hybrid app system feature" in its log). This declares the feature in the app's container (a permissions file mounted by FramePort's Podman wrapper; the APK isn't changed). Whether the app's VR part then works on the Frame depends on the app (GitHub #484). Applied with Update on Frame.
|
||||
|
||||
### <a id="device-language"></a>Game language
|
||||
|
||||
Choose the language for games that follow the system language. The game must include it. Apply with Update on Frame.
|
||||
@@ -2832,7 +2863,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The app opens a flat launcher that starts its VR part (…): Lepton runs VR apps headless, so the launcher is only seen in a shown Android window.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (134):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Attack on Titan VR: Unbreakable, Audica and 126 more.
|
||||
- **Catalog games (135):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Attack on Titan VR: Unbreakable, Audica and 127 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). While the game runs, FramePort turns off SteamVR's automatic theater mode for game windows: it put the Android window in front of the player with the controllers as a mouse pointer, and the game got no controller input. The window stays a page of the game in the dashboard. Pairs with "Make Unity text fields work without a system keyboard". Apps that open a flat launcher before their VR part (for example Xelee or WiiCompiled, whose launchers pick the game) need the window too: headless, the launcher is never seen and the VR part never starts. For those, theater mode stays on, so the launcher is in front of you; once the VR part runs, close the dashboard with the controller's button if it opened on the window.
|
||||
|
||||
@@ -117,6 +117,8 @@ longer investigations.
|
||||
| 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`, for example `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) gray 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) |
|
||||
| App closes at start: `ClassNotFoundException: Didn't find class "org.apache.http.protocol.HttpRequestHandler"` / `NoClassDefFoundError: Failed resolution of: Lorg/apache/http/…` (GitHub #484, YouTube VR; triage `system-library-missing`, also for the other libraries below) | The original manifest asks for `<uses-library android:name="org.apache.http.legacy" android:required="false"/>`; OVRPort's `patch_remove_uses_library` (default on) drops every `<uses-library>`, so Android 9+ doesn't put the legacy Apache HTTP classes on the class path of an app targeting API 28+. Lepton's Android 11 has them (/system/etc/permissions/org.apache.http.legacy.xml) | `frame.keep_system_libraries` (automatic when the original manifest names one of `system_libraries.LEPTON_LIBRARIES`, analysis `extra.uses_libraries`): puts those entries back after OVRPort, always `required="false"`; Update on Frame |
|
||||
| A 2D panel app (YouTube for Quest) stays in its panel: full screen and 360°/immersive buttons do nothing, log `Missing hybrid app system feature. Aborting launch of immersive mode.` (GitHub #484) | Horizon OS hybrid apps check for the system feature `oculus.software.vr.app.hybrid` (manifest `uses-feature … required="false"`) before they start their VR activity; the Frame's Android lacks it | opt-in patch `device.hybrid_app` (offered when the manifest names the feature; no APK change, Update on Frame): the launcher exports `FRAMEPORT_FEATURES` and puts FramePort's Podman wrapper on PATH, which mounts a permissions file at `/system/etc/permissions/frameport-features.xml`. Check: `podman exec lepton-steamlaunch-<appid> pm list features` lists the feature, launch.log has `FramePort: system features for this container`. Verified headless that Android picks the feature up; whether the app's VR activity then runs is untested |
|
||||
| `JNI DETECTED ERROR`, `GetStringUTFChars … NULL` | CheckJNI is on because overport marks the app debuggable | `frame.nodebug` |
|
||||
| Unreal game shows a one-time "optimizing" screen on its very first start, then closes (`System.exit` status 0 right after the system log's `ActivityTaskManager: START … LAUNCHER … pkg=<own package> … from uid 10xxx`, Lepton "Exited!"); the next start works | UE 4.21+ restarts the app after its first-run shader precompile (`r.ProgramBinaryCache.RestartAndroidAfterPrecompile`, `AndroidThunkJava_RestartApplication`); Lepton ends the container with the first process, the restarted one with it (Sports Scramble). Skipping the restart or setting the cvar to 0 (`-ini:Engine:[ConsoleVariables]:…` in UE4CommandLine.txt: read, no restart requested) left Sports Scramble on the "optimizing" level for good: the game relies on the restart | launch.sh starts Lepton once more (`_restart_requested` matches both log lines within 5 s; `launch-before-restart.log` keeps the first run; `FRAMEPORT_NO_RESTART=1` turns it off). Works for 32- and 64-bit games |
|
||||
| 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`) |
|
||||
@@ -188,6 +190,7 @@ longer investigations.
|
||||
| Video in a game or player stutters, falls behind or drops to a few fps (4K/8K, for example a video player's 360° video, Batman's cutscenes); `OMX.google.*` / `c2.android.*` decoders in the log | Lepton's Android has only software decoders | `frame.hw_video_decode` (suggested when the APK uses MediaCodec/ExoPlayer/VLC; no APK change, reinstall): `OMX.frameport.{avc,hevc,vp9}.decoder` on the Frame's Iris hardware (H.264 High, HEVC Main, VP9 profile 0 up to 4096x2304; 8-bit; up to 8192 px), mounted into this game's container only (log `Iris hardware video/… decoder active`; launch.log `FramePort video: loading the Iris hardware codec plugin`). `Iris … unavailable` / `initialization failed (-12)` (triage `hw-video-decoder-busy`): another decoder holds the hardware, usually Steam's own hardware video decoding: turn it off in Steam, restart Steam. Video broken with it: Settings → Installing → Hardware video decoding off (all games) or `FRAMEPORT_NO_HW_VIDEO=1 %command%` (one game) |
|
||||
| `Rec.709 color space unavailable`, the game quits before xrCreateSession (GitHub #336, OutRun Quest VR) | The Frame's runtime has no XR_FB_color_space | Adapter setting `fb_color_space=1` (triage `color-space-missing` suggests it): FrameBridge offers the extension (v3), reports Rec.709 and accepts any colour space without converting. Reaches games with their own OpenXR loader; OVRPort's dispatcher can't forward the new functions. Device-unverified |
|
||||
| `AppSW recommended buffers unavailable`, the game quits at start with or without XR_FB_space_warp (GitHub #336, OutRun Quest VR) | The game requires Meta's Application SpaceWarp; the Frame's runtime reports no motion-vector size and its GLES swapchains take only sRGB colour formats. `hide_space_warp` can't help (the game refuses to start without the extension) | Adapter setting `space_warp_emul=1` (triage `appsw-required`): FrameBridge offers XR_FB_space_warp, reports a motion-vector size (the runtime's, else eye/4), serves the motion-vector + space-warp depth swapchains as its own GL textures / Vulkan images and strips `XrCompositionLayerSpaceWarpInfoFB` at xrEndFrame. Log `space_warp_emul:`. docs/DEVICE_NOTES.md#xr_fb_space_warp-emulation-336 |
|
||||
| Log: `request display Hz: -1000101000` (XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB), then the game destroys its instance right after its first frames (GitHub #336, OutRun Quest VR) | The game asked for a refresh rate the runtime refuses and treats that as fatal | Fixed in FrameBridge: a refused or unsupported `xrRequestDisplayRefreshRateFB` keeps the current rate and returns success (log `refresh rate: app asked for …`); rebuild with Update on Frame. Older builds: Game settings → Refresh rate = 90 Hz |
|
||||
|
||||
Foveation mode, area size and diagnostic test are per-game user preferences (discussion #365), excluded from
|
||||
shared working recipes. Saving Game settings rewrites launcher exports through `set_settings`; changes apply
|
||||
@@ -256,6 +259,7 @@ support. Catalog `device.foveation` defaults and the guarded Batman/Climb 2 APK
|
||||
|---|---|---|
|
||||
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
|
||||
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
|
||||
| Build fails at once: `Input APK is not a file: …LEGO?? Bricktales/…` (a folder or file name with ®, ™, accents or CJK shows `?`) | FramePort on Linux or on the Frame started Java under a C/POSIX locale, so Java decoded the path as ASCII (GitHub #487, fixed) | Update FramePort (`toolchain.java_env` gives Java `LC_ALL=C.UTF-8` when the locale isn't UTF-8); older versions: rename the folder without those characters and rescan |
|
||||
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
|
||||
| An install button opens another program (or nothing) instead of FramePort | another program owns the link type, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Add from a link… (paste the button's address) |
|
||||
| Game page shows another game's description or screenshots, or they stay wrong after Rename (GitHub #377: "Lollipop" matched an adult Steam game) | Store details were looked up by title and kept after a rename; Steam exact-title matches had no content check | Rename/reset drops title-matched details and screenshots and fetches them again under the new name (package-matched details and custom art stay); Steam matches with sexual-content descriptors are rejected for details and artwork |
|
||||
|
||||
@@ -2711,12 +2711,13 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
HOOK_AS(xrDestroySession, hook_xrDestroySession)
|
||||
}
|
||||
if (layer_debug || refresh_rate > 0 || equirect_emul) HOOK_AS(xrBeginSession, hook_xrBeginSession)
|
||||
if (layer_debug || refresh_rate > 0) {
|
||||
// The Frame's runtime has no XR_FB_display_refresh_rate (newer SteamOS lists it), but OVRPort's dispatcher offers
|
||||
// it to the game and answers 0 Hz without it: the measured rate instead (the runtime's own answer when it has one),
|
||||
// and a request for a rate the runtime refuses keeps the current one (GitHub #336).
|
||||
if (instance != XR_NULL_HANDLE) {
|
||||
HOOK_AS(xrGetDisplayRefreshRateFB, hook_get_refresh_rate)
|
||||
HOOK_AS(xrRequestDisplayRefreshRateFB, hook_request_refresh_rate)
|
||||
}
|
||||
// The Frame's runtime has no XR_FB_display_refresh_rate, but OVRPort's dispatcher offers it to the game and answers
|
||||
// 0 Hz without it: the measured rate instead (the runtime's own answer when it has one).
|
||||
if (instance != XR_NULL_HANDLE) HOOK_AS(xrGetDisplayRefreshRateFB, hook_get_refresh_rate)
|
||||
if (surface_emul) HOOK_AS(xrCreateSwapchainAndroidSurfaceKHR, hook_xrCreateSwapchainAndroidSurfaceKHR)
|
||||
if (eye_debug || release_wait) HOOK_AS(xrReleaseSwapchainImage, eye_hook_xrReleaseSwapchainImage)
|
||||
if (swapchain_pool || layer_debug || cube_standin) { // pooled images, counters + cube stand-ins; every other wait/release hook above takes precedence
|
||||
|
||||
@@ -737,12 +737,16 @@ static void refresh_on_begin_session(XrSession session) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_request_refresh_rate(XrSession session, float rate) {
|
||||
PFN_xrRequestDisplayRefreshRateFB fn =
|
||||
(PFN_xrRequestDisplayRefreshRateFB)lookup(active_instance, "xrRequestDisplayRefreshRateFB");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
float wanted = refresh_rate > 0 ? refresh_rate : rate;
|
||||
XrResult result = fn(session, wanted);
|
||||
XrResult result = fn ? fn(session, wanted) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
LOG("refresh rate: app asked for %.1f Hz, requested %.1f Hz: result %d", rate, wanted, result);
|
||||
// A refused override keeps the current rate (as frame-control's compat layer does) instead of failing the app.
|
||||
return XR_FAILED(result) && refresh_rate > 0 ? XR_SUCCESS : result;
|
||||
// A refused request keeps the current rate (as frame-control's compat layer does) instead of failing the app: an
|
||||
// override always, the app's own rate when the runtime doesn't offer it (OutRun quit on
|
||||
// XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB right after its first frames, GitHub #336).
|
||||
if (XR_FAILED(result) && (refresh_rate > 0 || result == XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB ||
|
||||
result == XR_ERROR_FUNCTION_UNSUPPORTED || result == XR_ERROR_FEATURE_UNSUPPORTED))
|
||||
return XR_SUCCESS;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Always on: the Frame's runtime has no XR_FB_display_refresh_rate; OVRPort's dispatcher still offers the extension and
|
||||
|
||||
+18
-1
@@ -53,6 +53,21 @@ def check_bundle(out: Path) -> None:
|
||||
print("agent source in bundle:", ", ".join(str(p.relative_to(out)) for p in found))
|
||||
|
||||
|
||||
def write_file_list(out: Path) -> None:
|
||||
"""The bundle's own file list (frameport/core/bundle_files.py): the app removes, or never imports, modules an
|
||||
update left from another version, and says so when the install mixes versions (GitHub #476)."""
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
from frameport.core import bundle_files
|
||||
|
||||
pkgs = sorted({p.parent for name in ("_version.pyc", "_version.py") for p in out.rglob(name)
|
||||
if p.parent.name == "frameport" and (p.parent / "patches").is_dir()})
|
||||
if not pkgs: # e.g. the package sits in an archive (PyInstaller): nothing an update can leave behind
|
||||
print(f"{out}: no frameport package folder, no file list")
|
||||
return
|
||||
for pkg in pkgs:
|
||||
print(f"file list: {bundle_files.write(pkg)} files in {pkg.relative_to(out)}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--pyinstaller", action="store_true")
|
||||
@@ -84,7 +99,9 @@ def main() -> int:
|
||||
print(" ".join(cmd))
|
||||
rc = subprocess.call(cmd, cwd=ROOT)
|
||||
if rc == 0:
|
||||
check_bundle(ROOT / "dist" if args.pyinstaller else ROOT / "dist" / TARGET)
|
||||
out = ROOT / "dist" if args.pyinstaller else ROOT / "dist" / TARGET
|
||||
check_bundle(out)
|
||||
write_file_list(out)
|
||||
return rc
|
||||
finally:
|
||||
if not args.keep_data:
|
||||
|
||||
+65
-14
@@ -3,10 +3,14 @@
|
||||
|
||||
python scripts/update_smoke.py FramePort-<os>.<zip|tar.gz>
|
||||
|
||||
It unpacks the archive twice (an "installed" copy, plus an older-looking copy of it with a stale marker file), then
|
||||
runs the real update path: frameport.updates._extract (ditto on macOS) + the real swap script (PowerShell on Windows,
|
||||
/bin/sh elsewhere), without relaunching. It checks the installed copy now has the new files, and on Windows that the
|
||||
signer check sees the same certificate on both copies. Exits non-zero on any failure.
|
||||
It unpacks the archive twice (an "installed" copy, plus an older-looking copy of it with stale files), then runs the
|
||||
real update path: frameport.updates._extract (ditto on macOS) + the real swap script (PowerShell on Windows, /bin/sh
|
||||
elsewhere), without relaunching. It checks the installed copy now has the new files and none of the old version's
|
||||
leftovers, and on Windows that the signer check sees the same certificate on both copies.
|
||||
|
||||
Windows also runs the update of GitHub #476 first: a program file stays locked, so the copy fails half-way; the
|
||||
rollback must leave exactly the old version (no file the new version added, every old file back), and a user's own
|
||||
file next to FramePort.exe is never touched. Exits non-zero on any failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +24,37 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
|
||||
def _lock(path: Path):
|
||||
"""Windows: open `path` for reading and let others only read it, like a program that holds it open: the backup
|
||||
copy can read it, Copy-Item can't write over it."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.CreateFileW.restype = wintypes.HANDLE
|
||||
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
||||
wintypes.DWORD, wintypes.HANDLE]
|
||||
# GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING
|
||||
handle = k32.CreateFileW(str(path), 0x80000000, 1, None, 3, 0, None)
|
||||
if handle in (None, wintypes.HANDLE(-1).value):
|
||||
raise OSError(ctypes.get_last_error(), f"couldn't lock {path}")
|
||||
return lambda: k32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _run(updates, new: Path, installed: Path, platform: str, log_file: Path) -> str:
|
||||
"""The same launch as the app (detached; apply() checks the script started), then wait for it to finish."""
|
||||
start = log_file.stat().st_size if log_file.exists() else 0
|
||||
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform)
|
||||
log = ""
|
||||
for _ in range(240):
|
||||
log = log_file.read_bytes()[start:].decode(errors="replace") if log_file.exists() else ""
|
||||
if "not relaunching" in log:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
print(script.read_text()[:300], "...\n--- update.log ---\n" + log)
|
||||
return log
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import logging
|
||||
|
||||
@@ -43,28 +78,44 @@ def main() -> int:
|
||||
sub = next(p for p in sorted(marker.parent.iterdir()) if p.is_dir() and not p.is_symlink())
|
||||
nested = sub / "new-version.txt" # files in existing subfolders must be replaced too (Windows merges folders)
|
||||
nested.write_text("new")
|
||||
# a module only the old version has, in the bundle's own folders (GitHub #476: Python imports whatever it finds)
|
||||
leftover = installed / sub.relative_to(new) / "old_only_module.pyc"
|
||||
leftover.write_text("old")
|
||||
if platform == "win32" and os.environ.get("HAS_CERT") == "true":
|
||||
a = updates._signer_thumbprint(installed / "FramePort.exe")
|
||||
b = updates._signer_thumbprint(new / "FramePort.exe")
|
||||
print(f"signer thumbprints: installed {a} new {b}")
|
||||
assert a and a == b, "signature check failed"
|
||||
# the same launch as the app (detached; apply() checks the script started), then wait for it to finish
|
||||
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform)
|
||||
log_file = home / "data/logs/update.log"
|
||||
for _ in range(240):
|
||||
log = log_file.read_text() if log_file.exists() else ""
|
||||
if "not relaunching" in log:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
print(script.read_text()[:400], "...\n--- update.log ---\n" + log)
|
||||
installed_nested = installed / nested.relative_to(new)
|
||||
if platform == "win32":
|
||||
# a file copied after the app folder is held open: the copy fails half-way and is rolled back
|
||||
last_dir = sorted((p for p in new.iterdir() if p.is_dir()), key=lambda p: p.name.lower())[-1]
|
||||
held = next(p for p in sorted((installed / last_dir.name).rglob("*"))
|
||||
if p.is_file() and p.suffix.lower() not in (".exe", ".dll", ".pyd"))
|
||||
print(f"holding {held.relative_to(installed)} open")
|
||||
release = _lock(held)
|
||||
try:
|
||||
log = _run(updates, new, installed, platform, log_file)
|
||||
finally:
|
||||
release()
|
||||
assert "update FAILED" in log, "the update should have failed on the locked file"
|
||||
assert not installed_nested.exists(), "a file the new version added was left after the rollback (#476)"
|
||||
assert leftover.read_text() == "old", "the rollback didn't bring back the old version's file"
|
||||
assert stale.read_text() == "from the old version", "a file next to FramePort.exe was touched"
|
||||
log = _run(updates, new, installed, platform, log_file)
|
||||
installed_marker = installed / marker.relative_to(new)
|
||||
assert installed_marker.read_text() == "new", "the new files weren't installed"
|
||||
assert (installed / nested.relative_to(new)).read_text() == "new", "a subfolder wasn't updated"
|
||||
assert installed_nested.read_text() == "new", "a subfolder wasn't updated"
|
||||
assert "installed" in log and "FAILED" not in log, "the update script reported a failure"
|
||||
assert not leftover.exists(), "a module of the old version is still there (#476)"
|
||||
exe = {"win32": installed / "FramePort.exe", "darwin": installed / "Contents/MacOS"}.get(platform,
|
||||
installed / "FramePort")
|
||||
assert exe.exists(), f"{exe} missing after the update"
|
||||
if platform != "win32": # Windows copies over the old files (the zip has no folder of its own); others swap
|
||||
if platform == "win32": # Windows copies over the old files (the zip has no folder of its own)
|
||||
assert stale.read_text() == "from the old version", "a file next to FramePort.exe was removed"
|
||||
assert (home / "previous" / leftover.relative_to(installed)).exists(), "the removed file isn't kept"
|
||||
else: # others swap the whole folder
|
||||
assert not stale.exists(), "the old version's files are still there"
|
||||
print("update smoke test passed")
|
||||
return 0
|
||||
|
||||
+283
-112
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"updated": "2026-10-19",
|
||||
"updated": "2026-10-25",
|
||||
"people": [
|
||||
{
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 783,
|
||||
"commits": 981,
|
||||
"issues": 6,
|
||||
"prs": 5,
|
||||
"total": 826
|
||||
"prs": 6,
|
||||
"total": 1029
|
||||
},
|
||||
{
|
||||
"login": "Izimzizi",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/77581918?v=4",
|
||||
"url": "https://github.com/Izimzizi",
|
||||
"commits": 0,
|
||||
"issues": 21,
|
||||
"issues": 24,
|
||||
"prs": 0,
|
||||
"total": 63
|
||||
"total": 72
|
||||
},
|
||||
{
|
||||
"login": "tkp206093",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/29872453?v=4",
|
||||
"url": "https://github.com/tkp206093",
|
||||
"commits": 0,
|
||||
"issues": 21,
|
||||
"issues": 22,
|
||||
"prs": 0,
|
||||
"total": 63
|
||||
"total": 66
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
@@ -60,9 +60,27 @@
|
||||
"avatar": "https://avatars.githubusercontent.com/u/40860703?v=4",
|
||||
"url": "https://github.com/Genosystem",
|
||||
"commits": 0,
|
||||
"issues": 13,
|
||||
"issues": 14,
|
||||
"prs": 0,
|
||||
"total": 39
|
||||
"total": 42
|
||||
},
|
||||
{
|
||||
"login": "Nikoroq",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/135438574?v=4",
|
||||
"url": "https://github.com/Nikoroq",
|
||||
"commits": 0,
|
||||
"issues": 14,
|
||||
"prs": 0,
|
||||
"total": 42
|
||||
},
|
||||
{
|
||||
"login": "supercoleman04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/342592118?v=4",
|
||||
"url": "https://github.com/supercoleman04",
|
||||
"commits": 0,
|
||||
"issues": 14,
|
||||
"prs": 0,
|
||||
"total": 42
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
@@ -73,6 +91,15 @@
|
||||
"prs": 5,
|
||||
"total": 37
|
||||
},
|
||||
{
|
||||
"login": "brunoga",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1944863?v=4",
|
||||
"url": "https://github.com/brunoga",
|
||||
"commits": 0,
|
||||
"issues": 11,
|
||||
"prs": 0,
|
||||
"total": 33
|
||||
},
|
||||
{
|
||||
"login": "Sheebalove",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/176067652?v=4",
|
||||
@@ -82,15 +109,6 @@
|
||||
"prs": 0,
|
||||
"total": 33
|
||||
},
|
||||
{
|
||||
"login": "brunoga",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1944863?v=4",
|
||||
"url": "https://github.com/brunoga",
|
||||
"commits": 0,
|
||||
"issues": 10,
|
||||
"prs": 0,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "LynkWyland",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/54166762?v=4",
|
||||
@@ -109,15 +127,6 @@
|
||||
"prs": 4,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "supercoleman04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/342592118?v=4",
|
||||
"url": "https://github.com/supercoleman04",
|
||||
"commits": 0,
|
||||
"issues": 10,
|
||||
"prs": 0,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "inseend",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
|
||||
@@ -127,20 +136,20 @@
|
||||
"prs": 0,
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "jim2011",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/15848374?v=4",
|
||||
"url": "https://github.com/jim2011",
|
||||
"commits": 0,
|
||||
"issues": 8,
|
||||
"prs": 0,
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "Jmckenzie84",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/71196491?v=4",
|
||||
"url": "https://github.com/Jmckenzie84",
|
||||
"commits": 0,
|
||||
"issues": 9,
|
||||
"prs": 0,
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "jim2011",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/15848374?v=4",
|
||||
"url": "https://github.com/jim2011",
|
||||
"commits": 0,
|
||||
"issues": 8,
|
||||
"prs": 0,
|
||||
"total": 24
|
||||
@@ -163,6 +172,24 @@
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
},
|
||||
{
|
||||
"login": "greggray24",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/255593097?v=4",
|
||||
"url": "https://github.com/greggray24",
|
||||
"commits": 0,
|
||||
"issues": 6,
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
},
|
||||
{
|
||||
"login": "pinbear-creator",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/194687704?v=4",
|
||||
"url": "https://github.com/pinbear-creator",
|
||||
"commits": 0,
|
||||
"issues": 6,
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
},
|
||||
{
|
||||
"login": "buildmine10",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/33100363?v=4",
|
||||
@@ -191,18 +218,27 @@
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "greggray24",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/255593097?v=4",
|
||||
"url": "https://github.com/greggray24",
|
||||
"login": "jeliastam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
|
||||
"url": "https://github.com/jeliastam",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "jeliastam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
|
||||
"url": "https://github.com/jeliastam",
|
||||
"login": "Natouille04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
|
||||
"url": "https://github.com/Natouille04",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "s1lenceSRG",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/340965322?v=4",
|
||||
"url": "https://github.com/s1lenceSRG",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
@@ -235,6 +271,15 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "freshkleensucks",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/347648558?v=4",
|
||||
"url": "https://github.com/freshkleensucks",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Loafmeister",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
|
||||
@@ -254,18 +299,18 @@
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Natouille04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
|
||||
"url": "https://github.com/Natouille04",
|
||||
"login": "nickwilde77555",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
|
||||
"url": "https://github.com/nickwilde77555",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "nickwilde77555",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
|
||||
"url": "https://github.com/nickwilde77555",
|
||||
"login": "pocketpal69",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/339918777?v=4",
|
||||
"url": "https://github.com/pocketpal69",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
@@ -316,6 +361,15 @@
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "alicecatpaw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/7423859?v=4",
|
||||
"url": "https://github.com/alicecatpaw",
|
||||
"commits": 6,
|
||||
"issues": 0,
|
||||
"prs": 1,
|
||||
"total": 11
|
||||
},
|
||||
{
|
||||
"login": "AnthonyScola",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/98185049?v=4",
|
||||
@@ -334,6 +388,15 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "Kanobar",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/122025313?v=4",
|
||||
"url": "https://github.com/Kanobar",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "m4ddh7jm7c-bit",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338352097?v=4",
|
||||
@@ -344,9 +407,27 @@
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "pocketpal69",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/339918777?v=4",
|
||||
"url": "https://github.com/pocketpal69",
|
||||
"login": "marcoszaballos",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/44778619?v=4",
|
||||
"url": "https://github.com/marcoszaballos",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "Mitchlett",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
|
||||
"url": "https://github.com/Mitchlett",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "Momofier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
|
||||
"url": "https://github.com/Momofier",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
@@ -379,6 +460,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Callamuchi",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/346494602?v=4",
|
||||
"url": "https://github.com/Callamuchi",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "crointer",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/165038970?v=4",
|
||||
@@ -469,6 +559,15 @@
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "IAmFarb",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/342179991?v=4",
|
||||
"url": "https://github.com/IAmFarb",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "konga056",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/67296924?v=4",
|
||||
@@ -479,18 +578,18 @@
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "mamasboi2000",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/344123932?v=4",
|
||||
"url": "https://github.com/mamasboi2000",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"login": "lucasoskorep",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/17054135?v=4",
|
||||
"url": "https://github.com/lucasoskorep",
|
||||
"commits": 1,
|
||||
"issues": 0,
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "marcoszaballos",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/44778619?v=4",
|
||||
"url": "https://github.com/marcoszaballos",
|
||||
"login": "mamasboi2000",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/344123932?v=4",
|
||||
"url": "https://github.com/mamasboi2000",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
@@ -505,6 +604,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "MayhemMike",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/137106723?v=4",
|
||||
"url": "https://github.com/MayhemMike",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Minibattle",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
|
||||
@@ -515,18 +623,18 @@
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Momofier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
|
||||
"url": "https://github.com/Momofier",
|
||||
"login": "peetzweg",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/839848?v=4",
|
||||
"url": "https://github.com/peetzweg",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "peetzweg",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/839848?v=4",
|
||||
"url": "https://github.com/peetzweg",
|
||||
"login": "Puchor",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
|
||||
"url": "https://github.com/Puchor",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
@@ -550,6 +658,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Veticia",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/668573?v=4",
|
||||
"url": "https://github.com/Veticia",
|
||||
"commits": 1,
|
||||
"issues": 0,
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "yzfsoldier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/35410319?v=4",
|
||||
@@ -559,15 +676,6 @@
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "alicecatpaw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/7423859?v=4",
|
||||
"url": "https://github.com/alicecatpaw",
|
||||
"commits": 0,
|
||||
"issues": 0,
|
||||
"prs": 1,
|
||||
"total": 5
|
||||
},
|
||||
{
|
||||
"login": "Agent-Gato",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/141082538?v=4",
|
||||
@@ -685,6 +793,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Derek-Roberts",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/4860382?v=4",
|
||||
"url": "https://github.com/Derek-Roberts",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DolphinOfficial",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
|
||||
@@ -703,6 +820,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DylanDAG",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/149199149?v=4",
|
||||
"url": "https://github.com/DylanDAG",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "EdgarJLopez",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/82680990?v=4",
|
||||
@@ -748,6 +874,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Gei-1",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/102943357?v=4",
|
||||
"url": "https://github.com/Gei-1",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Haseo-sudo",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/344139069?v=4",
|
||||
@@ -757,6 +892,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "HaywireAlt",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/347392021?v=4",
|
||||
"url": "https://github.com/HaywireAlt",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "hokey1981",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
|
||||
@@ -766,6 +910,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "idkAnAlliasYetTbh",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/262686353?v=4",
|
||||
"url": "https://github.com/idkAnAlliasYetTbh",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "InsideCat02",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/111202797?v=4",
|
||||
@@ -811,15 +964,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Kanobar",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/122025313?v=4",
|
||||
"url": "https://github.com/Kanobar",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "LeaPhant",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/14080165?v=4",
|
||||
@@ -839,9 +983,9 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Mitchlett",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
|
||||
"url": "https://github.com/Mitchlett",
|
||||
"login": "mitch030504",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/40723072?v=4",
|
||||
"url": "https://github.com/mitch030504",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -866,9 +1010,9 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Nikoroq",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/135438574?v=4",
|
||||
"url": "https://github.com/Nikoroq",
|
||||
"login": "nabel0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/50377455?v=4",
|
||||
"url": "https://github.com/nabel0",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -901,24 +1045,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "pinbear-creator",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/194687704?v=4",
|
||||
"url": "https://github.com/pinbear-creator",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Puchor",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
|
||||
"url": "https://github.com/Puchor",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Rabbitbeam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22737155?v=4",
|
||||
@@ -929,9 +1055,45 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "s1lenceSRG",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/340965322?v=4",
|
||||
"url": "https://github.com/s1lenceSRG",
|
||||
"login": "RadFox34",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/36874376?v=4",
|
||||
"url": "https://github.com/RadFox34",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "rc2189",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/100491666?v=4",
|
||||
"url": "https://github.com/rc2189",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "ResKohza",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/347285306?v=4",
|
||||
"url": "https://github.com/ResKohza",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Runir",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/16932342?v=4",
|
||||
"url": "https://github.com/Runir",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "stanvill",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/119756631?v=4",
|
||||
"url": "https://github.com/stanvill",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -1000,6 +1162,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Venjjeance",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/34115864?v=4",
|
||||
"url": "https://github.com/Venjjeance",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "WhoPlaysTheFool",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/26016667?v=4",
|
||||
|
||||
@@ -439,6 +439,11 @@
|
||||
"title": "Hide Android's navigation bar",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.hybrid_app": {
|
||||
"anchor": "device-hybrid_app",
|
||||
"title": "Hybrid app: allow the switch into VR",
|
||||
"group": "Files and settings on the Frame"
|
||||
},
|
||||
"device.language": {
|
||||
"anchor": "device-language",
|
||||
"title": "Game language",
|
||||
@@ -549,6 +554,11 @@
|
||||
"title": "Just Dance VR: keep progress in the local save",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.keep_system_libraries": {
|
||||
"anchor": "frame-keep_system_libraries",
|
||||
"title": "Keep Android's shared libraries the app uses",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.langpacks": {
|
||||
"anchor": "frame-langpacks",
|
||||
"title": "Language packs from the game's files",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-19",
|
||||
"updated": "2026-10-25",
|
||||
"releases": [
|
||||
{
|
||||
"tag": "dev",
|
||||
"name": "FramePort dev build 1.3.1.dev703",
|
||||
"date": "2026-10-25",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/dev",
|
||||
"dev": true,
|
||||
"notes": "Dev build **1.3.1.dev703** from commit dc98903 (2026-10-25). For testing fixes before\nthe next release; you get that release as a normal update.\n\n## Please test\n\nYouTube and other Quest hybrid apps: new opt-in 'Hybrid app: allow the switch into VR' (Customize) for the VR part (360 videos, full screen) (#484). Also: Windows update fix (#476), expansion file names (#415), Steam library 'Now' installs (#305), Android shared libraries kept (#484).\n\n## Changes since v1.3.0\n\n- Hybrid apps: declare the system feature they check before switching into VR (#484)\n- Windows updates: relative paths from the folders' long names (8.3 install paths)\n- Keep Android's shared libraries OVRPort removes (#484)\n- Steam library (now): later installs no longer hang behind a waiting library update (#305)\n- Expansion files: other kinds get the game's package spelling too (#415)\n- Windows updates never mix two versions (#476)\n- Site: current app and docs; Controller buttons page; clearer wording\n- Docs: name the thing instead of 'it', 'this' or 'that' where the referent was unclear\n- Docs: themes, install button, controller buttons, diagnostics, contributing: tighter wording\n- Docs: install guide and FAQ: shorter, one idea per sentence\n- Docs: README and compatibility: no game count, shorter text\n- Docs: architecture: triage signatures now update from GitHub main too\n- Docs: third-party notices list Cpp2IL and APKEditor, which FramePort downloads on first use\n- Docs: transfer benchmark: remove local report paths, say what the uploader does today\n- Docs: runtime reference: version-neutral title, current Revive-under-Proton facts, no agent versions\n- Docs: playbook: fix the broken first table, current PC VR model, one section for FramePort's own problems\n- Docs: contributor pages: current menu labels, PC VR runs as-is by default, Linux apps, no agent versions\n- Docs: install guide and FAQ follow the new game menu, settings and save backups\n- Docs: README and compatibility pages: current game count, full docs index, Linux apps; regenerate the tested-games list\n- Catalog: Hungry Shark Evolution (#486)\n- Tests: shorter line in the shader-fix triage test\n- Shader fixes: say once when none of a game's configured fixes matched\n- Headset identity: other OVRPort runtimes found by structure, not fixed offsets\n- Just Dance VR local save: no game version check\n- Just Dance VR keeps its progress (#480): frame.justdance_local_save\n- Triage signatures update from GitHub main like the catalog\n- QuestZDoom slowdown (#237): Team Beef ports get gl_error_clear\n- Locales: template with the latest patch texts\n- The Climb 1 starts again: the foveation layer file always ships with FrameBridge, and never interposes libvulkan (#432, #449)\n- Agent v123: the flat-launcher theater exception (#475) ships after v122's diagnostics change; test follows deployment_recipe\n- Apps with a flat launcher before their VR part get the Android window, shown in theater mode (#475)\n- Diagnostics: collect_diag returns the last launch's stop-pause, exit-hook, theater and dashboard logs and plays.log, so a zip shows whether the game was pause\n- UI text: \"for example\" instead of \"e.g.\" in the OBB name warnings\n- Menus: a solid grey left edge and a solid soft highlight instead of the portal gradient (#414)\n- Menus: FramePort's own menu with hover submenus for every context menu and the game page's \"…\" (#414)\n- Menus: long items wrap instead of running past the edge; shorter Library right-click menu (#414)\n- Steam's own Exit game pauses FramePort games first: the launcher's _exit_hook worker hooks SteamClient.Apps.TerminateApp in Steam's UI through CEF devtools an\n- App bundles: .xapk/.apks/.apkm/.apkx files and split APK folders are unpacked and merged into one APK (discussion #482)\n- Installs: OBBs with a download's copy suffix or another case go to the Frame under the name the game looks for (#481)\n- Games are paused before they end: launch.sh, agent stop and End game send the activity home in the container and wait up to 3 s, so saves written on pause run\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.3.0",
|
||||
"name": "FramePort v1.3.0",
|
||||
@@ -25,14 +33,6 @@
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.2.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Run many 32-bit Gear VR, Oculus Go and older Quest games** (experimental, thanks UnicronNL): a built-in 32-bit translator now runs AFFECTED The Manor, Gunjack, Spunky Dungeon, Sports Scramble, Journey of the Gods and Shadow Point.\n- **New working recipes:** Assassin's Creed Nexus, Ghostbusters: Rise of the Ghost Lord, Majora's Mask VR and Plezy.\n- **Game fixes:** Vader Immortal no longer slows down the longer you play, Medal of Honor and other Unreal games get past the invisible data-check screen, and UNDERDOGS and games that crashed on a cube-map (skybox) keep running.\n- **Fixes:** more reliable Steam library shortcuts, \"Add to Steam library\" works again after an older install left duplicate entries, and faster uploads for games with many small files.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
|
||||
},
|
||||
{
|
||||
"tag": "dev",
|
||||
"name": "FramePort dev build 1.2.1.dev433",
|
||||
"date": "2026-10-15",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/dev",
|
||||
"dev": true,
|
||||
"notes": "Dev build **1.2.1.dev433** from commit 6cd1eaa (2026-10-15). For testing fixes before\nthe next release; you get that release as a normal update.\n\n## Please test\n\nPreyVR (and other Doom3Quest-based ports with a curved menu): fix for the black menu. The menu is a per-eye cylinder the Frame's runtime rejected as too many layers; this build shows it to both eyes. Install, then rebuild PreyVR on the Frame and launch it from Steam — the menu should appear.\n\n## Changes since v1.2.0\n\n- adapter: show a per-eye cylinder menu to both eyes (fix black curved menus)\n- adapter: log the created swapchain handle in xrCreateSwapchain\n- Catalog: 16 community-verified recipes\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.1.1",
|
||||
"name": "FramePort v1.1.1",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.4.0"
|
||||
@@ -60,7 +60,11 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 25: meta_depth_occlusion (Unity games with Meta's depth-occlusion keywords: frame.meta_depth_occlusion, PR #246)
|
||||
# (2026-10)
|
||||
# 26: browser_query (the manifest's <queries> look for a web browser: frame.browser_handoff, GitHub #466) (2026-10)
|
||||
ANALYSIS_VERSION = 26 # (cortopia_org_id's TMNT layout, GitHub #422, needed no re-analysis: see the patch)
|
||||
# 27: uses_libraries (the original manifest's <uses-library> names, which OVRPort removes:
|
||||
# frame.keep_system_libraries, GitHub #484) (2026-10)
|
||||
# 28: vr_activity counts an INFO activity as the start activity (a single MAIN + INFO + VR activity was taken for
|
||||
# a VR part behind a flat launcher, GitHub #476) (2026-10)
|
||||
ANALYSIS_VERSION = 28 # (cortopia_org_id's TMNT layout, GitHub #422, needed no re-analysis: see the patch)
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -491,6 +495,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"gamepad": "android.hardware.gamepad" in features or axml.LEANBACK_LAUNCHER in cats,
|
||||
# the manifest's <queries> look for a web browser (sign-ins; Lepton has none): frame.browser_handoff
|
||||
"browser_query": axml.queries_browser(manifest),
|
||||
# shared Java libraries the original manifest asks for (OVRPort drops them): frame.keep_system_libraries
|
||||
"uses_libraries": axml.uses_libraries(manifest),
|
||||
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
|
||||
"meta_depth_occlusion": (
|
||||
bool(il2cpp_meta) and (b"HARD_OCCLUSION" in il2cpp_meta or b"SOFT_OCCLUSION" in il2cpp_meta)
|
||||
|
||||
@@ -362,17 +362,23 @@ def component_filters(manifest: bytes) -> list[tuple[str, str, list[tuple[str, i
|
||||
|
||||
|
||||
def vr_activity(manifest: bytes) -> str | None:
|
||||
"""The activity with Meta's VR category when it isn't the launcher activity (a 2D launcher that starts the VR
|
||||
part, e.g. WiiCompiled's LauncherActivity -> QuestActivity)."""
|
||||
"""The activity with Meta's VR category when it isn't the start activity (a 2D launcher that starts the VR
|
||||
part, e.g. WiiCompiled's LauncherActivity -> QuestActivity). The start activity is the LAUNCHER one, else the
|
||||
INFO one (Quest store builds often give their only activity MAIN + INFO + VR, GitHub #476), else the first MAIN."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
vr = launcher = None
|
||||
vr = None
|
||||
start = {} # category -> first activity
|
||||
for el, name, items in component_filters(manifest):
|
||||
cats = {names[i] for kind, _, i in items if kind == "category" and i < len(names)}
|
||||
if el == "activity" and VR_CATEGORY in cats:
|
||||
vr = vr or name
|
||||
if LAUNCHER in cats:
|
||||
launcher = launcher or name
|
||||
for cat in (LAUNCHER, INFO):
|
||||
if cat in cats:
|
||||
start.setdefault(cat, name)
|
||||
if el == "activity" and any(k == "action" and i < len(names) and names[i] == MAIN for k, _, i in items):
|
||||
start.setdefault(MAIN, name)
|
||||
launcher = start.get(LAUNCHER) or start.get(INFO) or start.get(MAIN)
|
||||
return vr if vr and vr != launcher else None
|
||||
|
||||
|
||||
@@ -794,6 +800,60 @@ def add_browser_activity(manifest: bytes, activity: str) -> bytes | None:
|
||||
return bytes(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- shared libraries (frame.keep_system_libraries)
|
||||
USES_LIBRARY_ATTR_IDS = {"name": 0x01010003, "required": 0x0101028E}
|
||||
|
||||
|
||||
def uses_libraries(manifest: bytes) -> list[str]:
|
||||
"""The shared Java libraries the manifest's <uses-library> entries name (not <uses-native-library>), in file
|
||||
order, each once."""
|
||||
x = Axml(manifest)
|
||||
out: list[str] = []
|
||||
for el in x.elements():
|
||||
if el.name == "uses-library":
|
||||
name = x.attr_str(el, "name")
|
||||
if name and name not in out:
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def add_uses_libraries(manifest: bytes, libraries: list[str]) -> bytes | None:
|
||||
"""Declare each of `libraries` the manifest doesn't name yet as `<uses-library android:name=… android:required=
|
||||
"false"/>` at the start of <application> (never required: a library the device lacks must not block the
|
||||
install). None when there's nothing to add or no <application>."""
|
||||
have = set(uses_libraries(manifest))
|
||||
wanted = [lib for i, lib in enumerate(libraries) if lib not in have and lib not in libraries[:i]]
|
||||
x = Axml(manifest)
|
||||
if not wanted or not any(el.name == "application" for el in x.elements()):
|
||||
return None
|
||||
for name, resource in USES_LIBRARY_ATTR_IDS.items():
|
||||
x = _attr_name(x, name, resource)
|
||||
|
||||
def index(s: str) -> int:
|
||||
i = x.index(s)
|
||||
return i if i is not None else x.append_string(s)
|
||||
|
||||
ns, element = index(ANDROID_NS), index("uses-library")
|
||||
values = [index(lib) for lib in wanted]
|
||||
names, ids = x.strings(), _resource_map(x)[1]
|
||||
attr = {a: next(i for i, rid in enumerate(ids) if rid == resource and names[i] == a)
|
||||
for a, resource in USES_LIBRARY_ATTR_IDS.items()}
|
||||
chunk = b""
|
||||
for value in values:
|
||||
# name before required: sorted by resource id, as aapt writes them
|
||||
records = (struct.pack("<IIIHBBI", ns, attr["name"], value, 8, 0, TYPE_STRING, value)
|
||||
+ struct.pack("<IIIHBBI", ns, attr["required"], 0xFFFFFFFF, 8, 0, TYPE_INT_BOOLEAN, 0))
|
||||
chunk += (struct.pack("<HHIII", RES_XML_START_ELEMENT, 16, 36 + len(records), 0, 0xFFFFFFFF)
|
||||
+ struct.pack("<IIHHHHHH", 0xFFFFFFFF, element, 20, 20, 2, 0, 0, 0) + records
|
||||
+ struct.pack("<HHIII", RES_XML_END_ELEMENT, 16, 24, 0, 0xFFFFFFFF)
|
||||
+ struct.pack("<II", 0xFFFFFFFF, element))
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
at = app.offset + app.size
|
||||
data = x.data[:at] + chunk + x.data[at:]
|
||||
struct.pack_into("<I", data, 4, len(data))
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def manifest_attrs(manifest: bytes) -> dict[str, str | int | bool]:
|
||||
"""The <manifest> element's attributes by name (package, split, versionCode, configForSplit, isFeatureSplit…):
|
||||
strings, ints or bools. App bundles (sources/bundles.py) tell the base APK from its splits with them."""
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""The file list of a packaged FramePort (GitHub #476).
|
||||
|
||||
`scripts/package.py` writes `_bundle_files.txt` into the bundle's `frameport` package folder after `flet build`: one
|
||||
line per file of the package (path relative to it, '/'-separated), with the size for compiled modules. A Windows
|
||||
update copies the new files over the old ones, so an update that went wrong — or one made by an older version's
|
||||
update script, which leaves the old version's files behind — can mix two versions: Python then imports whatever
|
||||
.pyc it finds (`patches.base` imports every module in patches/frame). At start the app compares its own files with
|
||||
the list: files the build doesn't have are removed (or at least never imported), and changed or missing compiled
|
||||
modules mean the install is broken, which the GUI says plainly instead of crashing somewhere later.
|
||||
|
||||
No list (source checkout, wheel, PyInstaller bundle) = nothing to check."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
NAME = "_bundle_files.txt"
|
||||
PKG = Path(__file__).resolve().parents[1]
|
||||
SIZED = (".pyc", ".py") # program files signing never changes (.exe/.dll get signed after the list is written)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
stale: list[str] = field(default_factory=list) # files the build doesn't have
|
||||
removed: list[str] = field(default_factory=list) # … of which deleted now
|
||||
mismatched: list[str] = field(default_factory=list) # compiled modules missing or of another size
|
||||
|
||||
@property
|
||||
def broken(self) -> bool:
|
||||
"""The install mixes versions and can't be trusted: reinstall by hand."""
|
||||
return bool(self.mismatched) or len(self.removed) < len(self.stale)
|
||||
|
||||
|
||||
def write(pkg: Path) -> int:
|
||||
"""Packaging: list every file of the built package folder `pkg`. Returns the number of files."""
|
||||
lines = []
|
||||
for root, dirs, files in os.walk(pkg):
|
||||
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
||||
for name in files:
|
||||
path = Path(root) / name
|
||||
rel = path.relative_to(pkg).as_posix()
|
||||
if rel == NAME:
|
||||
continue
|
||||
lines.append(f"{rel}\t{path.stat().st_size}" if name.endswith(SIZED) else rel)
|
||||
lines.sort()
|
||||
(pkg / NAME).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return len(lines)
|
||||
|
||||
|
||||
def read(pkg: Path = PKG) -> dict[str, int | None] | None:
|
||||
"""{relative path: size or None}, or None without a list."""
|
||||
try:
|
||||
text = (pkg / NAME).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
out: dict[str, int | None] = {}
|
||||
for line in text.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rel, _, size = line.partition("\t")
|
||||
try:
|
||||
out[rel.lower()] = int(size) if size else None
|
||||
except ValueError:
|
||||
out[rel.lower()] = None
|
||||
return out
|
||||
|
||||
|
||||
_stale: set[str] = set()
|
||||
|
||||
|
||||
def check(pkg: Path = PKG, repair: bool = True) -> Check | None:
|
||||
"""Compare the package folder with its list; with `repair`, delete files the build doesn't have — but only when
|
||||
the build's own compiled modules all match the list (then the list belongs to the code that runs)."""
|
||||
files = read(pkg)
|
||||
if files is None:
|
||||
return None
|
||||
result = Check()
|
||||
seen = set()
|
||||
for root, dirs, names in os.walk(pkg):
|
||||
dirs[:] = [d for d in dirs if d != "__pycache__"] # only used next to a .py source; bundles have none
|
||||
for name in names:
|
||||
path = Path(root) / name
|
||||
rel = path.relative_to(pkg).as_posix()
|
||||
key = rel.lower()
|
||||
if key == NAME.lower():
|
||||
continue
|
||||
seen.add(key)
|
||||
if key not in files:
|
||||
result.stale.append(rel)
|
||||
continue
|
||||
size = files[key]
|
||||
if size is not None:
|
||||
try:
|
||||
if path.stat().st_size != size:
|
||||
result.mismatched.append(rel)
|
||||
except OSError:
|
||||
result.mismatched.append(rel)
|
||||
result.mismatched += sorted(rel for rel, size in files.items() if size is not None and rel not in seen)
|
||||
_stale.clear()
|
||||
_stale.update(rel.lower() for rel in result.stale)
|
||||
if repair and result.stale and not result.mismatched:
|
||||
for rel in result.stale:
|
||||
try:
|
||||
(pkg / rel).unlink()
|
||||
result.removed.append(rel)
|
||||
except OSError as exc:
|
||||
_log.warning("couldn't remove %s left from another FramePort version: %s", rel, exc)
|
||||
_stale.difference_update(rel.lower() for rel in result.removed)
|
||||
return result
|
||||
|
||||
|
||||
def is_stale(module_file: str | Path, pkg: Path = PKG) -> bool:
|
||||
"""A module file left from another version that check() found and couldn't remove (never import it)."""
|
||||
if not _stale:
|
||||
return False
|
||||
try:
|
||||
rel = Path(module_file).resolve().relative_to(pkg).as_posix().lower()
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
return rel in _stale
|
||||
@@ -173,6 +173,7 @@
|
||||
"Also shows each patch's ID, exactly what it changes and its parameters. Useful when reporting a problem.": "",
|
||||
"An AppImage, folder or .zip/.tar archive with a Linux program, best built for arm64. Builds for x86_64 PCs run too, but slower. The app must bring the libraries SteamOS lacks.": "",
|
||||
"An ordinary Android app or game without VR. It's installed unchanged and runs in Lepton, Valve's Android container.": "",
|
||||
"An update didn't finish: this folder holds files of two FramePort versions, so parts of FramePort may fail.": "",
|
||||
"An update is available for {name}": [
|
||||
"",
|
||||
""
|
||||
@@ -348,6 +349,7 @@
|
||||
"Click to open · right-click for quick actions · drag across cards to select several": "",
|
||||
"Close": "",
|
||||
"Close FramePort": "",
|
||||
"Close FramePort, delete the folder {folder} and unpack the FramePort download into it again. Your games, settings and keys are stored elsewhere and stay.": "",
|
||||
"Closes the game like Steam's Exit game. Unsaved progress is lost.": "",
|
||||
"Collect and open GitHub": "",
|
||||
"Collect FramePort logs": "",
|
||||
@@ -514,6 +516,7 @@
|
||||
"Download all…": "",
|
||||
"Download and install": "",
|
||||
"Download and update": "",
|
||||
"Download page": "",
|
||||
"Download selected…": "",
|
||||
"Download to this PC…": "",
|
||||
"Download to which folder on this PC?": "",
|
||||
@@ -744,6 +747,8 @@
|
||||
"FramePort's layer between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.": "",
|
||||
"FramePort's original violet": "",
|
||||
"FramePort's own name for it": "",
|
||||
"FramePort's program files are incomplete": "",
|
||||
"FramePort's program folder": "",
|
||||
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
|
||||
"frames per second": "",
|
||||
"Frames that are set up show up here by themselves.": "",
|
||||
@@ -863,6 +868,7 @@
|
||||
"Higher is sharper but needs more power.": "",
|
||||
"Higher keeps more detail but uses more memory.": "",
|
||||
"Home folder": "",
|
||||
"Horizon OS \"hybrid apps\" start as a 2D panel and switch into VR for some content (e.g. YouTube's 360° videos and full screen). Before the switch the app checks that the system has the feature oculus.software.vr.app.hybrid; the Frame's Android doesn't, so the app stays in its panel (\"Missing hybrid app system feature\" in its log). This declares the feature in the app's container (a permissions file mounted by FramePort's Podman wrapper; the APK isn't changed). Whether the app's VR part then works on the Frame depends on the app (GitHub #484). Applied with Update on Frame.": "",
|
||||
"hottest: {part}": "",
|
||||
"How a PC VR game starts: Proton runs it on the Frame; Revive lets Oculus games run on SteamVR.": "",
|
||||
"How busy the Frame's graphics chip is, from every program that draws. Near 100% the game can't keep its frame rate.": "",
|
||||
@@ -871,6 +877,7 @@
|
||||
"How often the picture updates. Higher is smoother if the game can keep up.": "",
|
||||
"How the app shows in the headset: in VR, or as a flat window like a phone or tablet app. Automatic picks a window for apps without VR.": "",
|
||||
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" were tested on a Frame; \"Untested\" is a suggestion nobody has confirmed yet. \"Can't run\" means a known blocker.": "",
|
||||
"Hybrid app: allow the switch into VR": "",
|
||||
"I played it in the headset with this recipe": "",
|
||||
"Icon": "",
|
||||
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
|
||||
@@ -963,6 +970,7 @@
|
||||
"Just Dance VR: keep progress in the local save": "",
|
||||
"just now": "",
|
||||
"keep all": "",
|
||||
"Keep Android's shared libraries the app uses": "",
|
||||
"Keep converted copies on this PC (only needed to inspect a build)": "",
|
||||
"Keep full resolution (no dynamic resolution)": "",
|
||||
"Keep game saves on the Frame": "",
|
||||
@@ -1009,6 +1017,7 @@
|
||||
"Last launch test: {get} · got as far as: {value}": "",
|
||||
"Last session · ended {ago}": "",
|
||||
"late": "",
|
||||
"Later": "",
|
||||
"Later — next Steam start": "",
|
||||
"Later — next Steam start (default)": "",
|
||||
"Launch log": "",
|
||||
@@ -1033,6 +1042,8 @@
|
||||
"Lepton's Android has no web browser. Apps that check for one before opening a page (for example Plex's sign-in) say no browser is installed and stop. This adds a small browser entry to the app itself (an activity for http/https links in its manifest and an extra classes.dex): the app's check finds it, and the page opens in Steam's browser, where Lepton sends every link. Whether the app notices the finished sign-in depends on the app (a code or PIN it polls for works; a sign-in that returns to the app through a link doesn't).": "",
|
||||
"Lepton's Android only gets a keyboard, a pointer and touch from the Frame, so a 2D game never sees a controller (the controllers only move the pointer; SDL games show their touch controls). This passes Steam Input's virtual gamepad (\"Microsoft X-Box 360 pad\", made by Steam for the Frame's controllers and paired Bluetooth pads) into this app's container with an Xbox 360 key layout, and tells SDL games to use it (SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD). Steam Input must be on for the game's shortcut; the controllers must be on when the game starts (a pad Steam makes later isn't seen until the next start). The APK isn't changed. Off for one start: FRAMEPORT_NO_GAMEPAD=1 in the game's launch options. Steam Input's own per-game remapping may not reach the app (GitHub #162); the options below change the buttons inside the app instead (applied with Update on Frame).": "",
|
||||
"Lets a mixed-reality game load the room even if it thinks you stand outside it (for example Laser Dance asking again and again to scan your room).": "",
|
||||
"Lets apps start that need one of Android's optional libraries (for example YouTube VR).": "",
|
||||
"Lets apps that start as a 2D panel (e.g. YouTube) switch into VR for 360° videos and full screen.": "",
|
||||
"Lets apps with a USB MIDI driver start on the Frame (they'd crash looking for USB devices); USB MIDI devices aren't available there.": "",
|
||||
"Lets games built on Meta's oldest VR interface run on the Frame.": "",
|
||||
"Lets games built with Unity's App UI start on the Frame (they'd quit at once on an Android 12 vibrator call).": "",
|
||||
@@ -1337,6 +1348,7 @@
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
"OVRPort converts Quest games to standard OpenXR. These are its optional patches.": "",
|
||||
"OVRPort marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy JNI calls ('JNI DETECTED ERROR', for example 'GetStringUTFChars ... NULL' in Time Stall). Clear it for those.": "",
|
||||
"OVRPort removes every <uses-library> entry from the manifest (Quest-only libraries would stop the install), including ones the Frame's Android has. An app that needs one of those then can't find its classes and closes at start (for example YouTube VR: ClassNotFoundException org.apache.http.protocol.HttpRequestHandler, from org.apache.http.legacy). This puts back the entries for libraries the Frame provides, marked optional.": "",
|
||||
"OVRPort's Meta platform library opens the microphone only when it's started, but answers a game's question about the microphone buffer size from that not-yet-opened stream: games that ask first (Unreal's Oculus voice chat, for example The Walking Dead: Saints & Sinners Ch. 2) crash a few seconds after the logo (SIGSEGV in libaaudio.so). Adds the missing check (size 0 until the microphone is started).": "",
|
||||
"OVRPort's OpenXR dispatcher aborts the app ('Wrong createInfo size', SIGABRT in libopenxr_loader.so xrCreateSwapchain) when a swapchain is wider or taller than 4096 px, which video players do for 8K video or big theater textures (for example 4XVR). Raises the guard to 16384 px so the Frame's runtime (8192 px max) decides instead.": "",
|
||||
"OVRPort's own converter for games that use Meta's oldest VR interface (experimental).": "",
|
||||
|
||||
@@ -185,10 +185,15 @@ def load_all() -> None:
|
||||
def _import_modules() -> None:
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
|
||||
from ..core import bundle_files
|
||||
from . import frame, overport, pcvr, settings # noqa: F401 (registration side effects)
|
||||
|
||||
folder = Path(frame.__path__[0])
|
||||
for mod in pkgutil.iter_modules(frame.__path__):
|
||||
if any(bundle_files.is_stale(folder / f"{mod.name}{ext}") for ext in (".pyc", ".py")):
|
||||
continue # left from another version by an update (GitHub #476)
|
||||
importlib.import_module(f"{frame.__name__}.{mod.name}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Android's optional shared libraries back in the manifest (GitHub #484, YouTube VR).
|
||||
|
||||
An app names the shared Java libraries it uses with `<uses-library>` in its manifest; Android then puts them on the
|
||||
app's class path. OVRPort's `patch_remove_uses_library` (on by default) drops every `<uses-library>` and
|
||||
`<uses-native-library>` but libopenxr.google.so, because Quest-only ones (for example Meta's system libraries) would
|
||||
block the install. That also drops the ones Lepton's Android 11 has, so an app that uses one of them starts without it:
|
||||
YouTube VR names `org.apache.http.legacy` (Android 9+ gives it to apps targeting API 28+ only on request) and dies at
|
||||
start with `ClassNotFoundException: org.apache.http.protocol.HttpRequestHandler`.
|
||||
|
||||
This patch puts back the entries of the original manifest (read at analysis: `extra.uses_libraries`) that Lepton
|
||||
provides (`LEPTON_LIBRARIES`, the libraries in its /system/etc/permissions), always with `android:required="false"`
|
||||
so a library can never stop the install. Builds of apps without such an entry are unchanged."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...apk import axml
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
MANIFEST = "AndroidManifest.xml"
|
||||
|
||||
# Shared libraries Lepton's Android 11 provides (/system/etc/permissions/*.xml in its image). The one place this list
|
||||
# lives: detect(), apply() and the triage signature's classes (catalog/triage.yaml `system-library-missing`) follow it.
|
||||
LEPTON_LIBRARIES = (
|
||||
"org.apache.http.legacy",
|
||||
"android.test.base",
|
||||
"android.test.mock",
|
||||
"android.test.runner",
|
||||
"javax.obex",
|
||||
"com.android.location.provider",
|
||||
"com.android.mediadrm.signer",
|
||||
"com.android.media.remotedisplay",
|
||||
"com.android.future.usb.accessory",
|
||||
"android.net.ipsec.ike",
|
||||
)
|
||||
|
||||
|
||||
def wanted(analysis) -> list[str]:
|
||||
"""The Lepton-provided libraries the original manifest names, in its order."""
|
||||
names = (analysis.extra or {}).get("uses_libraries") or []
|
||||
return [name for name in names if name in LEPTON_LIBRARIES]
|
||||
|
||||
|
||||
class KeepSystemLibraries(Patch):
|
||||
id = "frame.keep_system_libraries"
|
||||
title = "Keep Android's shared libraries the app uses"
|
||||
description = ("OVRPort removes every <uses-library> entry from the manifest (Quest-only libraries would stop "
|
||||
"the install), including ones the Frame's Android has. An app that needs one of those then can't "
|
||||
"find its classes and closes at start (for example YouTube VR: ClassNotFoundException "
|
||||
"org.apache.http.protocol.HttpRequestHandler, from org.apache.http.legacy). This puts back the "
|
||||
"entries for libraries the Frame provides, marked optional.")
|
||||
order = 23
|
||||
default_on = True # suggested only where the original manifest names such a library (detect)
|
||||
|
||||
def detect(self, a):
|
||||
libs = wanted(a)
|
||||
if libs:
|
||||
return Suggestion(True, "The app asks for Android's " + ", ".join(libs) + " library, which OVRPort "
|
||||
"removes from the manifest; without it the app can't load those classes.")
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
# OVRPort doesn't run on 2D apps or 32-bit games on the bridge: their manifests keep the entries
|
||||
return bool(wanted(a)) and a.vr_kind != "none"
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
libs = wanted(ctx.analysis)
|
||||
if not libs:
|
||||
return False
|
||||
manifest = ctx.ws.read(MANIFEST)
|
||||
fixed = axml.add_uses_libraries(manifest, libs)
|
||||
if fixed is None:
|
||||
return False
|
||||
ctx.ws.put(MANIFEST, fixed)
|
||||
added = [lib for lib in libs if lib not in axml.uses_libraries(manifest)]
|
||||
ctx.notes.append("shared libraries declared again: " + ", ".join(added))
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
libs = wanted(ctx.analysis)
|
||||
if not libs:
|
||||
return []
|
||||
missing = [lib for lib in libs if lib not in axml.uses_libraries(ctx.ws.read(MANIFEST))]
|
||||
if missing:
|
||||
return [("Shared libraries", False, "not declared: " + ", ".join(missing))]
|
||||
return [("Shared libraries", True, "declared: " + ", ".join(libs))]
|
||||
|
||||
|
||||
register(KeepSystemLibraries)
|
||||
@@ -942,6 +942,29 @@ class SteamGamepad(Patch):
|
||||
ctx.env["FRAMEPORT_GAMEPAD_KEYS"] = "; ".join(lines)
|
||||
|
||||
|
||||
class HybridApp(Patch):
|
||||
id = "device.hybrid_app"
|
||||
title = "Hybrid app: allow the switch into VR"
|
||||
description = ("Horizon OS \"hybrid apps\" start as a 2D panel and switch into VR for some content (e.g. YouTube's "
|
||||
"360° videos and full screen). Before the switch the app checks that the system has the feature "
|
||||
"oculus.software.vr.app.hybrid; the Frame's Android doesn't, so the app stays in its panel "
|
||||
"(\"Missing hybrid app system feature\" in its log). This declares the feature in the app's "
|
||||
"container (a permissions file mounted by FramePort's Podman wrapper; the APK isn't changed). "
|
||||
"Whether the app's VR part then works on the Frame depends on the app (GitHub #484). Applied with "
|
||||
"Update on Frame.")
|
||||
category = "device"
|
||||
needs_vr = False
|
||||
stage = "install" # the launcher exports FRAMEPORT_FEATURES; the agent's Podman wrapper mounts the file
|
||||
FEATURE = "oculus.software.vr.app.hybrid"
|
||||
ENV_KEY = "FRAMEPORT_FEATURES"
|
||||
|
||||
def applies(self, a):
|
||||
return self.FEATURE in ((a.extra or {}).get("features") or {})
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
ctx.env[self.ENV_KEY] = self.FEATURE
|
||||
|
||||
|
||||
def foveation_options(params):
|
||||
mode = params.get("mode") or ""
|
||||
if mode not in ("", "tracked", "fixed", "off"):
|
||||
@@ -1100,6 +1123,7 @@ register(LeptonEnv)
|
||||
register(HideNavBar)
|
||||
register(Language)
|
||||
register(SteamGamepad)
|
||||
register(HybridApp)
|
||||
register(SharedFolder)
|
||||
register(TextInputWindow)
|
||||
register(DisplayMode)
|
||||
|
||||
@@ -123,6 +123,8 @@ SUMMARIES = {
|
||||
"seasons).",
|
||||
"frame.ovr_microphone": "Stops a crash a few seconds after the logo in games with voice chat.",
|
||||
"frame.ovr_stable_user": "Keeps your progress in games that file their saves under your Meta user.",
|
||||
"frame.keep_system_libraries": "Lets apps start that need one of Android's optional libraries (for example "
|
||||
"YouTube VR).",
|
||||
"frame.cortopia_org_id": "Gets Cortopia games (for example Escaping Wonderland) past their loading screen.",
|
||||
"frame.ovr_org_scoped_id": "Answers Meta's request for your org-scoped user id, which games such as Eleven Table "
|
||||
"Tennis wait for on their loading screen.",
|
||||
@@ -167,6 +169,8 @@ SUMMARIES = {
|
||||
"keeps working.",
|
||||
"device.display_mode": "Choose Flat window if a phone or tablet app shows nothing in the headset, VR if a VR app "
|
||||
"opens as a flat window.",
|
||||
"device.hybrid_app": "Lets apps that start as a 2D panel (e.g. YouTube) switch into VR for 360° videos and full "
|
||||
"screen.",
|
||||
"device.steam_gamepad": "Lets the game use your controllers as a gamepad instead of a pointer, for 2D games with "
|
||||
"touch controls (for example SDL games).",
|
||||
"device.lepton_env": "Extra settings for the Android container (for testing).",
|
||||
|
||||
@@ -202,8 +202,8 @@ def _find_data_dir(apk_dir: Path, pkg: str | None) -> Path | None:
|
||||
# Android's expansion file names, also CryEngine's main.<package>.obb without a versionCode (GitHub #238).
|
||||
OBB_NAME = re.compile(r"^(main|patch)\.(?:(\d+)\.)?(.+)\.obb$", re.IGNORECASE)
|
||||
# Found by name next to an APK, only with that APK's package: other kinds too, e.g. Crisis Brigade 2's
|
||||
# office.<versionCode>.<package>.obb (GitHub #415)
|
||||
NAMED_OBB = re.compile(r"^([a-z]+)\.(?:(\d+)\.)?(.+)\.obb$", re.IGNORECASE)
|
||||
# office.<versionCode>.<package>.obb and ta1.<versionCode>.<package>.obb (GitHub #415)
|
||||
NAMED_OBB = re.compile(r"^([a-z][a-z0-9]*)\.(?:(\d+)\.)?(.+)\.obb$", re.IGNORECASE)
|
||||
|
||||
|
||||
# What downloads and copies add after the package (GitHub #450/#481): main.203.com.Armature.VR4-003.obb,
|
||||
@@ -226,8 +226,10 @@ class ObbName:
|
||||
|
||||
|
||||
def obb_names(package: str, rels) -> list[ObbName]:
|
||||
"""Expansion files (top level of the game's data, main/patch kinds) not named <kind>.<versionCode>.<package>.obb
|
||||
exactly, with what to do about each. Renamed (the name says which file it is, only its spelling is off): a
|
||||
"""Expansion files (top level of the game's data) not named <kind>.<versionCode>.<package>.obb exactly, with what
|
||||
to do about each. Other kinds than main/patch (Crisis Brigade 2's office/alleys/ta1…, GitHub #415) only count
|
||||
when their package part is the game's in another case or with a copy suffix (never "another app's": they may be
|
||||
any data). Renamed (the name says which file it is, only its spelling is off): a
|
||||
different case (Main.203.com.armature.vr4.obb) and a copy suffix after the package (-003, " (1)", " - Copy").
|
||||
Warned, sent as they are: another app's name (a renamed build's main.1716.com.beatgames.beatsaber.lj369vr.obb,
|
||||
GitHub #129), a copy of a file that is there under its right name too, two files for one name, a copy suffix
|
||||
@@ -243,6 +245,13 @@ def obb_names(package: str, rels) -> list[ObbName]:
|
||||
for name in names:
|
||||
m = OBB_NAME.match(name)
|
||||
if not m:
|
||||
m = NAMED_OBB.match(name)
|
||||
if m:
|
||||
# other kinds (Crisis Brigade 2's office.222.<package>.obb, GitHub #415): only the game's own files
|
||||
# with the package's case or a copy suffix off; anything else is some other data, never warned about
|
||||
own = _own_named(name, m, pkg, low)
|
||||
if own:
|
||||
found.append(own)
|
||||
continue
|
||||
kind, vc, rest = m.group(1).lower(), m.group(2), m.group(3)
|
||||
version = f"{vc}." if vc else ""
|
||||
@@ -278,6 +287,21 @@ def obb_names(package: str, rels) -> list[ObbName]:
|
||||
return out
|
||||
|
||||
|
||||
def _own_named(name: str, m: re.Match, pkg: str, low: str) -> tuple[str, str, str] | None:
|
||||
"""(name, wanted, reason) for a <kind>.<versionCode>.<package>.obb of another kind than main/patch whose package
|
||||
part is the game's in another case or with a copy suffix; the kind keeps its own spelling."""
|
||||
kind, vc, rest = m.group(1), m.group(2), m.group(3)
|
||||
rl = rest.lower()
|
||||
if rl == low:
|
||||
reason = "case"
|
||||
elif rl.startswith(low) and COPY_SUFFIX.fullmatch(rest[len(pkg):]):
|
||||
reason = "copy" if vc else "unclear-version"
|
||||
else:
|
||||
return None
|
||||
wanted = f"{kind}.{vc + '.' if vc else ''}{pkg}.obb"
|
||||
return (name, wanted, reason) if wanted != name else None
|
||||
|
||||
|
||||
def frame_names(package: str, manifest: dict[str, int]) -> dict[str, str]:
|
||||
"""{name in the game's data: name on the Frame} for the files obb_names renames (the rest keep theirs)."""
|
||||
return {o.name: o.wanted for o in obb_names(package, manifest) if o.rename}
|
||||
@@ -316,8 +340,8 @@ def _named_obbs(d: Path, pkg: str) -> list[tuple[str, int | None, Path]]:
|
||||
if not m or is_dir:
|
||||
continue
|
||||
rest = m.group(3).lower()
|
||||
if rest == pkg.lower() or (m.group(1).lower() in ("main", "patch") and rest.startswith(pkg.lower())
|
||||
and COPY_SUFFIX.fullmatch(rest[len(pkg):])):
|
||||
# a download's copy suffix too (any kind), installed renamed (obb_names)
|
||||
if rest == pkg.lower() or (rest.startswith(pkg.lower()) and COPY_SUFFIX.fullmatch(rest[len(pkg):])):
|
||||
# a download's copy suffix too (main.203.com.Armature.VR4-003.obb, GitHub #481): installed renamed
|
||||
out.append((m.group(1).lower(), int(m.group(2)) if m.group(2) else None, f))
|
||||
elif not m.group(2) and rest.endswith(suffix) and "." not in rest[:-len(suffix)]:
|
||||
|
||||
@@ -311,9 +311,23 @@ def java_cmd(*args: str) -> list[str]:
|
||||
return [str(java), "-Djava.awt.headless=true", *args]
|
||||
|
||||
|
||||
def java_env(environ=None) -> dict | None:
|
||||
"""Linux: an environment with a UTF-8 locale when the inherited one isn't (None: inherit it). Java decodes its
|
||||
arguments with the locale's charset, so under C/POSIX (FramePort on the Frame) `LEGO® Bricktales/x.apk` reached
|
||||
OVRPort as `LEGO?? Bricktales/x.apk` and the build failed (GitHub #487). glibc has C.UTF-8 built in."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return None
|
||||
environ = dict(os.environ if environ is None else environ)
|
||||
locale = environ.get("LC_ALL") or environ.get("LC_CTYPE") or environ.get("LANG") or ""
|
||||
if "utf-8" in locale.lower() or "utf8" in locale.lower():
|
||||
return None
|
||||
environ["LC_ALL"] = "C.UTF-8"
|
||||
return environ
|
||||
|
||||
|
||||
def run_java(args: list[str], cwd: Path | None = None, timeout: float | None = None) -> subprocess.CompletedProcess:
|
||||
kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
|
||||
return subprocess.run(java_cmd(*args), cwd=cwd, capture_output=True, text=True, errors="replace",
|
||||
timeout=timeout, **kwargs)
|
||||
timeout=timeout, env=java_env(), **kwargs)
|
||||
@@ -217,6 +217,7 @@ class FramePortApp:
|
||||
threading.Thread(target=self._backfill_covers, daemon=True).start()
|
||||
self.updater.start()
|
||||
self.announcer.start()
|
||||
self._bundle_warning()
|
||||
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
|
||||
self.run_bg(self._check_link_updates) # newer versions of games installed from install links
|
||||
_link_state["owner"] = self
|
||||
@@ -3169,6 +3170,27 @@ class FramePortApp:
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: self.page.pop_dialog()),
|
||||
C.danger(tr("Uninstall"), ft.Icons.DELETE_FOREVER_OUTLINED, go)]))
|
||||
|
||||
def _bundle_warning(self) -> None:
|
||||
"""The program files mix two FramePort versions (an update that didn't finish, GitHub #476): say how to
|
||||
reinstall instead of failing somewhere later."""
|
||||
if not _bundle_check or not _bundle_check[0].broken:
|
||||
return
|
||||
from .. import REPO_URL, updates
|
||||
|
||||
folder = updates.bundle_root() or tr("FramePort's program folder")
|
||||
self.page.show_dialog(C.dialog(
|
||||
tr("FramePort's program files are incomplete"),
|
||||
ft.Column([
|
||||
C.body(tr("An update didn't finish: this folder holds files of two FramePort versions, so parts of "
|
||||
"FramePort may fail.")),
|
||||
C.body(tr("Close FramePort, delete the folder {folder} and unpack the FramePort download into it "
|
||||
"again. Your games, settings and keys are stored elsewhere and stay.")
|
||||
.format(folder=folder)),
|
||||
], spacing=T.S2, tight=True),
|
||||
actions=[C.ghost(tr("Later"), on_click=lambda e: self.page.pop_dialog()),
|
||||
C.primary(tr("Download page"), ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
lambda e: self.open_url(f"{REPO_URL}/releases"))]))
|
||||
|
||||
def _uninstalled(self, out: dict) -> None:
|
||||
async def close(e=None):
|
||||
try:
|
||||
@@ -3292,6 +3314,31 @@ def pop_dialog_skips_toasts() -> None:
|
||||
applog.log.warning("pop_dialog() couldn't be kept off toasts (another Flet version?): %s", exc)
|
||||
|
||||
|
||||
_bundle_check: list = [] # the start's bundle_files.Check (packaged app only)
|
||||
|
||||
|
||||
def check_bundle_files() -> None:
|
||||
"""Packaged app: remove files an update left from another FramePort version (GitHub #476); a mixed install is
|
||||
reported in the window (`FramePortApp._bundle_warning`)."""
|
||||
from ..core import bundle_files
|
||||
|
||||
try:
|
||||
result = bundle_files.check()
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("checking the program files failed")
|
||||
return
|
||||
if result is None:
|
||||
return
|
||||
_bundle_check[:] = [result]
|
||||
if result.removed:
|
||||
applog.log.warning("removed %d file(s) left from another FramePort version: %s", len(result.removed),
|
||||
", ".join(result.removed[:20]))
|
||||
if result.broken:
|
||||
applog.log.error("FramePort's program files mix versions: changed/missing %s; left over %s",
|
||||
", ".join(result.mismatched[:20]),
|
||||
", ".join(sorted(set(result.stale) - set(result.removed))[:20]))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import sys
|
||||
|
||||
@@ -3311,6 +3358,7 @@ def main(argv=None):
|
||||
print("FramePort is already running.", file=sys.stderr)
|
||||
return
|
||||
serialize_flet_updates()
|
||||
check_bundle_files() # before anything imports the patch modules
|
||||
from ..core import library
|
||||
from .updater import apply_pending_at_start
|
||||
|
||||
|
||||
@@ -617,40 +617,103 @@ def _sh_quote(path: Path | str) -> str:
|
||||
return "'" + str(path).replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
WAIT_FOR_FILES = 60 # seconds the Windows swap script waits for the installed program files to be free
|
||||
|
||||
|
||||
def swap_script(app: Path, target: Path, pid: int, platform: str, relaunch: bool, log: Path) -> str:
|
||||
"""The script that installs `app` over `target` once process `pid` has exited (text; see apply())."""
|
||||
"""The script that installs `app` over `target` once process `pid` has exited (text; see apply()).
|
||||
|
||||
Windows copies the new files over the installed ones (the zip has no folder of its own, so the install folder may
|
||||
hold the user's other files too). The installed copy must end up as exactly one version (GitHub #476: a failed
|
||||
copy was rolled back without removing the files the new version had *added*, so the old version started with a
|
||||
new module next to its own and crashed at start):
|
||||
- nothing is touched while FramePort or a program file (.exe/.dll/.pyd) is still in use;
|
||||
- files of the old version the new one no longer has are moved into `previous` (only inside the bundle's own
|
||||
folders: app, Lib, DLLs, site-packages, data; never top-level files);
|
||||
- on failure the files the update added are deleted again and every replaced/moved file comes back."""
|
||||
if platform == "win32":
|
||||
start = f"Start-Process -FilePath {_ps_quote(target / 'FramePort.exe')} -WorkingDirectory {_ps_quote(target)}" \
|
||||
if relaunch else "Write-Log 'not relaunching'"
|
||||
return (f"""$ErrorActionPreference = 'Stop'
|
||||
$log = {_ps_quote(log)}
|
||||
function Write-Log($m) {{ Add-Content -LiteralPath $log -Value ("$(Get-Date -Format s) $m") }}
|
||||
function Start-FramePort {{ {start} }}
|
||||
Write-Log 'update: waiting for FramePort (pid {pid}) to exit'
|
||||
for ($i = 0; $i -lt 240 -and (Get-Process -Id {pid} -ErrorAction SilentlyContinue); $i++) {{ Start-Sleep"""
|
||||
f""" -Milliseconds 500 }}
|
||||
if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{
|
||||
Write-Log 'update FAILED: FramePort (pid {pid}) is still running; nothing was changed'
|
||||
exit 1
|
||||
}}
|
||||
Start-Sleep -Milliseconds 500
|
||||
$src = {_ps_quote(app)}; $dst = {_ps_quote(target)}
|
||||
# the folders' long names: Get-ChildItem lists 8.3 paths (C:\\Users\\RUNNER~1\\...) under their long names, and the
|
||||
# relative paths below are cut from those
|
||||
$src = (Get-Item -LiteralPath $src -Force).FullName.TrimEnd('\\')
|
||||
$dst = (Get-Item -LiteralPath $dst -Force).FullName.TrimEnd('\\')
|
||||
$backup = Join-Path (Split-Path -Parent $src) 'previous'
|
||||
# every file of the new version, relative to its folder
|
||||
$new = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
Get-ChildItem -LiteralPath $src -Recurse -File -Force | ForEach-Object {{
|
||||
[void]$new.Add($_.FullName.Substring($src.Length).TrimStart('\\'))
|
||||
}}
|
||||
# the bundle's own folders (app, Lib, DLLs, site-packages, data): old files in them that the new version doesn't have
|
||||
# would mix two versions (Python imports whatever .pyc it finds)
|
||||
$owned = @(Get-ChildItem -LiteralPath $src -Directory -Force | ForEach-Object {{ $_.Name }})
|
||||
# wait until no program (another FramePort, a helper, a virus scan) holds the program files
|
||||
function Test-InUse($p) {{
|
||||
try {{ $s = [IO.File]::Open($p, 'Open', 'ReadWrite', 'None'); $s.Close(); return $false }}
|
||||
catch [System.IO.IOException] {{ return $true }}
|
||||
catch {{ return $false }}
|
||||
}}
|
||||
$binaries = @($new | Where-Object {{ $_ -match '\\.(exe|dll|pyd)$' }} | ForEach-Object {{ Join-Path $dst $_ }} |
|
||||
Where-Object {{ Test-Path -LiteralPath $_ }})
|
||||
$busy = @()
|
||||
for ($i = 0; $i -lt {WAIT_FOR_FILES}; $i++) {{
|
||||
$busy = @($binaries | Where-Object {{ Test-InUse $_ }})
|
||||
if ($busy.Count -eq 0) {{ break }}
|
||||
Start-Sleep -Seconds 1
|
||||
}}
|
||||
if ($busy.Count -gt 0) {{
|
||||
Write-Log "update FAILED: files still in use, nothing was changed: $(($busy | Select-Object -First 5) -join ', ')"
|
||||
Start-FramePort
|
||||
exit 1
|
||||
}}
|
||||
$added = New-Object 'System.Collections.Generic.List[string]'
|
||||
$moved = 0
|
||||
try {{
|
||||
if (Test-Path -LiteralPath $backup) {{ Remove-Item -LiteralPath $backup -Recurse -Force }}
|
||||
New-Item -ItemType Directory -Path $backup | Out-Null
|
||||
# keep the files that will be replaced, so a failed copy can be undone
|
||||
Get-ChildItem -LiteralPath $src -Recurse -File | ForEach-Object {{
|
||||
$rel = $_.FullName.Substring($src.Length).TrimStart('\\')
|
||||
# keep the files that will be replaced, so a failed copy can be undone; note the ones that are new
|
||||
foreach ($rel in $new) {{
|
||||
$old = Join-Path $dst $rel
|
||||
if (Test-Path -LiteralPath $old) {{
|
||||
if (Test-Path -LiteralPath $old -PathType Leaf) {{
|
||||
$keep = Join-Path $backup $rel
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $keep) | Out-Null
|
||||
Copy-Item -LiteralPath $old -Destination $keep -Force
|
||||
}} else {{ $added.Add($rel) }}
|
||||
}}
|
||||
# move the old version's files that the new one doesn't have into the backup
|
||||
foreach ($dir in $owned) {{
|
||||
$d = Join-Path $dst $dir
|
||||
if (-not (Test-Path -LiteralPath $d -PathType Container)) {{ continue }}
|
||||
foreach ($f in @(Get-ChildItem -LiteralPath $d -Recurse -File -Force)) {{
|
||||
$rel = $f.FullName.Substring($dst.Length).TrimStart('\\')
|
||||
if ($new.Contains($rel)) {{ continue }}
|
||||
$keep = Join-Path $backup $rel
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $keep) | Out-Null
|
||||
Move-Item -LiteralPath $f.FullName -Destination $keep -Force
|
||||
$moved++
|
||||
}}
|
||||
}}
|
||||
Copy-Item -Path (Join-Path $src '*') -Destination $dst -Recurse -Force
|
||||
Write-Log 'update: installed'
|
||||
Write-Log "update: installed ($($added.Count) new file(s), $moved old file(s) removed)"
|
||||
}} catch {{
|
||||
Write-Log "update FAILED: $_ (restoring the previous files)"
|
||||
foreach ($rel in $added) {{ Remove-Item -LiteralPath (Join-Path $dst $rel) -Force -ErrorAction SilentlyContinue }}
|
||||
Copy-Item -Path (Join-Path $backup '*') -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}}
|
||||
{start}
|
||||
Start-FramePort
|
||||
""")
|
||||
if platform == "darwin":
|
||||
start = f"open {_sh_quote(target)}" if relaunch else "echo 'not relaunching' >>\"$log\""
|
||||
|
||||
@@ -2567,6 +2567,146 @@ def test_manual_restart_worker_wakes_existing_lock_holder(monkeypatch, tmp_path)
|
||||
assert json.load(open(a.RESTART_REQUEST_FILE))["requested"] > 0
|
||||
|
||||
|
||||
def _deploy(a, pkg, title="X"):
|
||||
anchor = Path(a.ANCHORS) / pkg
|
||||
anchor.mkdir(parents=True, exist_ok=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"title": title, "appid": 123, "base": str(anchor)}))
|
||||
|
||||
|
||||
def _waiting_worker(monkeypatch, a, clock, during_wait):
|
||||
"""Run a `now` worker for com.a.first that waits for FramePort to close; during_wait(n) runs at each 10 s check."""
|
||||
calls = {"updated": [], "removed": [], "stops": 0}
|
||||
holds = ["frameport"]
|
||||
monkeypatch.setattr(a.time, "time", lambda: clock[0])
|
||||
monkeypatch.setattr(a, "refresh_desktop_entries", lambda: None)
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "library_changes", lambda *args: True)
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: holds[0])
|
||||
monkeypatch.setattr(a, "ensure_library_hooks", lambda: None)
|
||||
monkeypatch.setattr(a, "run", lambda *args, **kw: pytest.fail("started a second worker"))
|
||||
monkeypatch.setattr(a, "update_library", lambda user, pkgs, result, shortcuts=True:
|
||||
calls["updated"].append(sorted(pkgs)))
|
||||
monkeypatch.setattr(a, "remove_from_library", lambda user, remove, result:
|
||||
calls["removed"].extend(r["exe"] for r in remove))
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: calls.__setitem__("stops", calls["stops"] + 1) or True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: None)
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
n = [0]
|
||||
|
||||
def sleep(seconds):
|
||||
n[0] += 1
|
||||
if during_wait(n[0]):
|
||||
holds[0] = None
|
||||
clock[0] += seconds
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
a.shortcuts_worker(json.dumps({"packages": ["com.a.first"], "remove": []}))
|
||||
return calls
|
||||
|
||||
|
||||
def test_library_mode_does_not_wait_for_a_waiting_worker(monkeypatch, tmp_path):
|
||||
"""Discussion #305: every install calls library_mode first; it took the worker's lock (blocking) and the install
|
||||
hung until the PC's 600 s agent timeout while the first install's worker waited for FramePort to close."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(returncode=0))
|
||||
Path(a.STATUS_FILE).parent.mkdir(parents=True)
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
start = a.time.monotonic()
|
||||
assert a.cmd_library_mode({"mode": "now"}) == {"mode": "now", "worker_active": True}
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
assert a.cmd_library_mode({"mode": "off"})["worker_active"]
|
||||
assert a.time.monotonic() - start < 5
|
||||
assert a.library_mode() == "off" and a.pending_library()["packages"] == []
|
||||
|
||||
|
||||
def test_later_queue_is_bounded_while_the_lock_stays_busy(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "ensure_library_hooks", lambda: None)
|
||||
monkeypatch.setattr(a, "library_users", lambda: pytest.fail("touched the live library without the lock"))
|
||||
clock = [0.0]
|
||||
monkeypatch.setattr(a.time, "monotonic", lambda: clock[0])
|
||||
monkeypatch.setattr(a.time, "sleep", lambda s: clock.__setitem__(0, clock[0] + s))
|
||||
Path(a.STATUS_FILE).parent.mkdir(parents=True)
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
result = a.cmd_shortcuts({"packages": ["com.x.y"], "mode": "later"})
|
||||
assert result["state"] == "pending" and a.pending_library()["packages"] == ["com.x.y"]
|
||||
assert 30 <= clock[0] < 31
|
||||
|
||||
|
||||
def test_install_while_a_worker_waits_is_journaled_not_a_second_worker(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "run", lambda *args, **kw: pytest.fail("started a second worker"))
|
||||
a._write_json(a.STATUS_FILE, {"state": "waiting", "reason": "frameport", "packages": ["com.a.first"],
|
||||
"started": a.time.time()})
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
out = a.cmd_shortcuts({"packages": ["com.b.second"], "mode": "now"})
|
||||
assert out["waiting"] and out["state"] == "waiting" and out["reason"] == "frameport" # the PC says why
|
||||
exe = '"' + a.ANCHORS + '/com.c.gone/launch.sh"'
|
||||
assert a.cmd_shortcuts({"remove": [{"exe": exe, "appid": 7}]})["waiting"]
|
||||
assert a.pending_library() == {"packages": ["com.b.second"], "remove": [{"exe": exe, "appid": 7}]}
|
||||
# nobody holds the lock (the worker died): a new worker starts, the stale status isn't trusted
|
||||
started = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: started.append(cmd))
|
||||
assert a.cmd_shortcuts({"packages": ["com.b.second"], "mode": "now"})["started"] and started
|
||||
|
||||
|
||||
def test_waiting_worker_merges_changes_made_while_it_waited(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
for pkg in ("com.a.first", "com.b.second", "com.c.third", "com.d.gone"):
|
||||
_deploy(a, pkg)
|
||||
gone = '"' + a.ANCHORS + '/com.d.gone/launch.sh"'
|
||||
|
||||
def during_wait(n):
|
||||
if n == 1: # another install, an install + uninstall, and a rename while FramePort is open
|
||||
assert a.cmd_library_mode({"mode": "now"})["worker_active"]
|
||||
assert a.cmd_shortcuts({"packages": ["com.b.second"], "mode": "now"})["waiting"]
|
||||
assert a.cmd_shortcuts({"packages": ["com.d.gone"], "mode": "now"})["waiting"]
|
||||
(Path(a.ANCHORS) / "com.d.gone/deployment.json").unlink()
|
||||
assert a.cmd_shortcuts({"remove": [{"exe": gone, "appid": 9}]})["waiting"]
|
||||
return False
|
||||
assert a.cmd_shortcuts({"packages": ["com.c.third"]})["waiting"]
|
||||
return True
|
||||
calls = _waiting_worker(monkeypatch, a, [100.0], during_wait)
|
||||
assert calls["stops"] == 1 # one Steam restart for everything
|
||||
assert calls["updated"] == [["com.a.first", "com.b.second", "com.c.third"]]
|
||||
assert calls["removed"] == [gone]
|
||||
assert a.pending_library() == {"packages": [], "remove": []}
|
||||
assert a.cmd_shortcut_status({})["state"] == "done"
|
||||
|
||||
|
||||
def test_entries_journaled_after_the_merge_stay_for_the_next_run(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
late = {"exe": '"' + a.ANCHORS + '/com.z.late/launch.sh"'}
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.a.first", "com.z.late"], "remove": [late]})
|
||||
a.consume_pending({"packages": ["com.a.first"], "remove": []})
|
||||
assert a.pending_library() == {"packages": ["com.z.late"], "remove": [late]}
|
||||
a.consume_pending({"packages": ["com.z.late"], "remove": [late]})
|
||||
assert a.pending_library() == {"packages": [], "remove": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["later", "off"])
|
||||
def test_mode_switch_while_waiting_still_honoured_with_journaled_games(monkeypatch, tmp_path, mode):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
for pkg in ("com.a.first", "com.b.second"):
|
||||
_deploy(a, pkg)
|
||||
|
||||
def during_wait(n):
|
||||
if n == 1:
|
||||
assert a.cmd_shortcuts({"packages": ["com.b.second"], "mode": "now"})["waiting"]
|
||||
assert a.cmd_library_mode({"mode": mode})["worker_active"] # returns at once; the worker sees it
|
||||
return False
|
||||
calls = _waiting_worker(monkeypatch, a, [100.0], during_wait)
|
||||
assert calls["stops"] == 0
|
||||
if mode == "later":
|
||||
assert a.pending_library()["packages"] == ["com.a.first", "com.b.second"]
|
||||
assert a.cmd_shortcut_status({})["state"] == "pending"
|
||||
else:
|
||||
assert a.pending_library()["packages"] == [] and a.cmd_shortcut_status({})["disabled"]
|
||||
|
||||
|
||||
def test_library_hook_is_bounded_and_never_fails_steam(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
text = a.library_hooks_text(python="/usr/bin/python3", agent="/home/u/100% $HOME/frameport_agent.py",
|
||||
|
||||
@@ -134,6 +134,24 @@ def _filters(manifest):
|
||||
for _, n, items in axml.component_filters(manifest)}
|
||||
|
||||
|
||||
def test_a_single_vr_activity_is_not_a_separate_vr_part():
|
||||
"""GitHub #476: Quest store builds give their only activity MAIN + INFO + VR (no LAUNCHER); that activity is
|
||||
the game itself, not a VR part behind a flat launcher."""
|
||||
from conftest import build_axml
|
||||
|
||||
def manifest(*cats):
|
||||
categories = [e for c in cats for e in (("start", "category", [("name", "str", c)]), ("end", "category"))]
|
||||
return build_axml([("start", "manifest", [("package", "str", "org.x.game")]), ("start", "application", []),
|
||||
("start", "activity", [("name", "str", "com.unity3d.player.UnityPlayerActivity")]),
|
||||
("start", "intent-filter", []),
|
||||
("start", "action", [("name", "str", axml.MAIN)]), ("end", "action"),
|
||||
*categories,
|
||||
("end", "intent-filter"), ("end", "activity"), ("end", "application"), ("end", "manifest")])
|
||||
assert axml.vr_activity(manifest(axml.INFO, axml.VR_CATEGORY)) is None
|
||||
assert axml.vr_activity(manifest(axml.VR_CATEGORY)) is None
|
||||
assert axml.vr_activity(manifest(axml.LAUNCHER, axml.VR_CATEGORY)) is None
|
||||
|
||||
|
||||
def test_start_activity_moves_launcher_to_the_vr_activity():
|
||||
m = _two_activity_manifest()
|
||||
assert axml.vr_activity(m) == "org.x.game.QuestActivity"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""A packaged app's own file list (frameport.core.bundle_files, GitHub #476)."""
|
||||
from pathlib import Path
|
||||
|
||||
from frameport.core import bundle_files
|
||||
|
||||
|
||||
def _package(tmp_path: Path) -> Path:
|
||||
pkg = tmp_path / "app/frameport"
|
||||
(pkg / "patches/frame").mkdir(parents=True)
|
||||
(pkg / "__init__.pyc").write_bytes(b"x" * 10)
|
||||
(pkg / "patches/frame/langpack.pyc").write_bytes(b"x" * 20)
|
||||
(pkg / "_data").mkdir()
|
||||
(pkg / "_data/tool.exe").write_bytes(b"unsigned")
|
||||
assert bundle_files.write(pkg) == 3
|
||||
return pkg
|
||||
|
||||
|
||||
def test_no_list_means_nothing_to_check(tmp_path):
|
||||
assert bundle_files.check(tmp_path) is None
|
||||
|
||||
|
||||
def test_matching_install_is_clean(tmp_path):
|
||||
pkg = _package(tmp_path)
|
||||
(pkg / "_data/tool.exe").write_bytes(b"signed later: other size") # signing after packaging is fine
|
||||
result = bundle_files.check(pkg)
|
||||
assert result.stale == [] and result.mismatched == [] and not result.broken
|
||||
|
||||
|
||||
def test_leftovers_of_another_version_are_removed(tmp_path):
|
||||
pkg = _package(tmp_path)
|
||||
old = pkg / "patches/frame/removed_patch.pyc"
|
||||
old.write_bytes(b"old")
|
||||
result = bundle_files.check(pkg)
|
||||
assert result.stale == ["patches/frame/removed_patch.pyc"] == result.removed
|
||||
assert not old.exists() and not result.broken and not bundle_files.is_stale(old, pkg)
|
||||
|
||||
|
||||
def test_mixed_versions_are_reported_and_left_alone(tmp_path):
|
||||
pkg = _package(tmp_path)
|
||||
(pkg / "patches/frame/langpack.pyc").write_bytes(b"older build") # the update didn't replace it
|
||||
extra = pkg / "patches/frame/ovr_org_scoped_id.pyc"
|
||||
extra.write_bytes(b"new")
|
||||
result = bundle_files.check(pkg)
|
||||
assert result.mismatched == ["patches/frame/langpack.pyc"] and result.broken
|
||||
assert extra.exists() and result.removed == [] # the list may not belong to the code: delete nothing
|
||||
assert bundle_files.is_stale(extra, pkg) # but never import it
|
||||
|
||||
|
||||
def test_missing_module_is_reported(tmp_path):
|
||||
pkg = _package(tmp_path)
|
||||
(pkg / "__init__.pyc").unlink()
|
||||
assert bundle_files.check(pkg).mismatched == ["__init__.pyc"]
|
||||
|
||||
|
||||
def test_patch_loader_skips_stale_modules(monkeypatch):
|
||||
import importlib
|
||||
|
||||
from frameport.patches import base, frame
|
||||
|
||||
folder = Path(frame.__path__[0])
|
||||
name = sorted(p.stem for p in folder.glob("*.py") if p.stem != "__init__")[0]
|
||||
monkeypatch.setattr(bundle_files, "_stale", {f"patches/frame/{name}.py"})
|
||||
imported = []
|
||||
monkeypatch.setattr(importlib, "import_module", lambda n, *a: imported.append(n))
|
||||
base._import_modules()
|
||||
assert imported and f"{frame.__name__}.{name}" not in imported
|
||||
@@ -0,0 +1,92 @@
|
||||
"""device.hybrid_app (GitHub #484): a Horizon OS hybrid app (YouTube) gets the system feature it checks before it
|
||||
switches from its 2D panel into VR, as a permissions file FramePort's Podman wrapper mounts into its container."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_steam_gamepad import ENV, LEPTON, NEEDS_POSIX, RUN, agent_module, anchor, frame_inputs
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.patches.base import InstallContext
|
||||
from frameport.validate import triage
|
||||
|
||||
FEATURE = "oculus.software.vr.app.hybrid"
|
||||
FEATURES_ENV = {"FRAMEPORT_FEATURES": FEATURE, "SteamAppId": "123"}
|
||||
|
||||
|
||||
def test_patch_is_offered_only_to_hybrid_apps_and_sets_the_feature():
|
||||
patch = base.get("device.hybrid_app")
|
||||
hybrid = SimpleNamespace(package="com.google.android.apps.youtube.vr.oculus", extra={"features": {FEATURE: False}})
|
||||
assert patch.applies(hybrid) and not patch.applies(SimpleNamespace(package="com.example.game", extra={}))
|
||||
assert patch.detect(hybrid) is None # opt-in: whether the VR part runs depends on the app
|
||||
ctx = InstallContext(package="x.y", params={}, files={}, env={}, adapter_settings={})
|
||||
patch.install(ctx)
|
||||
assert ctx.env == {"FRAMEPORT_FEATURES": FEATURE}
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_run_args_get_the_permissions_file(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
out = agent.podman_run_args(RUN, FEATURES_ENV, str(tmp_path / "sys"), str(tmp_path / "dev"))
|
||||
assert out[:2] == ["run", "--mount"] and out[3:] == RUN[1:]
|
||||
source = out[2].split(",")[1].partition("=")[2]
|
||||
assert out[2] == f"type=bind,source={source},destination={agent.FEATURES_TARGET},ro"
|
||||
text = open(source).read()
|
||||
assert f'<feature name="{FEATURE}" />' in text and text.startswith("<?xml")
|
||||
# names only: anything else in the variable is left out
|
||||
assert agent.system_features(f'{FEATURE}, x" /><evil, a.b') == ["a.b", FEATURE]
|
||||
unchanged = dict(FEATURES_ENV, FRAMEPORT_FEATURES='x" />')
|
||||
assert agent.podman_run_args(RUN, unchanged, str(tmp_path / "sys"), str(tmp_path / "dev")) == RUN
|
||||
assert agent.podman_run_args(RUN, dict(FEATURES_ENV, SteamAppId="9"), "/x", "/x") == RUN # another container
|
||||
|
||||
|
||||
@NEEDS_POSIX
|
||||
def test_features_and_gamepad_together(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
frame_inputs(tmp_path)
|
||||
out = " ".join(agent.podman_run_args(RUN, {**ENV, **FEATURES_ENV}, str(tmp_path / "sys"), str(tmp_path / "dev")))
|
||||
assert "destination=/dev/fp-input,rw" in out and "Vendor_28de_Product_11ff.kl,ro" in out
|
||||
assert f"destination={agent.FEATURES_TARGET},ro" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hw_video,gamepad", [(False, False), (True, False), (True, True)])
|
||||
def test_launchers_get_the_features_line_last(monkeypatch, tmp_path, hw_video, gamepad):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
directory = tmp_path / "apps/game"
|
||||
directory.mkdir(parents=True)
|
||||
agent.write_launcher(str(directory), str(tmp_path / "base"), "com.example.game", "Game", 123, "/lepton/lepton",
|
||||
{"FRAMEPORT_FEATURES": FEATURE}, hw_video=hw_video, gamepad=gamepad, features=True)
|
||||
text = (directory / "launch.sh").read_text()
|
||||
assert text.count(agent.FEATURES_LINE) == 1 and f"export FRAMEPORT_FEATURES={FEATURE}\n" in text
|
||||
for line in (agent.VIDEO_CODEC_LINE, agent.GAMEPAD_LINE):
|
||||
if line in text: # FramePort's wrapper first on PATH
|
||||
assert text.index(line) < text.index(agent.FEATURES_LINE)
|
||||
upgraded = agent.set_features_line(agent.set_gamepad_line(agent.set_codec_line(text, hw_video), gamepad), True)
|
||||
assert upgraded == text # upgrades keep it
|
||||
|
||||
|
||||
def test_upgrade_adds_the_line_after_a_codec_added_later(monkeypatch, tmp_path):
|
||||
agent = agent_module(monkeypatch, tmp_path)
|
||||
plain = LEPTON.format(lines=f"export FRAMEPORT_FEATURES={FEATURE}\n" + agent.FEATURES_LINE + "\n")
|
||||
directory = anchor(tmp_path, "com.game", plain, recipe={"patches": ["frame.hw_video_decode"]})
|
||||
assert agent.upgrade_launchers() == ["com.game"]
|
||||
text = (directory / "launch.sh").read_text()
|
||||
assert text.index(agent.VIDEO_CODEC_LINE) < text.index(agent.FEATURES_LINE)
|
||||
assert text.count(agent.FEATURES_LINE) == 1 and agent.upgrade_launchers() == []
|
||||
# the patch turned off: the next launcher has neither the export nor the line
|
||||
gone = anchor(tmp_path, "com.other", LEPTON.format(lines=agent.FEATURES_LINE + "\n"))
|
||||
agent.upgrade_launchers()
|
||||
assert agent.FEATURES_LINE not in (gone / "launch.sh").read_text()
|
||||
|
||||
|
||||
def test_finalize_uses_the_wrapper_for_features():
|
||||
from pathlib import Path
|
||||
source = (Path(__file__).resolve().parents[1] / "agent/frameport_agent.py").read_text()
|
||||
assert "if gamepad or features:\n ensure_podman_wrapper()" in source
|
||||
|
||||
|
||||
def test_triage_suggests_the_patch():
|
||||
log = ("10-25 05:18:21.264 1220 1220 W gob : "
|
||||
"Missing hybrid app system feature. Aborting launch of immersive mode.")
|
||||
result = triage.triage(log, "RUNNING")
|
||||
assert "hybrid-feature-missing" in [f.id for f in result.findings]
|
||||
assert "device.hybrid_app" in result.suggestions()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""GitHub #487: FramePort on the Frame (C/POSIX locale) started Java without UTF-8, so `LEGO® Bricktales/x.apk`
|
||||
reached OVRPort as `LEGO?? Bricktales/x.apk`."""
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.tools import toolchain
|
||||
|
||||
|
||||
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux only")
|
||||
def test_java_gets_a_utf8_locale_on_linux():
|
||||
assert toolchain.java_env({"PATH": "/bin"})["LC_ALL"] == "C.UTF-8"
|
||||
assert toolchain.java_env({"LC_ALL": "C", "LANG": "en_US.UTF-8"})["LC_ALL"] == "C.UTF-8"
|
||||
assert toolchain.java_env({"LANG": "en_US.UTF-8"}) is None # already UTF-8: inherited unchanged
|
||||
assert toolchain.java_env({"LC_CTYPE": "de_DE.utf8"}) is None
|
||||
|
||||
|
||||
def test_other_systems_inherit_the_environment(monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
assert toolchain.java_env({"LC_ALL": "C"}) is None
|
||||
@@ -59,6 +59,52 @@ def test_unclear_cases_stay_warnings():
|
||||
False, "unclear-version")
|
||||
|
||||
|
||||
CB2 = "com.sumalab.CrisisBrigade2reloaded"
|
||||
CB2_KINDS = ("office", "alleys", "challenges", "mansion", "ta1", "ta2", "ta3")
|
||||
|
||||
|
||||
def test_other_kinds_with_the_package_in_another_case_are_renamed():
|
||||
"""#415: Crisis Brigade 2's seven <kind>.222.<package>.obb files, the package part all lower case."""
|
||||
lower = [f"{k}.222.{CB2.lower()}.obb" for k in CB2_KINDS]
|
||||
assert names(CB2, lower) == {n: (f"{k}.222.{CB2}.obb", True, "case")
|
||||
for n, k in zip(lower, CB2_KINDS, strict=True)}
|
||||
assert names(CB2, [f"{k}.222.{CB2}.obb" for k in CB2_KINDS]) == {} # the right names
|
||||
# the kind keeps its own spelling; a copy suffix goes as for main/patch
|
||||
assert names(CB2, ["Office.222.com.sumalab.crisisbrigade2reloaded-002.obb",
|
||||
"ta1.222.com.sumalab.CrisisBrigade2reloaded (1).obb"]) == {
|
||||
"Office.222.com.sumalab.crisisbrigade2reloaded-002.obb": (f"Office.222.{CB2}.obb", True, "copy"),
|
||||
"ta1.222.com.sumalab.CrisisBrigade2reloaded (1).obb": (f"ta1.222.{CB2}.obb", True, "copy")}
|
||||
# the right name next to a copy: the copy stays as it is
|
||||
assert names(CB2, [f"office.222.{CB2}.obb", f"office.222.{CB2} - Copy.obb"])[f"office.222.{CB2} - Copy.obb"][1:] \
|
||||
== (False, "duplicate")
|
||||
|
||||
|
||||
def test_other_kinds_of_another_package_are_not_warned_about():
|
||||
"""Unlike main/patch, any other <word>.<n>.<x>.obb may be unrelated data: no "another app" warning."""
|
||||
assert names(CB2, ["office.222.com.sumalab.other.obb", "office.222.com.sumalab.CrisisBrigade2reloaded.extra.obb",
|
||||
"data.1.something.obb"]) == {}
|
||||
entry = {"package": CB2, "data_manifest": {"office.222.com.y.other.obb": 1}}
|
||||
assert pipeline.misnamed_obbs(entry) == [] and pipeline.renamed_obbs(entry) == []
|
||||
|
||||
|
||||
def test_other_kinds_go_to_the_frame_under_the_games_names():
|
||||
manifest = {f"{k}.222.{CB2.lower()}.obb": i for i, k in enumerate(CB2_KINDS, 1)}
|
||||
manifest[f"main.222.{CB2}.obb"] = 99
|
||||
assert quest_dump.frame_manifest(CB2, manifest) == {
|
||||
**{f"{k}.222.{CB2}.obb": i for i, k in enumerate(CB2_KINDS, 1)}, f"main.222.{CB2}.obb": 99}
|
||||
assert set(quest_dump.frame_names(CB2, manifest)) == {f"{k}.222.{CB2.lower()}.obb" for k in CB2_KINDS}
|
||||
|
||||
|
||||
def test_lower_case_other_kinds_next_to_the_apk_are_found(tmp_path):
|
||||
(tmp_path / "game.apk").write_bytes(b"")
|
||||
files = [f"{k}.222.{CB2.lower()}.obb" for k in CB2_KINDS] + [f"office.222.{CB2.lower()}-001.obb"]
|
||||
for n in files:
|
||||
(tmp_path / n).write_bytes(b"x")
|
||||
data, got = quest_dump.find_data(tmp_path, CB2, 222)
|
||||
# the copy loses the tie against the plain lower-case name of its kind; everything else is sent
|
||||
assert data == tmp_path and sorted(got) == sorted(files[:-1])
|
||||
|
||||
|
||||
def test_game_page_lists_renamed_files_as_info_and_the_rest_as_warnings():
|
||||
entry = {"package": VR4, "data_manifest": {"main.203.com.Armature.VR4-003.obb": 1,
|
||||
"patch.203.com.Armature.VR4-001.obb": 1}}
|
||||
@@ -136,6 +182,25 @@ def test_install_sends_renamed_files_under_the_games_names(tmp_path, monkeypatch
|
||||
"patch.203.com.Armature.VR4-001.obb"] # PC files untouched
|
||||
|
||||
|
||||
def test_install_sends_other_kinds_under_the_games_names(tmp_path, monkeypatch):
|
||||
"""#415: lower-case Crisis Brigade 2 files go to the Frame under the game's spelling; the PC copies keep theirs."""
|
||||
data = tmp_path / "CB2"
|
||||
data.mkdir()
|
||||
for k in CB2_KINDS:
|
||||
(data / f"{k}.222.{CB2.lower()}.obb").write_bytes(b"o" * 2)
|
||||
apk = tmp_path / "game.apk"
|
||||
apk.write_bytes(b"apk")
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, items, *a, **k: sent.extend(items))
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda frame, reporter, need: contextlib.nullcontext(frame))
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
frame = FakeFrame({})
|
||||
installer.install(frame, installer.InstallPlan(CB2, "CB2", apk, data, Recipe(CB2)), Reporter())
|
||||
assert sorted(rel for _, rel, _ in sent) == sorted(f"obb/{k}.222.{CB2}.obb" for k in CB2_KINDS)
|
||||
assert frame.calls["finalize"]["obb_manifest"] == {f"{k}.222.{CB2}.obb": 2 for k in CB2_KINDS}
|
||||
assert sorted(p.name for p in data.iterdir()) == sorted(f"{k}.222.{CB2.lower()}.obb" for k in CB2_KINDS)
|
||||
|
||||
|
||||
def test_install_renames_an_earlier_copy_on_the_frame_instead_of_sending_it(tmp_path, monkeypatch):
|
||||
"""Installed before this fix under the old name: moved into place on the Frame, nothing uploaded twice."""
|
||||
frame, sent, _ = _install(tmp_path, monkeypatch, {"main.203.com.Armature.VR4-003.obb": 5,
|
||||
|
||||
@@ -1097,9 +1097,8 @@ def test_swapchain_pool_is_opt_in_for_gles_and_vulkan():
|
||||
|
||||
def test_ac_nexus_uses_the_vulkan_panel_pool(monkeypatch):
|
||||
"""GitHub #424: the Vulkan panel pool frees the swapchain slots its Quest 3 eye images need (headless on the dev
|
||||
Frame, 2026-10-23). Until the release with it reaches users the catalog keeps the Quest 2 identity, since 1.3.0
|
||||
apps (GLES-only pool) read the same remote entry: at that release, drop device.headset_model from frame_remove and
|
||||
update this test. device.text_input_window stays off (its Android window took the controllers' input)."""
|
||||
Frame, 2026-10-23), so AC Nexus is a Quest 3 again from 1.4.0 (min_app: 1.3.0 apps have a GLES-only pool).
|
||||
device.text_input_window stays off (its Android window took the controllers' input)."""
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -1110,10 +1109,12 @@ def test_ac_nexus_uses_the_vulkan_panel_pool(monkeypatch):
|
||||
path = Path(__file__).resolve().parents[1] / "catalog/games/com.Ubisoft.ACNexusVR.yaml"
|
||||
entry = yaml.safe_load(path.read_text())
|
||||
assert entry["adapter"] == {"swapchain_pool": 1} and "#424" in entry["notes"]
|
||||
assert entry.get("frame_remove") == ["device.headset_model", "device.text_input_window"]
|
||||
assert entry.get("frame_remove") == ["device.text_input_window"]
|
||||
assert "device.text_input_window" not in (entry.get("device") or [])
|
||||
assert base.get("frame.adapter").revision_for("com.Ubisoft.ACNexusVR") == 3
|
||||
monkeypatch.setattr(frameport, "__version__", "1.3.0") # 1.3.0 knows swapchain_pool: the entry stays usable
|
||||
monkeypatch.setattr(frameport, "__version__", "1.3.0")
|
||||
assert catalog.unusable_reason(entry) == "needs FramePort 1.4.0"
|
||||
monkeypatch.setattr(frameport, "__version__", "1.4.0")
|
||||
assert catalog.unusable_reason(entry) is None
|
||||
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@ def test_fallback_rate_is_never_zero(tmp_path):
|
||||
def test_the_hook_is_always_installed():
|
||||
src = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
# not behind a per-game setting: every game that asks gets a rate, also where the runtime lacks the function
|
||||
assert re.search(r"\n if \(instance != XR_NULL_HANDLE\) "
|
||||
r"HOOK_AS\(xrGetDisplayRefreshRateFB, hook_get_refresh_rate\)",
|
||||
# and a refused request keeps the current rate (GitHub #336: OutRun quit on DISPLAY_REFRESH_RATE_UNSUPPORTED_FB)
|
||||
assert re.search(r"\n if \(instance != XR_NULL_HANDLE\) \{\n"
|
||||
r" HOOK_AS\(xrGetDisplayRefreshRateFB, hook_get_refresh_rate\)\n"
|
||||
r" HOOK_AS\(xrRequestDisplayRefreshRateFB, hook_request_refresh_rate\)",
|
||||
src)
|
||||
assert "fb_refresh_rate(" in (ROOT / "native/adapter/session_fixes.c").read_text()
|
||||
fixes = (ROOT / "native/adapter/session_fixes.c").read_text()
|
||||
assert "fb_refresh_rate(" in fixes and "result == XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB" in fixes
|
||||
|
||||
|
||||
def test_a_single_rejected_frame_is_not_an_error():
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""frame.keep_system_libraries (GitHub #484, YouTube VR): OVRPort drops every <uses-library>; the ones Lepton's
|
||||
Android provides (org.apache.http.legacy …) go back into the manifest, always optional."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from conftest import build_axml
|
||||
from pyaxmlparser.axmlprinter import AXMLPrinter
|
||||
from test_browser_handoff import _with_resource_map
|
||||
|
||||
from frameport.analysis import detect
|
||||
from frameport.apk import axml
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.core.models import Analysis
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame.system_libraries import LEPTON_LIBRARIES
|
||||
from frameport.recommend import engine
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
PKG = "com.google.android.apps.youtube.vr.oculus"
|
||||
|
||||
|
||||
def _uses(kind: str, name: str) -> list[tuple]:
|
||||
return [("start", kind, [("name", "str", name), ("required", "bool", False)]), ("end", kind)]
|
||||
|
||||
|
||||
def _manifest(original: bool) -> list[tuple]:
|
||||
"""Shaped like YouTube VR 1.89.09's manifest (diagnostics of #484): optional libraries between the components,
|
||||
two of them provided by no Android but Google's/Meta's own. `original=False` = after OVRPort's
|
||||
patch_remove_uses_library (every entry gone but libopenxr.google.so)."""
|
||||
lib = (lambda kind, name: _uses(kind, name)) if original else (lambda kind, name: [])
|
||||
return [
|
||||
("start", "manifest", [("package", "str", PKG)]),
|
||||
("start", "uses-feature", [("name", "str", "android.hardware.vr.headtracking"), ("required", "bool", False)]),
|
||||
("end", "uses-feature"),
|
||||
("start", "application", [("label", "str", "YouTube"), ("debuggable", "bool", False)]),
|
||||
("start", "activity", [("name", "str", "com.google.android.apps.youtube.vr.Main"),
|
||||
("exported", "bool", True)]),
|
||||
("start", "intent-filter", []),
|
||||
("start", "action", [("name", "str", axml.MAIN)]), ("end", "action"),
|
||||
("start", "category", [("name", "str", axml.LAUNCHER)]), ("end", "category"),
|
||||
("end", "intent-filter"),
|
||||
("end", "activity"),
|
||||
*lib("uses-library", "org.apache.http.legacy"),
|
||||
("start", "service", [("name", "str", "com.google.apps.tiktok.concurrent.AndroidFuturesService"),
|
||||
("exported", "bool", False)]),
|
||||
("end", "service"),
|
||||
*lib("uses-library", "android.ext.adservices"),
|
||||
*lib("uses-library", "androidx.window.extensions"),
|
||||
*lib("uses-library", "androidx.window.sidecar"),
|
||||
*_uses("uses-native-library", "libopenxr.google.so"), # OVRPort keeps this one
|
||||
*lib("uses-library", "com.android.extensions.xr"),
|
||||
("end", "application"),
|
||||
("end", "manifest"),
|
||||
]
|
||||
|
||||
|
||||
def _analysis(**extra) -> Analysis:
|
||||
return Analysis(package=PKG, version="1.89.09", label="YouTube", abis=["arm64-v8a"], engine="Other", xr="OpenXR",
|
||||
graphics="?", direct_vrapi=False, libs=[], launcher_activity=None, has_info_category=False,
|
||||
meta_permissions=[], uses_glad_gl=False, unity_msaa_levels=0, oculus_os_classes=False,
|
||||
is_overport_output=False, debuggable=False, extra={"vr_kind": "quest", **extra})
|
||||
|
||||
|
||||
YOUTUBE = _analysis(uses_libraries=["org.apache.http.legacy", "android.ext.adservices", "androidx.window.extensions",
|
||||
"androidx.window.sidecar", "com.android.extensions.xr"])
|
||||
|
||||
|
||||
def test_reads_uses_libraries():
|
||||
assert axml.uses_libraries(build_axml(_manifest(True))) == YOUTUBE.extra["uses_libraries"]
|
||||
assert axml.uses_libraries(build_axml(_manifest(False))) == []
|
||||
|
||||
|
||||
def _xml(manifest: bytes) -> str:
|
||||
"""The manifest as text, attribute prefixes left out (test manifests declare no namespace prefix)."""
|
||||
return re.sub(r"\b(ns0|android):", "", AXMLPrinter(manifest).get_xml().decode())
|
||||
|
||||
|
||||
def _check_restored(fixed: bytes, stripped: bytes) -> None:
|
||||
x = axml.Axml(fixed)
|
||||
assert axml.uses_libraries(fixed) == ["org.apache.http.legacy"]
|
||||
el = next(e for e in x.elements() if e.name == "uses-library")
|
||||
ids = axml._resource_map(x)[1]
|
||||
names = x.strings()
|
||||
got = {names[a.name]: (ids[a.name], a.dtype, a.value) for a in el.attrs}
|
||||
assert got["name"][:2] == (0x01010003, axml.TYPE_STRING) and names[got["name"][2]] == "org.apache.http.legacy"
|
||||
assert got["required"] == (0x0101028E, axml.TYPE_INT_BOOLEAN, 0)
|
||||
assert [ids[a.name] for a in el.attrs] == sorted(ids[a.name] for a in el.attrs) # aapt's order
|
||||
# an XML reader sees it under <application>, and everything else as before
|
||||
xml = _xml(fixed)
|
||||
app = xml[xml.index("<application"):xml.index("</application>")]
|
||||
assert 'uses-library name="org.apache.http.legacy" required="false"' in app
|
||||
assert 'uses-native-library name="libopenxr.google.so"' in app
|
||||
before = _xml(stripped)
|
||||
entry = '<uses-library name="org.apache.http.legacy" required="false"/>'
|
||||
assert "".join(xml.replace(entry, "").split()) == "".join(before.split())
|
||||
assert axml.add_uses_libraries(fixed, ["org.apache.http.legacy"]) is None # once
|
||||
|
||||
|
||||
def test_add_uses_libraries_round_trip():
|
||||
stripped = build_axml(_manifest(False))
|
||||
fixed = axml.add_uses_libraries(stripped, ["org.apache.http.legacy", "org.apache.http.legacy"])
|
||||
_check_restored(fixed, stripped)
|
||||
# like aapt: attribute names mapped to resource ids first ("required" is mapped by the edit)
|
||||
mapped = _with_resource_map(stripped, ["label", "name", "debuggable", "exported"])
|
||||
fixed = axml.add_uses_libraries(mapped, ["org.apache.http.legacy"])
|
||||
_check_restored(fixed, mapped)
|
||||
assert axml._resource_map(axml.Axml(fixed))[1][:4] == [0x01010001, 0x01010003, 0x0101000F, 0x01010010]
|
||||
assert axml.add_uses_libraries(stripped, []) is None
|
||||
|
||||
|
||||
def _apply(tmp_path, manifest: bytes, analysis: Analysis) -> tuple[bool, bytes, list]:
|
||||
apk = tmp_path / "in.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest)
|
||||
patch = base.get("frame.keep_system_libraries")
|
||||
with ApkWorkspace(apk) as ws:
|
||||
ctx = base.ApkContext(ws, analysis, {}, Reporter(), {patch.id: {}})
|
||||
changed = patch.apply(ctx)
|
||||
return changed, ws.read("AndroidManifest.xml"), patch.validate(ctx)
|
||||
|
||||
|
||||
def test_patch_restores_after_ovrport(tmp_path):
|
||||
stripped = build_axml(_manifest(False))
|
||||
changed, out, checks = _apply(tmp_path, stripped, YOUTUBE)
|
||||
assert changed and [ok for _, ok, _ in checks] == [True]
|
||||
_check_restored(out, stripped)
|
||||
|
||||
|
||||
def test_patch_changes_nothing_without_such_libraries(tmp_path):
|
||||
stripped = build_axml(_manifest(False))
|
||||
for a in (_analysis(), _analysis(uses_libraries=["android.ext.adservices", "com.android.extensions.xr"])):
|
||||
changed, out, checks = _apply(tmp_path, stripped, a)
|
||||
assert not changed and out == stripped and checks == []
|
||||
# OVRPort didn't run (entries still there): byte-identical
|
||||
original = build_axml(_manifest(True))
|
||||
changed, out, checks = _apply(tmp_path, original, YOUTUBE)
|
||||
assert not changed and out == original and [ok for _, ok, _ in checks] == [True]
|
||||
|
||||
|
||||
def test_detect_from_original_manifest(tmp_path):
|
||||
path = tmp_path / "youtube.apk"
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", build_axml(_manifest(True)))
|
||||
a = detect.analyze(path, deep=False)
|
||||
assert a.extra["uses_libraries"] == YOUTUBE.extra["uses_libraries"]
|
||||
patch = base.get("frame.keep_system_libraries")
|
||||
s = patch.detect(YOUTUBE)
|
||||
assert s.recommended and "org.apache.http.legacy" in s.reason and "adservices" not in s.reason
|
||||
assert patch.applies(YOUTUBE)
|
||||
assert patch.detect(_analysis()) is None and not patch.applies(_analysis())
|
||||
assert patch.detect(_analysis(uses_libraries=["android.ext.adservices"])) is None
|
||||
assert "frame.keep_system_libraries" in engine.suggest(YOUTUBE, use_catalog=False).patches
|
||||
assert "frame.keep_system_libraries" not in engine.suggest(_analysis(), use_catalog=False).patches
|
||||
# 2D apps aren't converted by OVRPort: their manifests keep the entries (and they stay unchanged)
|
||||
flat = _analysis(vr_kind="none", uses_libraries=["org.apache.http.legacy"])
|
||||
assert not patch.applies(flat)
|
||||
recipe = engine.suggest(flat, use_catalog=False)
|
||||
assert "frame.keep_system_libraries" not in recipe.patches and recipe.as_is
|
||||
assert not base.works_on_a32(patch)
|
||||
assert len(set(LEPTON_LIBRARIES)) == len(LEPTON_LIBRARIES) and "org.apache.http.legacy" in LEPTON_LIBRARIES
|
||||
|
||||
|
||||
def test_triage():
|
||||
log = ("10-25 00:26:12.111 1214 1214 E AndroidRuntime: Caused by: java.lang.NoClassDefFoundError: Failed "
|
||||
"resolution of: Lorg/apache/http/protocol/HttpRequestHandler;\n"
|
||||
"10-25 00:26:12.111 1214 1214 E AndroidRuntime: Caused by: java.lang.ClassNotFoundException: Didn't "
|
||||
"find class \"org.apache.http.protocol.HttpRequestHandler\" on path: DexPathList[[zip file \"/data/app/"
|
||||
"base.apk\"]]\n")
|
||||
r = triage(log, "EXITED", PKG)
|
||||
assert [f.suggest for f in r.findings if f.id == "system-library-missing"] == [["frame.keep_system_libraries"]]
|
||||
for line in ("java.lang.ClassNotFoundException: javax.obex.ServerRequestHandler",
|
||||
"NoClassDefFoundError: Failed resolution of: Landroid/test/InstrumentationTestCase;"):
|
||||
assert any(f.id == "system-library-missing" for f in triage(line, "EXITED", PKG).findings)
|
||||
other = "java.lang.ClassNotFoundException: com.android.settingslib.bluetooth.BroadcastSourceInfoHandler"
|
||||
assert not any(f.id == "system-library-missing" for f in triage(other, "RUNNING", PKG).findings)
|
||||
@@ -435,3 +435,19 @@ def test_source_restart_keeps_a_hidden_console_on_windows(monkeypatch):
|
||||
u._restart_process()
|
||||
flags = calls[0]["creationflags"]
|
||||
assert flags & 0x08000000 and not flags & 0x00000008 # CREATE_NO_WINDOW, not DETACHED_PROCESS
|
||||
|
||||
|
||||
def test_windows_script_never_mixes_two_versions():
|
||||
"""GitHub #476: a failed copy was rolled back without deleting the files the new version had added, and the old
|
||||
version's own leftovers stayed after a good one; Python imported both. scripts/update_smoke.py runs it for real."""
|
||||
text = updates.swap_script(Path("C:/data/updates/9.9.9/staged"), Path("D:/frameport"), 1234, "win32", True,
|
||||
Path("C:/data/logs/update.log"))
|
||||
# nothing changes while FramePort or a program file is still in use
|
||||
assert "is still running; nothing was changed" in text and "Test-InUse" in text
|
||||
assert text.index("Test-InUse $_") < text.index("New-Item -ItemType Directory -Path $backup")
|
||||
# the old version's files the new one doesn't have go into `previous`, only inside the bundle's own folders
|
||||
assert "Get-ChildItem -LiteralPath $src -Directory" in text and "Move-Item -LiteralPath $f.FullName" in text
|
||||
# a rollback deletes what the update added before restoring the old files
|
||||
rollback = text[text.index("} catch {"):]
|
||||
assert rollback.index("foreach ($rel in $added) { Remove-Item") < rollback.index("Join-Path $backup '*'")
|
||||
assert text.count("Start-FramePort") == 3 # defined; after "files in use"; at the end
|
||||
Reference in new issue
Block a user