Compare commits

..
13 Commits
Author SHA1 Message Date
spoopyghosty0 4fd95635d6 Site: current app and docs; Controller buttons page; clearer wording
- Home, meta descriptions: Linux apps alongside Quest, PC VR and Android; shorter notice, matching the README.
- Setup page: the app's labels ('Already set up?', 'Connect by address…'); Pair new host asks by itself.
- Tour: the install video is about 90 s.
- Docs: CONTROLLERS.md is a site page now, and the image sync copies docs/images subfolders (its diagram lives in
  controllers/); the 'game won't start' path starts with the FAQ.
- 'it'/'this'/'that' replaced by the noun where the referent was unclear (walkthrough, setup, download, install
  pages, compare, PC VR panel, demo app).
2026-10-24 23:35:53 -04:00
spoopyghosty0 8d2cb30da8 Docs: name the thing instead of 'it', 'this' or 'that' where the referent was unclear
Every page this branch touches: sentences that started with 'It'/'This'/'That' after naming two things, and short
answers ('report it', 'click it'), now say what they mean. Two FAQ headings start with 'The game' (no page links to
them).
2026-10-24 23:35:52 -04:00
spoopyghosty0 d16ff4e53f Docs: themes, install button, controller buttons, diagnostics, contributing: tighter wording
Diagnostics starts with what a user sends and moves the maintainer workflow into one paragraph; contributing folds
the tested-game section into the quick contributions.
2026-10-24 23:35:52 -04:00
spoopyghosty0 c5014bf249 Docs: install guide and FAQ: shorter, one idea per sentence
Same instructions in fewer words: setup alternatives as a list, moving from a PC in three short steps, room editing
as numbered steps, save backups once in the FAQ. The FAQ's 32-bit answer was outdated: the 32-bit bridge is
suggested for such games now.
2026-10-24 23:35:51 -04:00
spoopyghosty0 ce155af69d Docs: README and compatibility: no game count, shorter text
'Hundreds of games' instead of a number that goes stale (README, COMPATIBILITY, the site's comparison). Shorter
notice, features, quick start and comparison bullets; one-line docs index rows.
2026-10-24 23:35:51 -04:00
spoopyghosty0 173137ed33 Docs: architecture: triage signatures now update from GitHub main too 2026-10-24 23:35:50 -04:00
spoopyghosty0 370d3ae247 Docs: third-party notices list Cpp2IL and APKEditor, which FramePort downloads on first use 2026-10-24 23:35:50 -04:00
spoopyghosty0 e8b746ec9a Docs: transfer benchmark: remove local report paths, say what the uploader does today
The page named report files under a personal Windows home folder; the measurements stay, the paths go. A short
current-state section (OpenSSH SFTP for big files, 3 parallel uploads) heads the page, so the older 'production
still uses Paramiko, default 2' statements read as history.
2026-10-24 23:35:50 -04:00
spoopyghosty0 2f617d708a Docs: runtime reference: version-neutral title, current Revive-under-Proton facts, no agent versions
The title pinned SteamOS 0.3.0 although sections cover up to 0.5.x; PC VR under Proton isn't 'unverified' any more,
and Revive's hooks don't work there (the old line said they did). sshd comes with Developer Mode and FramePort's key,
no password. The headset-view webcam does show games. Foveation preferences moved next to the other foveation
notes (they sat under Monitoring sources), the scan preview below the room overview. Room tab paths.
2026-10-24 23:35:49 -04:00
spoopyghosty0 cf9da9a6d0 Docs: playbook: fix the broken first table, current PC VR model, one section for FramePort's own problems
The startup table had a row above its header separator (it rendered as plain text). PC VR games are installed
unchanged by default, Revive only for LibOVR games; the two duplicate -3001 rows are one now. Upload/SSH, install
link and store-detail rows moved out of the startup and PC VR tables into their own section, the OutRun rows into
the startup table. No agent version numbers; room scanning in the headset exists now; Add from a link.
2026-10-24 23:35:49 -04:00
spoopyghosty0 fdd3df2714 Docs: contributor pages: current menu labels, PC VR runs as-is by default, Linux apps, no agent versions
CONTRIBUTING: Share working recipe / Report a problem under their menu groups, Language lives in Appearance,
patch_docs and showcase pointers. ARCHITECTURE: bundles and backups as sources, session triage, remote catalog,
PC VR games installed unchanged with Revive only for LibOVR games, a Linux apps section. FRAME_SETUP: menu entries,
services and FEX that FramePort adds later. DIAGNOSTICS: no agent version numbers. 'for example' instead of e.g.
2026-10-24 23:35:48 -04:00
spoopyghosty0 9ef2c952b0 Docs: install guide and FAQ follow the new game menu, settings and save backups
INSTALL: menu paths through the game menu's groups, one section on when games appear in Steam (was buried under
running FramePort on the Frame), save backup folder/automatic snapshots, shorter overlay and first-launch notes,
rename/session/settings commands. FAQ: foveation lives in Game settings now, Exit game pauses games first, save
backups as a short list, controller buttons.
2026-10-24 23:35:48 -04:00
spoopyghosty0 404beb7786 Docs: README and compatibility pages: current game count, full docs index, Linux apps; regenerate the tested-games list 2026-10-24 23:35:48 -04:00
26 changed files with 80 additions and 1184 deletions

No files matched your search

+2 -12
View File
@@ -336,11 +336,7 @@ 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; 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,
replaced files kept in `updates/<ver>/previous` because the zip has no top folder; 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
@@ -639,7 +635,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), 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
**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
**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
@@ -818,9 +814,3 @@ 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.
+43 -142
View File
@@ -24,7 +24,6 @@ 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
@@ -49,7 +48,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 124
AGENT_VERSION = 123
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1190,91 +1189,18 @@ def library_mode(args=None):
return 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)
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():
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
with open(STATUS_FILE + ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if mode == "off":
pending = pending_library()
pending["packages"] = []
_write_json(PENDING_FILE, pending)
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}
drop_library_hooks_if_idle()
return {"mode": mode}
def pending_library():
@@ -1290,18 +1216,19 @@ def pending_library():
def 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": []}
# 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)
def _queue_library(packages, remove):
"""Caller holds the shortcut lock."""
pending = add_pending(packages, remove)
pending = pending_library()
pending["packages"] = sorted(set(pending["packages"]) | set(packages))
pending["remove"].extend(remove)
_write_json(PENDING_FILE, pending)
result = {"state": "pending", **pending, "added": [], "errors": []}
for user in library_users():
update_library(user, packages, result)
@@ -1400,10 +1327,8 @@ def cmd_library_pending(args):
remove_from_library(user, pending["remove"], result)
for user in library_users():
update_library(user, packages, result)
with pending_lock(): # only the errors: what was journaled meanwhile stays
current = pending_library()
current["errors"] = result["errors"]
_write_json(PENDING_FILE, current)
pending["errors"] = result["errors"]
_write_json(PENDING_FILE, pending)
return result
@@ -1419,13 +1344,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
consume_pending(pending)
_write_json(PENDING_FILE, {"packages": [], "remove": []})
drop_library_hooks_if_idle()
return result
for pkg in shortcuts_lost(library_users(), packages):
add_devkit_entry(pkg, result)
if not result["errors"]:
consume_pending(pending)
_write_json(PENDING_FILE, {"packages": [], "remove": []})
drop_library_hooks_if_idle()
_write_json(STATUS_FILE, result)
return result
@@ -1474,18 +1399,11 @@ def cmd_shortcuts(args):
return queue_library(packages, remove)
if not packages and not remove and not args.get("restart_now"):
return {"started": False}
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}
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)
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)
@@ -1579,22 +1497,9 @@ 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()
@@ -1602,7 +1507,16 @@ def _shortcuts_worker(payload):
pass
try:
users = library_users()
args["packages"], result["devkit"] = _split_devkit(args["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.
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
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:
@@ -1626,16 +1540,11 @@ def _shortcuts_worker(payload):
ensure_library_hooks()
_queue_library(args["packages"], args.get("remove", []))
return
if mode == "off": # games journaled meanwhile are dropped too; removals still happen
if mode == "off" and not args.get("remove"):
_write_json(STATUS_FILE, {"state": "done", "disabled": True, "added": [], "errors": []})
return
if mode == "off":
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
@@ -1643,20 +1552,13 @@ 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
# 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"])
pending = pending_library()
args["packages"] = sorted(set(args["packages"]) | {p for p in pending["packages"] if deployment(p)})
args.setdefault("remove", []).extend(pending["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:
@@ -1678,8 +1580,7 @@ def _shortcuts_worker(payload):
result["errors"].append(str(exc))
run(["systemctl", "--user", "start", "steam.service"])
if result["state"] == "done" and not result["errors"]:
if applied is not None:
consume_pending(applied)
_write_json(PENDING_FILE, {"packages": [], "remove": []})
drop_library_hooks_if_idle()
try:
os.remove(RESTART_REQUEST_FILE)
-7
View File
@@ -172,13 +172,6 @@ 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: web-wrapper
pattern: '(Creating TwaLauncher for com\.oculus\.browser|NameNotFoundException:? com\.oculus\.browser)'
severity: fatal
+3 -10
View File
@@ -75,16 +75,9 @@ 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 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.
- `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.
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
-8
View File
@@ -1382,14 +1382,6 @@ 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`
-4
View File
@@ -206,10 +206,6 @@ 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).
+2 -16
View File
@@ -2,7 +2,7 @@
# Patches
Every patch and game setting FramePort knows: 202 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: 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.
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,7 +11,7 @@ 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) (71)
- [Steam Frame patches](#group-frame) (70)
- [Game settings](#group-adapter) (82)
- [Files and settings on the Frame](#group-device) (11)
- [PC VR (Proton and Revive)](#group-pcvr) (13)
@@ -62,7 +62,6 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [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 |
| [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 |
@@ -753,19 +752,6 @@ 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 &lt;uses-library&gt; 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.
-1
View File
@@ -117,7 +117,6 @@ 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 |
| `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`) |
+1 -18
View File
@@ -53,21 +53,6 @@ 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")
@@ -99,9 +84,7 @@ def main() -> int:
print(" ".join(cmd))
rc = subprocess.call(cmd, cwd=ROOT)
if rc == 0:
out = ROOT / "dist" if args.pyinstaller else ROOT / "dist" / TARGET
check_bundle(out)
write_file_list(out)
check_bundle(ROOT / "dist" if args.pyinstaller else ROOT / "dist" / TARGET)
return rc
finally:
if not args.keep_data:
+14 -65
View File
@@ -3,14 +3,10 @@
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 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.
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.
"""
from __future__ import annotations
@@ -24,37 +20,6 @@ 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
@@ -78,44 +43,28 @@ 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"
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)
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_marker = installed / marker.relative_to(new)
assert installed_marker.read_text() == "new", "the new files weren't installed"
assert installed_nested.read_text() == "new", "a subfolder wasn't updated"
assert (installed / nested.relative_to(new)).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)
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
if platform != "win32": # Windows copies over the old files (the zip has no folder of its own); others swap
assert not stale.exists(), "the old version's files are still there"
print("update smoke test passed")
return 0
-5
View File
@@ -549,11 +549,6 @@
"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 -5
View File
@@ -60,9 +60,7 @@ 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)
# 27: uses_libraries (the original manifest's <uses-library> names, which OVRPort removes:
# frame.keep_system_libraries, GitHub #484) (2026-10)
ANALYSIS_VERSION = 27 # (cortopia_org_id's TMNT layout, GitHub #422, needed no re-analysis: see the patch)
ANALYSIS_VERSION = 26 # (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)
@@ -493,8 +491,6 @@ 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)
-54
View File
@@ -794,60 +794,6 @@ 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."""
-124
View File
@@ -1,124 +0,0 @@
"""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
-9
View File
@@ -173,7 +173,6 @@
"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}": [
"",
""
@@ -349,7 +348,6 @@
"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": "",
@@ -516,7 +514,6 @@
"Download all…": "",
"Download and install": "",
"Download and update": "",
"Download page": "",
"Download selected…": "",
"Download to this PC…": "",
"Download to which folder on this PC?": "",
@@ -747,8 +744,6 @@
"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.": "",
@@ -968,7 +963,6 @@
"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": "",
@@ -1015,7 +1009,6 @@
"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": "",
@@ -1040,7 +1033,6 @@
"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 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).": "",
@@ -1345,7 +1337,6 @@
"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).": "",
-5
View File
@@ -185,15 +185,10 @@ 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}")
@@ -1,87 +0,0 @@
"""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)
-2
View File
@@ -123,8 +123,6 @@ 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.",
+6 -30
View File
@@ -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 and ta1.<versionCode>.<package>.obb (GitHub #415)
NAMED_OBB = re.compile(r"^([a-z][a-z0-9]*)\.(?:(\d+)\.)?(.+)\.obb$", re.IGNORECASE)
# office.<versionCode>.<package>.obb (GitHub #415)
NAMED_OBB = re.compile(r"^([a-z]+)\.(?:(\d+)\.)?(.+)\.obb$", re.IGNORECASE)
# What downloads and copies add after the package (GitHub #450/#481): main.203.com.Armature.VR4-003.obb,
@@ -226,10 +226,8 @@ class ObbName:
def obb_names(package: str, rels) -> list[ObbName]:
"""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
"""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
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
@@ -245,13 +243,6 @@ 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 ""
@@ -287,21 +278,6 @@ 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}
@@ -340,8 +316,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()
# 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):])):
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 (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)]:
-48
View File
@@ -217,7 +217,6 @@ 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
@@ -3170,27 +3169,6 @@ 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:
@@ -3314,31 +3292,6 @@ 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
@@ -3358,7 +3311,6 @@ 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
+8 -67
View File
@@ -617,99 +617,40 @@ 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()).
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."""
"""The script that installs `app` over `target` once process `pid` has exited (text; see apply())."""
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)}.TrimEnd('\\'); $dst = {_ps_quote(target)}.TrimEnd('\\')
$src = {_ps_quote(app)}; $dst = {_ps_quote(target)}
$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; note the ones that are new
foreach ($rel in $new) {{
# 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('\\')
$old = Join-Path $dst $rel
if (Test-Path -LiteralPath $old -PathType Leaf) {{
if (Test-Path -LiteralPath $old) {{
$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 ($($added.Count) new file(s), $moved old file(s) removed)"
Write-Log 'update: installed'
}} 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-FramePort
{start}
""")
if platform == "darwin":
start = f"open {_sh_quote(target)}" if relaunch else "echo 'not relaunching' >>\"$log\""
-140
View File
@@ -2567,146 +2567,6 @@ 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",
-66
View File
@@ -1,66 +0,0 @@
"""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
-65
View File
@@ -59,52 +59,6 @@ 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}}
@@ -182,25 +136,6 @@ 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,
-178
View File
@@ -1,178 +0,0 @@
"""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)
-16
View File
@@ -435,19 +435,3 @@ 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