Compare commits

...
15 Commits
Author SHA1 Message Date
spoopyghosty0 e4465fd799 Docs: update screenshots and videos 2026-10-12 01:56:22 +00:00
spoopyghosty0 80ef9ca6a7 Site: release and contributor data refreshed (1.0.4) 2026-10-11 21:30:25 -04:00
spoopyghosty0 c744f8740b Site: the games board lists games in a random order on every visit (working games first, then with issues, then not running) 2026-10-11 21:30:13 -04:00
spoopyghosty0 859f921361 Monitor: games Steam runs that FramePort didn't install count (e.g. Job Simulator from Steam's store, named from its appmanifest; End game = Steam's Exit game), overlay apps are never the game being played, and the newest game comes first (agent v81) 2026-10-11 17:27:10 -04:00
spoopyghosty0 6acf9d048e App icon: all four corners rounded (only the top-left one was); source runs on Windows show FramePort's icon instead of Flet's (window.icon from an .ico made of it) 2026-10-11 14:40:32 -04:00
spoopyghosty0 cf18b30be9 Uninstall dialog: the delete-saves checkbox label wraps (it ran past the dialog; also the delete-files-on-this-PC, share-recipe and remove-FramePort checkboxes), games uninstalled from the Frame page show their name; Monitor: the agent's own workers aren't a running game (uninstalling showed the game as now playing while Steam restarted; agent v80) 2026-10-11 14:33:47 -04:00
spoopyghosty0 eec0218b1d FramePort 1.0.4 2026-10-11 14:13:56 -04:00
spoopyghosty0 526ba0a684 Launch tests: a game that ran until the session ended for lack of a worn headset (VrApi bridge tracking deadline) passes, even when it crashes afterwards (BlazeRush 'Failed to launch' on install, GitHub #57) 2026-10-11 14:12:04 -04:00
spoopyghosty0 23b6ff1b5e Dev: WSL source runs can share the Windows copy's data folder (FRAMEPORT_WINDOWS_DATA=1): paths saved by the other side are translated on load, Java and Cpp2IL keep one copy per OS, and such runs leave the link handler alone 2026-10-11 13:59:37 -04:00
spoopyghosty0 00c6863e54 FramePort 1.0.3 2026-10-11 13:31:33 -04:00
spoopyghosty0 4863035a05 catalog: Robo Recall gets sync_guard (vrclient xrSyncActions crash on the splash after the intro, GitHub #188) 2026-10-11 13:31:20 -04:00
spoopyghosty0 a30162ea63 Fixes from reports: update dialog links open again (#194), a relaunched update no longer inherits the old app's Flet/Python environment (black window, #195), report form says to attach the zip in a comment if the box won't take it (#196), Job Simulator and Vacation Simulator without Lepton's Android window (frozen hands, #193/#163) 2026-10-11 13:27:18 -04:00
spoopyghosty0 24eaa97fde GUI: one window per data folder (a second start brings it to the front, a windowless leftover is ended), the process ends with its window, and checked play sessions get one pop-up instead of one per game 2026-10-11 13:20:31 -04:00
spoopyghosty0 767b152423 Merge catalog recipe from #183 2026-10-11 12:29:42 -04:00
github-actions[bot] 10e7669f70 catalog: com.drawandcode.TaskmasterVR (from #183) 2026-10-11 16:29:18 +00:00
45 changed files with 959 additions and 108 deletions

No files matched your search

+1 -1
View File
@@ -38,6 +38,6 @@ body:
id: logs
attributes:
label: Diagnostics
description: Drag the FramePort-diag-*.zip here (max 25 MB)
description: "Drag the FramePort-diag-*.zip here (max 25 MB). If the box won't take it, submit the report and attach the zip in a comment."
validations:
required: true
+22 -3
View File
@@ -69,6 +69,10 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
needs agent v73), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
Games in a sample (agent v81): FramePort's own (launch.sh / container / Proton folder), any other game Steam's
reaper runs (`steam:<appid>`, title from the appmanifest, e.g. Job Simulator from Steam's store; not SteamVR, not
devkit entries; End game = Steam's Exit game), overlay apps flagged `overlay` and listed last; newest first. The
UI takes `frame/monitor.main_game(sample)` (never an overlay app alone). The agent's own workers never count.
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
@@ -323,8 +327,17 @@ Games tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>); on-device chec
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
managed tools. Real app data (WSL): `~/.local/share/frameport` (tools, overport workspace + game keystores, library,
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
managed tools. **The owner's real app data is the Windows copy's: `%LOCALAPPDATA%\FramePort`** (= `/mnt/c/Users/
<user>/AppData/Local/FramePort`; tools, overport workspace + game keystores, library, artwork, frames.json, the app's
SSH key which the dev Frame authorizes; moved from WSL's `~/.local/share/frameport` on 2026-10-11, which is now only
a stale backup). The desktop shortcut "FramePort (dev)" runs the source checkout natively on Windows
(`%LOCALAPPDATA%\FramePort-dev\launch-frameport-dev.vbs`: venv `FramePort-dev\venv` via `UV_PROJECT_ENVIRONMENT`,
`uv sync --frozen`, log `FramePort\logs\dev-gui.log`). **WSL runs on that data need `FRAMEPORT_WINDOWS_DATA=1`**
(set in `.claude/settings.local.json`; `paths.shares_windows_data`): the data folder becomes the Windows one, saved
paths from the other side are translated on load (`paths.localize`: /mnt/d/X <-> D:\X in library.json, tools.json,
PC deployment records), Java/Cpp2IL keep one copy per OS (tools.json `java-<os>-<arch>`), and the link handler isn't
registered (it would point the Windows copy's links at WSL). Don't run a WSL GUI and the Windows GUI at once
(the gui.lock flock/msvcrt locks don't see each other; only the gui.alive heartbeat does).
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
- Docs screenshots + videos (docs/SHOWCASE.md): never by hand. `uv run python scripts/showcase/render_docs.py`
@@ -352,6 +365,11 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
`T.px(n)`, Material defaults through the theme's text_theme; never hardcode a bare pixel number in ui/.
`scripts/ui_smoke.py --scale 1.5 --viewport 2560x1440` renders it. Flet draws a grey box for invalid layouts (e.g.
an `expand` child in a `wrap=True` Row).
- One GUI per data folder (`core/instance.py`, 2026-10-11): `<data>/gui.lock` (flock/msvcrt, pid inside); a second
start asks the open window to come to the front (`gui.focus`, read by `_watch_links`) and exits; a lock held with no
window (no `gui.alive`) and older than 30 s is a leftover FramePort process and is ended. `main()` ends the process
(`os._exit`) once `ft.run` returns: background threads (re-analysis, session triage) used to keep a windowless
process running. Session triage shows one pop-up for all games of a burst (`_announce_sessions`).
- Stopping the GUI: `pkill -f` patterns match your own shell — use `pgrep -f "[b]in/frameport-gui|[f]let-desktop-light"`.
## Round-2 polish (2026-10-02)
@@ -827,7 +845,8 @@ on the dev Frame) in `files/framebridge-vrapi-fov.txt` and answers with it at th
r_3dwidth/height, r_width/height from the runtime eye size (FrameBridge logs `runtime recommended eye size`) × scale,
written at finalize, set_settings and by launch.sh before Lepton starts (`_config_sync`). Headless runs at scale 1.7
end in `zink: DEVICE LOST` right after the bridge's 30 s no-tracking deadline tears the session down (gpu fault at the
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline.
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline. Launch tests count an exit/crash after
such a line as a pass (triage.yaml `headless_end`, finding `headless-end`; GitHub #57 "Failed to launch" on install).
**Session triage (agent v70, 2026-10-09):** launch tests never reach FOCUSED, so real play sessions are triaged
too. list_installed gives each game `last_play` {start, end, test} from `<anchor>/plays.log` (launch tests write a
`test <unix>` line first → test sessions are only marked); `session_log` returns the newest session's launch.log
+52 -5
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 79
AGENT_VERSION = 81
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -6604,6 +6604,10 @@ MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor",
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
AGENT_FILES = {os.path.abspath(__file__), os.path.realpath(__file__)}
STEAM_GAME = "steam:" # monitor: a game Steam started that FramePort didn't install ("steam:<appid>", e.g. a Lepton
# game from Steam's store); its title comes from the appmanifest
REAPER_APPID = re.compile(r"\bAppId=(\d+)")
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
("steam", ("steam", "reaper", "fossilize")),
@@ -6935,6 +6939,7 @@ class Monitor:
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
self.deps = []
self.deps_at = -1e9
self.steam_titles = {}
self.tick = 0
self.last = None # time of the previous sample
self.disk_at = -1e9
@@ -7035,10 +7040,16 @@ class Monitor:
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
if m and m.group(1) in by_appid:
root = (by_appid[m.group(1)]["package"], "container")
else:
elif not any(a in cmd for a in AGENT_FILES):
# the agent's own workers name a game's launch.sh too (uninstall's shortcut removal while Steam
# restarts): they aren't the game (helpers launch.sh starts still count, as its children)
pkg = next((k for path, k in anchors if path in cmd), None) or \
next((k for base, k in bases if base in cmd), None)
root = (pkg, "launcher") if pkg else None
m = REAPER_APPID.search(cmd) if comm == "reaper" and not pkg else None
# any other game Steam runs (its reaper); not SteamVR itself, not a devkit entry of ours
if m and m.group(1) not in by_appid and m.group(1) != STEAMVR_APPID and "/devkit-game/" not in cmd:
root = (STEAM_GAME + m.group(1), "steam")
self.root_cache[key] = root
if self.root_cache[key]:
roots[pid] = self.root_cache[key]
@@ -7116,7 +7127,7 @@ class Monitor:
deps = {d["package"]: d for d in self.deployments(now)}
out = []
for pkg in sorted({v[0] for v in self.roots.values()}):
d = deps.get(pkg) or {}
d = deps.get(pkg) or (self.steam_game(pkg[len(STEAM_GAME):]) if pkg.startswith(STEAM_GAME) else {})
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
@@ -7136,10 +7147,25 @@ class Monitor:
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
"fps": fps, "frame_ms": ms, "processes": len(pids)})
"fps": fps, "frame_ms": ms, "processes": len(pids), "overlay": is_overlay(d)})
# the game in front first: overlay apps (they run next to games) last, then the most recently started
out.sort(key=lambda g: (g["overlay"], g["elapsed"] is None, g["elapsed"] or 0))
self.games = out
return out
def steam_game(self, appid):
"""{title, kind, appid} of a Steam game FramePort didn't install (cached: appmanifest/shortcuts reads)."""
if appid not in self.steam_titles:
app = find_app_id(appid)
name = (app or {}).get("name")
if not name:
for user in steam_users():
name = shortcut_names(user).get(appid)
if name:
break
self.steam_titles[appid] = name or f"App {appid}"
return {"title": self.steam_titles[appid], "kind": "steam", "appid": appid}
def filtered(self, now=None):
procs = self.procs
span = (now - self.scan_prev) if now is not None and self.scan_prev is not None else 0.0
@@ -7285,8 +7311,29 @@ class Monitor:
return {"ended": False, "pid": pid, "name": comm}
def steam_app_running(appid):
"""A Steam reaper runs this app id (a Steam game FramePort didn't install)."""
for name in os.listdir(PROC):
if name.isdigit() and _rd(f"{PROC}/{name}/comm").strip() == "reaper" and \
re.search(rf"\bAppId={appid}\b", _rd(f"{PROC}/{name}/cmdline").replace("\0", " ")):
return True
return False
def monitor_end_game(pkg, wait=6.0):
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs."""
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs.
A Steam game FramePort didn't install ("steam:<appid>"): Steam's Exit game only."""
if str(pkg).startswith(STEAM_GAME):
appid = str(pkg)[len(STEAM_GAME):]
if not appid.isdigit():
raise AgentError(f"bad Steam app id {appid!r}")
if not steam_app_running(appid):
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
steam_js(f"SteamClient.Apps.TerminateApp('{appid}', false)")
end = time.monotonic() + wait
while time.monotonic() < end and steam_app_running(appid):
time.sleep(0.5)
return {"ended": not steam_app_running(appid), "via": "steam", "package": pkg}
pkg = check_pkg(pkg)
dep = deployment(pkg)
if not dep:
@@ -5,6 +5,8 @@ notes: Needed the LAUNCHER category fix.
details: Source APK is a community 'patch+savefix+90Hz' build. Shares Vader Immortal's Unreal 4 Oculus input (GitHub
49) - thumbs follow the touches (frame.unreal_thumb_touch) and controller poses are located at the right time
(pose_time_fix); added 2026-10-09 from Vader's headset results, not yet checked in this game.
sync_guard added 2026-10-11 for a vrclient xrSyncActions crash on the loading splash after the intro (GitHub
#188; the same runtime race as Myst).
tested_version: '1.0'
engine: Unreal
xr: VrApi
@@ -14,10 +16,11 @@ frame:
- frame.unreal_thumb_touch
adapter:
pose_time_fix: 1
sync_guard: 1
verified:
date: '2026-09-28'
overport_cli: 1.2.3
overport_runtime: 3.4.3-23204ea
known_good_sha256: 4ce6e3563da131e288645f4b62a634ac98043e55bc42b7c0ad5f461207d5f53c
updated: '2026-10-09'
updated: '2026-10-11'
source_hint: Robo Recall- Unplugged
@@ -0,0 +1,24 @@
package: com.drawandcode.TaskmasterVR
title: Taskmaster
status: works
notes: Launched and played a few tasks. Seems to work pretty much flawlessly other than some occasionally finicky
grip detection - very possible that this is just the game itself though
tested_version: 1.0.17557.29562
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-11'
known_good_sha256: e3b3c49d77284afb76e9f960828cbfaa4250d66c11b7fd9e4053d489aca662ae
app: 1.0.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 78
issue: 183
source_hint: Taskmaster VR
@@ -1,13 +1,12 @@
package: com.owlchemylabs.jobsimulator
title: Job Simulator
status: works
notes: 'Without Lepton''s Android window: with it, the hands froze after a few seconds (GitHub #193).'
tested_version: 1.4.0.6515
engine: Unity
xr: OpenXR
frame:
frame_remove:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-03'
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
@@ -16,4 +15,5 @@ verified:
frame_build: '20260930.6234839'
agent: 33
issue: 11
updated: '2026-10-11'
source_hint: Job Simulator
@@ -1,14 +1,13 @@
package: com.owlchemylabs.vacationsimulator
title: Vacation Simulator
status: issues
notes: Controllers aren't tracked (reported; not yet diagnosed).
status: works
notes: 'Without Lepton''s Android window: with it (or after clicking it), the hands stayed on the floor (GitHub
#163).'
tested_version: 1.3.0.40142
engine: Unity
xr: OpenXR
frame:
frame_remove:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
refresh_rate: 90.0
@@ -20,4 +19,5 @@ verified:
frame_build: '20261007.6125817'
agent: 59
issue: 163
updated: '2026-10-11'
source_hint: com.owlchemylabs.vacationsimulator
+7
View File
@@ -481,6 +481,13 @@ signatures:
the video was decoded in software instead, which is slow for 4K/8K. Turn off hardware video decoding in Steam's
settings, restart Steam, then start the game again.
suggest: []
# Launch tests run without a worn headset. Some parts give up on tracking after a while and end the VR session; what
# the game does after that (often a crash in the GPU driver while its renderer tears down) never happens in the
# headset. In a launch test that already had paced frames before such a line, a later exit or crash still passes,
# with a note (finding `headless-end`). Play sessions ignore this list.
headless_end:
- label: the VrApi bridge's 30 s tracking deadline
pattern: 'EnterVrMode: no tracked head pose before deadline'
milestones: # progress markers, in order; the furthest one reached is reported
- {id: pcvr-launch, kind: pcvr, pattern: 'FramePort: launching|Launched injector with', label: Launcher started}
- {id: pcvr-oculus-hmd, kind: pcvr, pattern: 'FramePort oculushmd: OculusHMDConnected event ready', label: Oculus headset event provided}
+10 -10
View File
@@ -59,7 +59,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 1 |
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 13 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 14 |
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
| [Trace Meta platform requests (diagnostics)](#frame-ovr_trace) | `frame.ovr_trace` | Steam Frame patches | |
@@ -77,7 +77,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 9 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 17 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 57 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 56 |
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 1 |
@@ -114,7 +114,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 43 |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 44 |
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
@@ -146,7 +146,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Native video surfaces (Batman renderer)](#adapter-surface_native) | `adapter.surface_native` | Game settings | 1 |
| [Swap eyes](#adapter-swap_eyes) | `adapter.swap_eyes` | Game settings | |
| [Retry refused picture formats](#adapter-swapchain_fix) | `adapter.swapchain_fix` | Game settings | |
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 2 |
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 3 |
| [Hide Vulkan foveation](#adapter-vk_hide_fdm) | `adapter.vk_hide_fdm` | Game settings | 1 |
| [Stop objects popping in and out](#adapter-vk_query_slots) | `adapter.vk_query_slots` | Game settings | |
| [Vulkan shader dump (diagnostics)](#adapter-vk_shader_dump) | `adapter.vk_shader_dump` | Game settings | |
@@ -162,7 +162,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 57 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 56 |
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
@@ -668,7 +668,7 @@ Provides stand-ins for Quest system calls some games make at start.
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
- **Offered (off):** … references com.oculus.os.AnalyticsEvent; usually harmless (for example Batman: Arkham Shadow and LEGO Bricktales run without it). Enable if the game aborts with ClassNotFoundException.
- **Catalog games (13):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 5 more.
- **Catalog games (14):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 6 more.
- **Suggested by launch-test signatures:**
- `oculus-os-class` (fatal): The game looks up a Quest-only system class and aborts.
- **Source:** [patches/frame/telemetry.py](../src/frameport/patches/frame/telemetry.py)
@@ -908,7 +908,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
- `frame.unity_text_input` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
- **Catalog games (57):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 49 more.
- **Catalog games (56):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, BattleSisters, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad and 48 more.
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -&gt; false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -&gt; true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
@@ -1436,7 +1436,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
- **Catalog games (43):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Caves, Clockwork, Cosmodread, cubism and 35 more.
- **Catalog games (44):** AgeOfJoy, Ancient_Dungeon, Audica, BattleSisters, Caves, Clockwork, Cosmodread, cubism and 36 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.
@@ -1905,7 +1905,7 @@ For games that crash right after you return to them (for example after the Steam
- **Options:**
- Value: int, default `0`.
- Game settings → Troubleshooting → "Steady controller input after focus" (advanced).
- **Catalog games (2):** Batman: Arkham Shadow, Myst.
- **Catalog games (3):** Batman: Arkham Shadow, Myst, Robo Recall.
- **Suggested by launch-test signatures:**
- `runtime-input-crash` (fatal): The Frame's runtime crashed in its controller code, usually right after the game got focus. sync_guard runs input syncs (xrSyncActions) one at a time and pauses them briefly after focus returns.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -2134,7 +2134,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
- `device.text_input_window`
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
- **Catalog games (57):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad, BodyCombat and 49 more.
- **Catalog games (56):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Audica, Beat Saber, Beat Saber (co-existence build), Blade & Sorcery: Nomad, BodyCombat and 48 more.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
+1
View File
@@ -31,6 +31,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| An overlay app (fpsVR, a wrist watch) installed on the Frame never appears, or SteamVR doesn't start it | not registered with the Frame's SteamVR, or registered with a manifest SteamVR skips: on the Frame vrserver reads only `binary_path_linux_arm` (vrserver.txt: "must specify binary_path for launch_type binary. Skipping") | `pcvr.vr_overlay` (Windows) / a Linux app with a bundled `.vrmanifest` (`is_dashboard_overlay`): agent v75 `register_vr_overlay` (check `vrcmd --overlays`, `~/.config/openvr/config/appconfig.json` manifest_paths); the app's own `--install` writes binary_path_linux only. fpsVR under Proton never loads OpenVR (quits after 60 s): unsupported for now |
| An overlay app with **Start with SteamVR** on doesn't start by itself after the Frame or SteamVR starts (it works when started by hand) | SteamVR's own auto-launch flag (SetApplicationAutoLaunch) didn't persist on the Frame (no autolaunch entry in `~/.config/openvr/config/steamvr.vrsettings`) | agent v76: FramePort's service `frameport-vr-overlays.service` starts it at every new vrserver. Check `systemctl --user status frameport-vr-overlays`, `~/.local/share/frameport/vr-overlays.log` (`new SteamVR (vrserver N)` → `<pkg>: started` / `running` / `couldn't start it: …`), the deployment's `overlay.autostart`, and that `~/.local/share/frameport/vr-overlays.disabled` doesn't exist; `frameport frame info` (ensure_host_fixes) installs a missing service |
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
| Launch test "failed" although the game runs in the headset: frames paced, then `OVRPortVrApi: EnterVrMode: no tracked head pose before deadline`, then a crash (e.g. `zink: DEVICE LOST` + SIGSEGV in a `gdrv` thread) (GitHub #57, BlazeRush) | the VrApi bridge ends the VR session after 30 s without head tracking, which only happens without a worn headset; the game's renderer then crashes while tearing down | none needed: launch tests (`triage(..., headless=True)`) treat an exit or crash after a `headless_end` line (catalog/triage.yaml) as a pass when frames were paced before it (info finding `headless-end`); play sessions ignore it. Add new such markers to `headless_end` |
| Launch test EXITED, launch.log has `Target app (<pkg>) did not start` and no `Start proc …:<pkg>` (GitHub #179) | Lepton occasionally doesn't start the app on the first start after an APK change; Android's `WebViewLoader-: System.exit` at boot is not the game (triage `app-not-started`) | start it again |
| A game keeps no progress between starts although it saves (its storage gets a new `Profiles/Oculus/<id>/`-style folder per start; Walkabout Mini Golf, GitHub #182) | OVRPort's platform loader answers `ovr_GetLoggedInUserID` with a made-up id (6e18 + `random()`), new at every start; games file saves under the user id | `frame.ovr_stable_user` (default for games with the platform library): a fixed id per game and keystore. Old progress: copy the newest old profile into the new id's folder |
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (for example Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+1
View File
@@ -149,6 +149,7 @@ def main() -> int:
("transit-activity", lambda a: a.show_activity(True))]
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
+7 -2
View File
@@ -79,7 +79,7 @@ const latest = latestCheck(games);
<script type="application/json" data-snapshot set:html={JSON.stringify(snapshot).replaceAll('<', '\\u003c')} />
<script>
import { counts, engines, latestCheck, rowsHtml, sortGames, type Snapshot } from '../scripts/catalog';
import { counts, engines, latestCheck, rowsHtml, shuffleGames, type Game, type Snapshot } from '../scripts/catalog';
import { patchHref } from '../scripts/patchlinks';
import { refreshCatalog } from '../scripts/catalog-live';
@@ -139,6 +139,11 @@ const latest = latestCheck(games);
apply();
});
search.addEventListener('input', apply);
// a random order on every visit (working games still first); the built page lists them alphabetically
const rank = new Map<string, number>();
const built = Object.values((JSON.parse(document.querySelector('[data-snapshot]')?.textContent || '{"files":{}}') as
Snapshot).files).map((f) => f.game).filter((g): g is Game => !!g);
if (built.length) list.innerHTML = rowsHtml(shuffleGames(built, rank), patchHref);
apply();
// bring the board up to date with the catalog on GitHub
@@ -152,7 +157,7 @@ const latest = latestCheck(games);
src.title = `The catalog on GitHub's main branch, as of ${when}`;
src.classList.add('live');
if (!live.changed) return;
const games = sortGames(live.games);
const games = shuffleGames(live.games, rank);
list.innerHTML = rowsHtml(games, patchHref);
const n = counts(games);
for (const s of ['works', 'issues', 'unsupported'] as const) set(`[data-n="${s}"]`, String(n[s]));
+111 -12
View File
@@ -1,14 +1,23 @@
{
"updated": "2026-10-11",
"updated": "2026-10-12",
"people": [
{
"login": "spoopyghosty0",
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
"url": "https://github.com/spoopyghosty0",
"commits": 523,
"issues": 4,
"commits": 542,
"issues": 5,
"prs": 3,
"total": 550
"total": 572
},
{
"login": "Klownicle",
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
"url": "https://github.com/Klownicle",
"commits": 0,
"issues": 20,
"prs": 0,
"total": 60
},
{
"login": "StevenPartridge",
@@ -29,13 +38,13 @@
"total": 45
},
{
"login": "Klownicle",
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
"url": "https://github.com/Klownicle",
"login": "Sheebalove",
"avatar": "https://avatars.githubusercontent.com/u/176067652?v=4",
"url": "https://github.com/Sheebalove",
"commits": 0,
"issues": 13,
"issues": 11,
"prs": 0,
"total": 39
"total": 33
},
{
"login": "inseend",
@@ -100,6 +109,15 @@
"prs": 0,
"total": 15
},
{
"login": "Scoota23",
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
"url": "https://github.com/Scoota23",
"commits": 0,
"issues": 5,
"prs": 0,
"total": 15
},
{
"login": "eepykith",
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
@@ -226,6 +244,15 @@
"prs": 0,
"total": 6
},
{
"login": "ganona",
"avatar": "https://avatars.githubusercontent.com/u/112239200?v=4",
"url": "https://github.com/ganona",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "GreySyntax",
"avatar": "https://avatars.githubusercontent.com/u/242626?v=4",
@@ -244,6 +271,15 @@
"prs": 0,
"total": 6
},
{
"login": "Minibattle",
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
"url": "https://github.com/Minibattle",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
@@ -289,6 +325,15 @@
"prs": 0,
"total": 3
},
{
"login": "Aryetis",
"avatar": "https://avatars.githubusercontent.com/u/4246001?v=4",
"url": "https://github.com/Aryetis",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "bcain2005-gif",
"avatar": "https://avatars.githubusercontent.com/u/258048498?v=4",
@@ -316,6 +361,15 @@
"prs": 0,
"total": 3
},
{
"login": "DolphinOfficial",
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
"url": "https://github.com/DolphinOfficial",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "dontstealthisplease-gif",
"avatar": "https://avatars.githubusercontent.com/u/337959488?v=4",
@@ -343,6 +397,15 @@
"prs": 0,
"total": 3
},
{
"login": "felis-lynx",
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
"url": "https://github.com/felis-lynx",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Froghut",
"avatar": "https://avatars.githubusercontent.com/u/1702171?v=4",
@@ -370,6 +433,15 @@
"prs": 0,
"total": 3
},
{
"login": "hokey1981",
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
"url": "https://github.com/hokey1981",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "InsideCat02",
"avatar": "https://avatars.githubusercontent.com/u/111202797?v=4",
@@ -398,9 +470,18 @@
"total": 3
},
{
"login": "Minibattle",
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
"url": "https://github.com/Minibattle",
"login": "Mitchlett",
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
"url": "https://github.com/Mitchlett",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Momofier",
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
"url": "https://github.com/Momofier",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -415,6 +496,15 @@
"prs": 0,
"total": 3
},
{
"login": "Obliviate28",
"avatar": "https://avatars.githubusercontent.com/u/341108069?v=4",
"url": "https://github.com/Obliviate28",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Puchor",
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
@@ -433,6 +523,15 @@
"prs": 0,
"total": 3
},
{
"login": "ssgod101",
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
"url": "https://github.com/ssgod101",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
+25 -1
View File
@@ -1,6 +1,30 @@
{
"updated": "2026-10-11",
"updated": "2026-10-12",
"releases": [
{
"tag": "v1.0.4",
"name": "FramePort v1.0.4",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.4",
"dev": false,
"notes": "FramePort v1.0.4: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Launch tests no longer report \"Failed to launch\"** for games that run fine but crash once the test ends their VR session because no headset is worn (e.g. BlazeRush).\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.3",
"name": "FramePort v1.0.3",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.3",
"dev": false,
"notes": "FramePort v1.0.3: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **One FramePort at a time:** starting it again brings the open window to the front, and FramePort fully closes with its window.\n- **After an update, FramePort restarts with a working window** instead of a black one (Windows).\n- The update dialogs' \"All releases\" and \"Release page\" buttons work again.\n- When several play sessions are checked at once, one message lists them instead of one per game.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.2",
"name": "FramePort v1.0.2",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.2",
"dev": false,
"notes": "FramePort v1.0.2: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Progress is kept:** games that save under your Meta user (for example Walkabout Mini Golf) no longer start with an empty profile each time. Click **Update on Frame** on their page.\n- A new troubleshooting FAQ in the docs: where a game's settings are and what to try when a game misbehaves.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.1",
"name": "FramePort v1.0.1",
+16 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { rowHtml, short, sortGames, toGame } from './catalog.ts';
import { rowHtml, short, shuffleGames, sortGames, toGame } from './catalog.ts';
test('catalog entries become rows like docs/GAMES.md', () => {
assert.equal(toGame({ title: 'x', status: 'maybe' }), null);
@@ -26,6 +26,21 @@ test('order: works, issues, unsupported, then title', () => {
assert.deepEqual(sortGames(games).map((g) => g.title), ['a', 'z', 'a', 'b']);
});
test('shuffle: status groups stay in order, a random order inside, kept across calls', () => {
const games = ['unsupported:b', 'works:z', 'issues:a', 'works:a', 'works:m', 'issues:q'].map((s) => {
const [status, title] = s.split(':');
return toGame({ status, title, package: `p.${status}.${title}` });
});
const seq = [0.9, 0.1, 0.5, 0.3, 0.7, 0.2];
const rank = new Map();
const first = shuffleGames(games, rank, () => seq.shift());
assert.deepEqual(first.map((g) => g.status), ['works', 'works', 'works', 'issues', 'issues', 'unsupported']);
assert.deepEqual(first.filter((g) => g.status === 'works').map((g) => g.title), ['z', 'a', 'm']);
// the same page reorders the same way (the live refresh), new games get their own place
const again = shuffleGames([...games].reverse(), rank, () => 0.0);
assert.deepEqual(again.map((g) => g.title), first.map((g) => g.title));
});
test('rows escape what comes from GitHub', () => {
const html = rowHtml(toGame({ title: '<img src=x onerror=alert(1)>', status: 'works', engine: '"x' }));
assert.ok(!html.includes('<img'));
+8
View File
@@ -69,6 +69,14 @@ export function sortGames(games: Game[]): Game[] {
|| a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }));
}
/** Working games first, then with issues, then not running; a random order inside each group, so every visit leads
* with other games. `rank` keeps each game's place for the page's lifetime (the live refresh doesn't reshuffle). */
export function shuffleGames(games: Game[], rank = new Map<string, number>(), rand: () => number = Math.random): Game[] {
const key = (g: Game) => g.package || g.title;
for (const g of games) if (!rank.has(key(g))) rank.set(key(g), rand());
return [...games].sort((a, b) => ORDER[a.status] - ORDER[b.status] || rank.get(key(a))! - rank.get(key(b))!);
}
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
/** Where a recipe chip links to (its entry in the patch reference), or null for none. */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 229 KiB

+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "1.0.2"
__version__ = "1.0.4"
+139
View File
@@ -0,0 +1,139 @@
"""One FramePort window at a time (no Flet).
Two GUI processes on one data folder fought over library.json and both re-analysed the library and triaged the same
play sessions (the Library flickered under a stream of pop-ups). The GUI holds an exclusive lock on
`<data>/gui.lock` for its lifetime (released by the OS when the process ends, also after a crash). A second start:
- the window is there (fresh `gui.alive` heartbeat): asks it to come to the front (`gui.focus`) and exits;
- the lock is held but no window answers (a process left over after its window went away): ends that process (only
when it is a FramePort GUI) and takes over.
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
from .paths import user_data_dir
_held = None # the open lock file (kept for the process lifetime)
def lock_file() -> Path:
return user_data_dir() / "gui.lock"
def focus_file() -> Path:
return user_data_dir() / "gui.focus"
def _try_lock(f) -> bool:
try:
if sys.platform == "win32":
import msvcrt
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except OSError:
return False
def acquire(path: Path | None = None) -> bool:
"""Take the GUI lock; False when another process holds it."""
global _held
path = path or lock_file()
path.parent.mkdir(parents=True, exist_ok=True)
f = open(path, "a+") # noqa: SIM115 (kept open on purpose)
if not _try_lock(f):
f.close()
return False
f.seek(0)
f.truncate()
f.write(str(os.getpid()))
f.flush()
_held = f
return True
def holder(path: Path | None = None) -> int | None:
try:
return int((path or lock_file()).read_text().strip() or 0) or None
except (OSError, ValueError):
return None
def _is_frameport_gui(pid: int) -> bool:
try:
import psutil
cmd = " ".join(psutil.Process(pid).cmdline()).lower()
except Exception: # noqa: BLE001 (gone, or not ours to inspect)
return False
return "frameport" in cmd
STARTUP_GRACE = 30.0 # a GUI this young may still be opening its window (no heartbeat yet): never end it
def end_leftover(pid: int, wait: float = 5.0) -> bool:
"""End a FramePort GUI process that has no window any more; True when it's gone."""
if pid == os.getpid() or not _is_frameport_gui(pid):
return False
import psutil
try:
p = psutil.Process(pid)
if time.time() - p.create_time() < STARTUP_GRACE:
return False
p.terminate()
try:
p.wait(wait)
except psutil.TimeoutExpired:
p.kill()
p.wait(wait)
except psutil.NoSuchProcess:
pass
except Exception: # noqa: BLE001
return False
return True
def ask_focus() -> None:
try:
focus_file().touch()
except OSError:
pass
def take_focus_request() -> bool:
"""True once after another start asked the window to come to the front."""
try:
focus_file().unlink()
return True
except OSError:
return False
def claim(window_open, retries: int = 20) -> bool:
"""Called at GUI start: True = this process is the GUI; False = another window is open (it was asked to come to
the front). `window_open()` says whether a window answers (urlhandler.app_running)."""
if acquire():
return True
if window_open():
ask_focus()
return False
pid = holder()
if pid:
end_leftover(pid)
for _ in range(retries): # the OS releases the lock when the process is gone
if acquire():
return True
time.sleep(0.25)
if window_open():
ask_focus()
return False
+2 -2
View File
@@ -10,7 +10,7 @@ from dataclasses import asdict
from pathlib import Path
from .models import Analysis, Recipe
from .paths import user_data_dir, write_atomic
from .paths import localize, user_data_dir, write_atomic
# One lock for every read-modify-write of library.json: jobs, the poll thread and the UI all change it, and a change
# made between another thread's load() and save() would be lost. Re-entrant so edit() can call load()/save().
@@ -28,7 +28,7 @@ _updating = threading.local() # set while load() runs the migrations/catalog st
def load() -> dict:
with _lock:
try:
data = json.loads(_path().read_text(encoding="utf-8"))
data = localize(json.loads(_path().read_text(encoding="utf-8")))
except (OSError, ValueError):
return {"games": {}, "settings": {}}
# The catalog step loads the catalog, whose first load reads a setting: that nested load() must not run the
+56
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import re
import sys
import threading
from pathlib import Path
@@ -45,10 +46,34 @@ def _on_frame() -> bool:
return on_frame()
def shares_windows_data() -> bool:
"""WSL with FRAMEPORT_WINDOWS_DATA=1: use the Windows FramePort's data folder (one library for a Windows copy and
WSL source runs; paths the other side saved are translated by `localize`)."""
if not os.environ.get("FRAMEPORT_WINDOWS_DATA") or os.environ.get("FRAMEPORT_HOME"):
return False
from .winhost import is_wsl
return is_wsl()
_win_data: list = [] # cached: finding %LOCALAPPDATA% from WSL starts cmd.exe
def _windows_data_dir() -> Path | None:
if not _win_data:
from .winhost import env_path
local = env_path("LOCALAPPDATA")
_win_data.append(local / "FramePort" if local else None)
return _win_data[0]
def user_data_dir() -> Path:
"""Tools, keystores, work dirs and settings. Override with FRAMEPORT_HOME."""
if os.environ.get("FRAMEPORT_HOME"):
base = Path(os.environ["FRAMEPORT_HOME"])
elif shares_windows_data() and _windows_data_dir() is not None:
base = _windows_data_dir()
elif sys.platform == "win32":
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "FramePort"
elif sys.platform == "darwin":
@@ -63,6 +88,37 @@ def user_data_dir() -> Path:
return base
_WSL_DRIVE = re.compile(r"^/mnt/([a-zA-Z])(?:/(.*))?$")
_WIN_DRIVE = re.compile(r"^([a-zA-Z]):[\\/](.*)$")
def local_path(p: str) -> str:
"""A path saved by FramePort on the other side of WSL in this side's spelling: /mnt/d/X -> D:\\X on Windows, and
D:\\X -> /mnt/d/X in WSL runs on the Windows copy's data (`shares_windows_data`); anything else unchanged.
Plain string mapping (drive letters only), so it is cheap enough for whole files."""
if sys.platform == "win32":
m = _WSL_DRIVE.match(p)
return f"{m[1].upper()}:\\" + (m[2] or "").replace("/", "\\") if m else p
if shares_windows_data():
m = _WIN_DRIVE.match(p)
if m:
return f"/mnt/{m[1].lower()}" + ("/" + m[2].replace("\\", "/") if m[2] else "")
return p
def localize(obj):
"""`local_path` applied to every string in loaded JSON (library.json, deployment records, tools.json): a data
folder shared by a Windows copy and WSL source runs holds paths from both."""
if isinstance(obj, str):
return local_path(obj) if obj[:1] in "/" or obj[1:3] in (":\\", ":/") else obj
if isinstance(obj, dict):
for k, v in obj.items():
obj[k] = localize(v)
elif isinstance(obj, list):
obj[:] = [localize(v) for v in obj]
return obj
_removed = threading.Event()
+3 -2
View File
@@ -178,8 +178,9 @@ def _problem_fields(g: dict | None, description: str, env: dict, bundle_name: st
a = g.get("analysis") or {}
game = f"{title} — {g['package']} (version {a.get('version') or '?'}, {a.get('engine') or '?'} / " \
f"{a.get('xr') or '?'})"
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box." \
if bundle_name else ""
# some browsers don't let the prefilled box take a file (GitHub #196): the comment route always works
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box. If it won't " \
"take the file, submit the report and attach the zip in a comment." if bundle_name else ""
return {
# a game's report is about the game on the Frame, not a FramePort fault (titles read as a list of app bugs)
"title": red.text(f"[Game report] {title}" if g else "[App] FramePort"),
+7 -1
View File
@@ -179,13 +179,19 @@ class History:
self.series.clear()
def main_game(sample: dict | None) -> dict | None:
"""The game in front: the agent lists games newest first with SteamVR overlay apps (they run next to games, e.g.
a desktop overlay) last; an overlay alone isn't a game being played. Older agents: the first non-overlay."""
return next((g for g in (sample or {}).get("games") or [] if not g.get("overlay")), None)
def series_of(sample: dict) -> dict[str, float | None]:
"""The values one sample adds to the History."""
mem = sample.get("mem") or {}
total = mem.get("total") or 0
temps = sample.get("temps") or {}
bat = sample.get("battery") or {}
game = (sample.get("games") or [None])[0] or {}
game = main_game(sample) or {}
extra = {f"temp:{g}": v for g, v in temps.items()}
if sample.get("fan") is not None:
extra["fan"] = sample["fan"]
+6
View File
@@ -538,6 +538,10 @@
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
"FramePort keeps its own copies; nothing is installed system-wide": "",
"FramePort live view": "",
"FramePort looked at your last session of {n} game and has something to try.": [
"",
""
],
"FramePort looked at your last session of {title} and has something to try.": "",
"FramePort picked {value} to start this game, but isn't sure. Check it before installing.": "",
"FramePort saves a diagnostics zip (no game files or personal data) and opens a GitHub issue. Drag the zip into it and submit.": "",
@@ -990,6 +994,7 @@
"Open in browser": "",
"Open the Monitor": "",
"Open {scheme}:// links with FramePort?": "",
"Open {title}": "",
"OpenGL ES games run through a translation to Vulkan that is stricter than Quest drivers. Vulkan games run directly.": "",
"OpenGL ES shader dump (diagnostics)": "",
"OpenGL ES shader fixes": "",
@@ -1386,6 +1391,7 @@
"Steam Frame": "",
"Steam Frame patches": "",
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
"Steam game": "",
"Steam on the Frame downloads Proton (about 1 GiB). Steam restarts once, which closes a running game.": "",
"Steam on the Frame isn't responding. Restart the Frame and try again.": "",
"SteamOS · Developer Mode": "",
+5 -5
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from ..core import winhost
from ..core.events import Reporter
from ..core.models import Recipe
from ..core.paths import agent_file, user_data_dir
from ..core.paths import agent_file, localize, user_data_dir
from ..patches.pcvr import game_args
from ..validate.triage import triage
from .base import PC_LABEL, Target
@@ -77,7 +77,7 @@ def local_installs() -> dict[str, dict]:
out = {}
for f in sorted((user_data_dir() / "pc").glob("*/deployment.json")):
try:
dep = json.loads(f.read_text(encoding="utf-8"))
dep = localize(json.loads(f.read_text(encoding="utf-8")))
out[dep["package"]] = dep
except (OSError, ValueError, KeyError):
continue
@@ -113,7 +113,7 @@ class PcReviveTarget(Target):
out = []
for f in sorted(pc_dir().glob("*/deployment.json")):
try:
dep = json.loads(f.read_text(encoding="utf-8"))
dep = localize(json.loads(f.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
dep["apk_present"] = Path(dep.get("exe_local", "")).exists()
@@ -122,7 +122,7 @@ class PcReviveTarget(Target):
def _dep(self, package: str) -> dict:
try:
return json.loads((pc_dir() / package / "deployment.json").read_text(encoding="utf-8"))
return localize(json.loads((pc_dir() / package / "deployment.json").read_text(encoding="utf-8")))
except (OSError, ValueError):
raise RuntimeError(f"{package} is not installed on this PC") from None
@@ -195,7 +195,7 @@ class PcReviveTarget(Target):
exe_field, _, _ = shortcut_fields(dep)
d = pc_dir() / package
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
old = json.loads((d / "deployment.json").read_text(encoding="utf-8"))
old = localize(json.loads((d / "deployment.json").read_text(encoding="utf-8")))
except (OSError, ValueError):
old = {}
same_exe = bool(old.get("appid") and old.get("exe_win") and shortcut_fields(old)[0] == exe_field)
+6 -2
View File
@@ -28,6 +28,10 @@ def asset_suffix() -> str:
return "Linux-ARM64" if arm else "Linux"
def _exe_name() -> str:
return "Cpp2IL.exe" if sys.platform == "win32" else "Cpp2IL"
def pick_release(releases: list[dict], suffix: str) -> tuple[str, str] | None:
"""(version, download url) of the newest release that has an executable for this computer."""
for rel in releases:
@@ -40,7 +44,7 @@ def pick_release(releases: list[dict], suffix: str) -> tuple[str, str] | None:
def path() -> Path | None:
if os.environ.get("FRAMEPORT_CPP2IL"):
return Path(os.environ["FRAMEPORT_CPP2IL"])
found = sorted(tools_dir().glob("cpp2il-*/Cpp2IL*"))
found = sorted(tools_dir().glob(f"cpp2il-*/{_exe_name()}")) # the data folder may hold other OSes' copies too
return found[-1] if found else None
@@ -59,7 +63,7 @@ def ensure(progress=None) -> Path:
if picked is None:
raise RuntimeError("couldn't find a Cpp2IL download for this computer")
version, url = picked
exe = tools_dir() / f"cpp2il-{version}" / ("Cpp2IL.exe" if sys.platform == "win32" else "Cpp2IL")
exe = tools_dir() / f"cpp2il-{version}" / _exe_name()
cache.download(url, exe, progress)
exe.chmod(exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return exe
+19 -7
View File
@@ -19,7 +19,7 @@ from pathlib import Path
from xml.etree import ElementTree
from ..core import cache
from ..core.paths import tools_dir, write_atomic
from ..core.paths import localize, tools_dir, write_atomic
ADOPTIUM = "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture={arch}&image_type=jre&os={os}"
# overport's CLI: maintained in the downstream fork Android-XR-Bridge/OVRPort since 1.2.5 (stable channel = plain
@@ -54,7 +54,7 @@ def _state_file() -> Path:
def _state() -> dict:
try:
return json.loads(_state_file().read_text())
return localize(json.loads(_state_file().read_text()))
except (OSError, ValueError):
return {}
@@ -64,37 +64,49 @@ def _save_state(state: dict) -> None:
# ------------------------------------------------------------------------------------------ Java
def _java_key() -> str:
"""The Java entry of tools.json: one per OS, since a data folder can be shared by Windows and WSL."""
return "java-{}-{}".format(*_os_arch())
def java_path() -> Path | None:
if os.environ.get("FRAMEPORT_JAVA"):
return Path(os.environ["FRAMEPORT_JAVA"])
st = _state().get("java")
state = _state()
st = state.get(_java_key())
if not st and state.get("java"): # before 1.0.4: one entry; it's this OS's if it has this OS's java name
st = state["java"] if Path(state["java"]["path"]).name == _java_exe() else None
if st and Path(st["path"]).exists():
return Path(st["path"])
return None
def _java_exe() -> str:
return "java.exe" if sys.platform == "win32" else "java"
def install_java(progress=None) -> ToolStatus:
osname, arch = _os_arch()
info = cache.http_get(ADOPTIUM.format(os=osname, arch=arch)).json()[0]
pkg = info["binary"]["package"]
version = info["version"]["semver"]
dest = tools_dir() / f"jre-{version}"
dest = tools_dir() / f"jre-{version}-{osname}-{arch}"
if not dest.exists():
archive = cache.download(pkg["link"], tools_dir() / pkg["name"], progress, expected_sha256=pkg["checksum"])
tmp = tools_dir() / f".jre-{version}.extract"
tmp = tools_dir() / f".jre-{version}-{osname}-{arch}.extract"
shutil.rmtree(tmp, ignore_errors=True)
_extract(archive, tmp)
top = next(tmp.iterdir())
top.rename(dest)
shutil.rmtree(tmp, ignore_errors=True)
archive.unlink(missing_ok=True)
exe = "java.exe" if sys.platform == "win32" else "java"
exe = _java_exe()
candidates = [dest / "bin" / exe, dest / "Contents/Home/bin" / exe]
java = next(p for p in candidates if p.exists())
if sys.platform != "win32":
java.chmod(0o755)
state = _state()
state["java"] = {"version": version, "path": str(java)}
state[_java_key()] = {"version": version, "path": str(java)}
_save_state(state)
return ToolStatus("java", True, version, java)
+71 -23
View File
@@ -9,6 +9,7 @@ The UI only calls `frameport.pipeline`, the targets and the toolchain; long work
from __future__ import annotations
import os
import sys
import threading
import time
import traceback
@@ -20,6 +21,7 @@ import flet as ft
from .. import pipeline
from ..core import applog, library
from ..errors import explain
from ..frame import monitor as M
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
from ..i18n import fmt_size, tr, tr_n
from ..recommend import catalog
@@ -53,12 +55,16 @@ def _stop_link_watch() -> None:
def _watch_links() -> None:
"""Heartbeat for the link handler scripts (FramePort runs) + hand queued install links to the window."""
"""Heartbeat for the link handler scripts (FramePort runs) + hand queued install links to the window; a second
start of FramePort brings this window to the front (core/instance.py)."""
from .. import urlhandler
from ..core import instance
while not _link_state["closing"]:
urlhandler.heartbeat()
app = _link_state["owner"]
if app is not None and instance.take_focus_request(): # FramePort was started again: show this window
app.page.run_task(app.bring_to_front)
if app is not None:
for link in urlhandler.take_links():
applog.log.info("install link received")
@@ -156,6 +162,8 @@ class FramePortApp:
for key, value in window_geometry(library.setting("ui.window"), T.SCALE).items():
setattr(page.window, key, value)
page.window.on_event = self._on_window_event
if sys.platform == "win32" and (icon := G.window_icon(Path(assets_dir()))):
page.window.icon = str(icon) # source runs: FramePort's icon instead of Flet's
page.on_keyboard_event = self._on_key
if page.web: # the library's right-click menu; otherwise the browser shows its own
try:
@@ -183,6 +191,13 @@ class FramePortApp:
atexit.register(_stop_link_watch)
self.run_bg(urlhandler.apply_setting) # framedrop:// + frameport:// → FramePort (Settings → General)
async def bring_to_front(self) -> None:
win = self.page.window
if win.minimized:
win.minimized = False
C.update(self.page)
await win.to_front()
# ================================================================== shell
def _build_shell(self) -> None:
"""The window: sidebar (logo, navigation, update/activity/Frame cards), the view area and the activity panel.
@@ -502,7 +517,7 @@ class FramePortApp:
def _on_card_sample(self, s: dict) -> None:
"""A sample for the card (the stream's thread): remember it, keep the fps history, redraw at most once a
second (with the Monitor open the stream runs faster than the card's 5 s)."""
game = (s.get("games") or [None])[0]
game = M.main_game(s)
pkg = game.get("package") if game else None
if pkg != self._card_pkg:
self._card_pkg = pkg
@@ -516,7 +531,7 @@ class FramePortApp:
self._card_art[pkg] = thumbs.url(pkg, CARD_ART, width=160, wait=False) if library.game(pkg) else None
was = self._card_sample
self._card_sample, self._card_state = s, "live"
shown_game = bool(was and was.get("games"))
shown_game = M.main_game(was) is not None
now = time.monotonic()
if now - self._card_shown < 1.0 and shown_game == bool(game):
return
@@ -935,7 +950,11 @@ class FramePortApp:
from .views.library import display_title, twins
g = library.game(pkg)
return display_title(g, twins(library.games())) if g else pkg
if g:
return display_title(g, twins(library.games()))
# a game on the Frame that isn't in this library (Frame page → Installed): the name it has there
on_frame = next((d for d in (self.frame_info or {}).get("installed", []) if d.get("package") == pkg), None)
return (on_frame or {}).get("title") or pkg
def install_options(self, g: dict) -> list[tuple]:
"""[(label, icon, on_click, disabled, tooltip)] — the first is the primary action."""
@@ -1660,9 +1679,9 @@ class FramePortApp:
delete_local, extra = self._delete_local_option(pkg)
delete_data = None
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
delete_data = ft.Checkbox(label=tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = delete_data if extra is None else ft.Column([delete_data, extra], spacing=T.px(8), tight=True)
delete_data, row = C.checkbox(tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = row if extra is None else ft.Column([row, extra], spacing=T.px(8), tight=True)
def run(job: Job):
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
@@ -1685,12 +1704,12 @@ class FramePortApp:
return None, None
size = sum((sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) if p.is_dir() else p.stat().st_size)
for p in files)
box = ft.Checkbox(label=tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
box, row = C.checkbox(tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
from ..core import winhost
shown = [winhost.to_windows(p) if winhost.is_wsl() else str(p) for p in files]
return box, ft.Column([box, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
return box, ft.Column([row, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
def remove_from_library(self, pkg: str) -> None:
title = self._title(pkg)
@@ -1893,7 +1912,7 @@ class FramePortApp:
else "works"))
notes = C.field(label=tr("Notes (what you checked, known issues)"), multiline=True, min_lines=2,
max_lines=6, width=T.DIALOG_M)
played = ft.Checkbox(label=tr("I played it in the headset with this recipe"), value=False)
played, played_row = C.checkbox(tr("I played it in the headset with this recipe"), value=False)
send = C.primary(tr("Open GitHub issue"), ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=None)
def sync(e=None):
@@ -1921,7 +1940,7 @@ class FramePortApp:
get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]),
T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played,
ft.Row([status, C.help_icon("share_config")]), notes, played_row,
C.body(tr("Launch tests can't check the picture or controls — only you can."),
T.TEXT_3),
], tight=True, spacing=T.S3),
@@ -2356,6 +2375,8 @@ class FramePortApp:
self._sessions_busy, target = True, self.target
def work():
found = [] # games whose session has something to try: one pop-up for all of them (a burst at the
# first connection used to show one per game, and the Library flickered under them)
try:
for pkg, lp, fetch in due:
if not fetch:
@@ -2368,22 +2389,36 @@ class FramePortApp:
if isinstance(exc, AgentFailed): # the agent answered: asking again won't help
pipeline.mark_session_checked(pkg, lp["end"])
continue
self._session_triaged(pkg, summary)
if self._session_triaged(pkg, summary):
found.append(pkg)
finally:
self._sessions_busy = False
self._announce_sessions(found)
threading.Thread(target=work, daemon=True).start()
def _session_triaged(self, pkg: str, summary: dict) -> None:
def _session_triaged(self, pkg: str, summary: dict) -> bool:
"""True when the session has something to try (shown on the game's page; _announce_sessions tells the user)."""
from ..validate.session import shown_findings
if not summary or not shown_findings(summary):
return
return False
if self.route[:2] == ("game", pkg):
self.refresh_view()
else:
return True
def _announce_sessions(self, packages: list[str]) -> None:
"""One pop-up for the play sessions that have something to try (not for the game page already shown)."""
packages = [p for p in packages if self.route[:2] != ("game", p)]
if len(packages) == 1:
pkg = packages[0]
self.toast(tr("FramePort looked at your last session of {title} and has something to try.")
.format(title=self._title(pkg)), action=tr("Open game"),
on_action=lambda e: self.open_game(pkg))
elif packages:
self.toast(tr_n("FramePort looked at your last session of {n} game and has something to try.",
"FramePort looked at the last sessions of {n} games and has something to try on their "
"pages.", len(packages)), action=tr("Open {title}").format(title=self._title(packages[0])),
on_action=lambda e: self.open_game(packages[0]))
def apply_session_fix(self, pkg: str, suggestions: list[str], finding: str | None = None) -> None:
"""A fix from the game page's "Last session": FrameBridge settings are written on the Frame at once (no
@@ -2724,12 +2759,11 @@ class FramePortApp:
shown_dir = winhost.to_windows(backup_dir) if winhost.is_wsl() else str(backup_dir)
keys_label = tr("Back up the signing keys ({len}) to {shown_dir} first").format(len=len(pl.keys),
shown_dir=shown_dir)
cb_keys = ft.Checkbox(label=keys_label,
value=bool(pl.keys), active_color=T.ACCENT)
cb_frame = ft.Checkbox(label=tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
disabled=not connected, active_color=T.ACCENT)
cb_saves = ft.Checkbox(label=tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
cb_keys, keys_row = C.checkbox(keys_label, value=bool(pl.keys), active_color=T.ACCENT)
cb_frame, frame_row = C.checkbox(tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
disabled=not connected, active_color=T.ACCENT)
cb_saves, saves_row = C.checkbox(tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
frame_items = [i for i in pl.items if i.kind == "frame"]
steam_items = [i for i in pl.items if i.kind == "steam"]
other = [i for i in pl.items if i.kind == "pc"]
@@ -2765,7 +2799,7 @@ class FramePortApp:
self.page.show_dialog(C.dialog(
tr("Uninstall FramePort?"),
ft.Column([C.body(tr("This removes:"), T.TEXT), *lines, ft.Container(height=T.S2),
cb_keys, cb_frame, cb_saves,
keys_row, frame_row, saves_row,
C.meta(tr("Keep the signing keys: without them, updating a game "
"loses its saves."))],
spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO),
@@ -2875,6 +2909,12 @@ def main(argv=None):
if links and urlhandler.app_running():
return # the open window takes it
applog.setup("gui")
from ..core import instance
if not instance.claim(urlhandler.app_running):
applog.log.info("FramePort is already running: brought its window to the front")
print("FramePort is already running.", file=sys.stderr)
return
serialize_flet_updates()
from ..core import library
from .updater import apply_pending_at_start
@@ -2889,6 +2929,14 @@ def main(argv=None):
if updates.install_kind() == "bundle": # a packaged app finds artwork by file path, not by URL (GitHub #16)
thumbs.use_file_paths(True)
ft.run(lambda page: FramePortApp(page), assets_dir=assets_dir())
# the window is gone: end the process now. Background threads (re-analysis, session triage) would otherwise keep
# it running without a window, holding the GUI lock; library writes are atomic and installs resume.
_stop_link_watch()
import logging
import os
logging.shutdown()
os._exit(0)
if __name__ == "__main__":
+17
View File
@@ -213,6 +213,23 @@ def h2(text: str) -> ft.Text:
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
def checkbox(label: str, **kw) -> tuple[ft.Checkbox, ft.Control]:
"""(the checkbox, the control to show): a checkbox whose label wraps (Material's own label stays on one line and
runs past a dialog's edge); clicking the label toggles it too."""
box = ft.Checkbox(**kw)
def click_label(e):
if box.disabled:
return
box.value = not box.value
update(box)
if box.on_change:
box.on_change(SimpleNamespace(control=box))
return box, ft.Row([box, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True,
on_click=click_label, ink=True, border_radius=T.RADIUS_XS)],
spacing=T.S1, vertical_alignment=ft.CrossAxisAlignment.CENTER)
def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
"""A labelled switch in the theme's text colour (Material's default label is dark text). The label wraps (a
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
+2 -3
View File
@@ -24,10 +24,9 @@ def now_playing(sample: dict | None, fps_series: list | None = None) -> dict | N
"""Display values for the card's "now playing" row, or None when no game runs: package, title, line ("playing ·
27 min"), fps text ("72" / "–"), level (ok | warn | error by the fps against its presumed refresh rate) and the
fps target (refresh rate) for the sparkline's guide."""
games = (sample or {}).get("games") or []
if not games:
g = M.main_game(sample)
if g is None:
return None
g = games[0]
pkg = g.get("package") or ""
elapsed = g.get("elapsed")
line = tr("playing · {t}").format(t=M.fmt_duration(elapsed)) if elapsed is not None else tr("playing")
+21
View File
@@ -45,6 +45,27 @@ def install(assets: Path) -> Path:
return target
APP_ICON = Path(__file__).resolve().parents[2] / "assets" / "icon.png" # src/assets: the bundles' app icon
def window_icon(assets: Path, source: Path = APP_ICON) -> Path | None:
"""<assets>/ui-icons/app.ico made from the app icon, for the window and taskbar of source runs on Windows (they
run Flet's prebuilt client, which shows Flet's logo; `flet build` bundles have the icon built in). None when the
icon isn't there (bundles) or can't be converted."""
dest = assets / FOLDER / "app.ico"
try:
if dest.is_file() and dest.stat().st_mtime >= source.stat().st_mtime:
return dest
from PIL import Image
dest.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as im:
im.convert("RGBA").save(dest, sizes=[(n, n) for n in (16, 20, 24, 32, 40, 48, 64, 128, 256)])
return dest
except Exception: # noqa: BLE001 (Flet's icon then; never worth failing the start)
return None
PORTAL_REV = 2
+19 -6
View File
@@ -143,7 +143,7 @@ class Updater:
tr("FramePort {version} is available").format(version=up.version),
_changes_column(page, changes, intro), size="l", height=_changes_height(changes, 100),
actions=[C.ghost(tr("Skip this version"), on_click=skip),
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: _open_url(page, up.page)),
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
def show_news(self) -> None:
@@ -161,7 +161,7 @@ class Updater:
page = self.app.page
if not entries:
self.app.toast(tr("Couldn't load the changelog — check the internet connection"), error=True,
action=tr("Releases"), on_action=lambda e: page.launch_url(f"{REPO_URL}/releases"))
action=tr("Releases"), on_action=lambda e: _open_url(page, f"{REPO_URL}/releases"))
return
self.app.page.run_thread(lambda: page.show_dialog(_changes_dialog(page, tr("FramePort changelog"),
entries, current=__version__)))
@@ -199,7 +199,7 @@ class Updater:
tr("Install dev build {version}?").format(version=up.version),
_changes_column(page, changes, warn), size="l", height=_changes_height(changes, 160),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: _open_url(page, up.page)),
C.primary(tr("Install dev build"), G.TEST, go)]))
# ---------------------------------------------------------------- installing
@@ -214,7 +214,7 @@ class Updater:
if not target or not updates.can_replace(target):
self.app.toast(tr("FramePort can't update itself in {value} (no permission) — opening the release "
"page to download it").format(value=target or 'this folder'), error=True)
page.launch_url(up.page)
_open_url(page, up.page)
return
if kind == "source" and updates.source_is_dirty():
self.app.toast(tr("This source checkout has uncommitted changes — commit or stash them, then update"),
@@ -301,7 +301,7 @@ def _entry_section(page: ft.Page, entry: updates.ChangelogEntry, expanded: bool,
notes = ft.Container(
ft.Markdown(entry.body.strip() or tr("No release notes."), selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, md_style_sheet=_notes_style(),
on_tap_link=lambda e: page.launch_url(e.data)),
on_tap_link=lambda e: _open_url(page, e.data)),
padding=ft.Padding(T.S4, 0, T.S4, T.S3), visible=expanded)
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED, size=T.px(18),
color=T.TEXT_3)
@@ -338,12 +338,25 @@ def _changes_height(entries: list[updates.ChangelogEntry], extra: int) -> float:
return T.px(min(480, extra + 26 * lines + 50 * len(entries)))
def _open_url(page: ft.Page, url: str) -> None:
"""Open a link in the browser. Flet 1.0 has no Page.launch_url (GitHub #194: "All releases" raised
AttributeError); same path as FramePortApp.open_url."""
from ..core import winhost
if winhost.is_wsl() and winhost.open_url(url): # the desktop client would open a Linux browser in WSL
return
try:
page.run_task(ft.UrlLauncher().launch_url, url)
except Exception: # noqa: BLE001
winhost.open_url(url)
def _changes_dialog(page: ft.Page, title: str, entries: list[updates.ChangelogEntry],
current: str | None = None) -> ft.AlertDialog:
return C.dialog(title, _changes_column(page, entries, None, current), size="l",
height=_changes_height(entries, 20),
actions=[C.ghost(tr("All releases"), ft.Icons.OPEN_IN_NEW_ROUNDED,
lambda e: page.launch_url(f"{REPO_URL}/releases")),
lambda e: _open_url(page, f"{REPO_URL}/releases")),
C.primary(tr("Close"), on_click=lambda e: page.pop_dialog())])
+3 -2
View File
@@ -518,7 +518,7 @@ class MonitorView:
def _apply_game(self, s: dict) -> list:
games = s.get("games") or []
self.last_games = games
game = games[0] if games else None
game = M.main_game(s)
was = self.game_card.visible
self.game_card.visible = game is not None
self.no_game.visible = game is None and self.filter.data == "game"
@@ -534,7 +534,8 @@ class MonitorView:
# once per game (not per tick): its artwork, by asset URL
self.game_art.content = C.art_fill(meta["art"], radius=T.RADIUS_SM, width=T.px(96), height=T.px(96))
self.page_btn.visible = meta["in_library"]
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app")}.get(game.get("kind"), tr("Quest game"))
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app"),
"steam": tr("Steam game")}.get(game.get("kind"), tr("Quest game"))
self.game_title.value = game.get("title") or pkg
self.game_sub.value = " · ".join(x for x in (kind, tr("running for {t}").format(
t=M.fmt_duration(game.get("elapsed"))) if game.get("elapsed") is not None else "") if x)
+18 -2
View File
@@ -480,12 +480,27 @@ def _extract(archive: Path, dest: Path, platform: str) -> Path:
return folder
# set by the running app's Flutter host / embedded Python for *this* process (its server socket, its temporary
# extraction folder, …): a relaunched FramePort that inherits them talks to the old, gone app and its window stays
# black (GitHub #195)
APP_ENV_PREFIXES = ("FLET_", "SERIOUS_PYTHON")
APP_ENV_NAMES = ("PYTHONHOME", "PYTHONPATH", "PYTHONNOUSERSITE", "PYTHONINSPECT", "PYTHONDONTWRITEBYTECODE")
def clean_env(env: dict | None = None) -> dict:
"""The environment for the swap script and the FramePort it relaunches: without the running app's own Flet and
embedded-Python variables."""
env = dict(os.environ if env is None else env)
return {k: v for k, v in env.items()
if not k.upper().startswith(APP_ENV_PREFIXES) and k.upper() not in APP_ENV_NAMES}
def _powershell() -> tuple[str, dict]:
"""Windows PowerShell 5.1 (always installed) and an environment it can load its own modules in: started from
PowerShell 7 (pwsh), PSModulePath points at pwsh's modules and 5.1 fails to load Microsoft.PowerShell.Security /
.Management (Get-AuthenticodeSignature, Copy-Item). Without PSModulePath it rebuilds its default."""
exe = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
env = {k: v for k, v in os.environ.items() if k.upper() != "PSMODULEPATH"}
env = {k: v for k, v in clean_env().items() if k.upper() != "PSMODULEPATH"}
return (str(exe) if exe.exists() else "powershell"), env
@@ -683,7 +698,8 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
"Hidden", "-File", str(script)], env=env)
else:
proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=clean_env())
if not wait: # make sure the script really runs before FramePort quits (it logs first thing)
deadline = time.time() + START_TIMEOUT
while time.time() < deadline:
+6 -2
View File
@@ -406,8 +406,12 @@ def unregister(schemes=None) -> dict:
def apply_setting() -> dict | None:
"""At GUI start: register the enabled schemes (never taking one from another program) and remove FramePort's
registration of the disabled ones. Errors are logged, never raised (links are a convenience)."""
# an isolated data dir (FRAMEPORT_HOME: tests, screenshots) never takes over the user's link handler
if os.environ.get("FRAMEPORT_NO_LINK_HANDLER") or os.environ.get("FRAMEPORT_HOME") or not platform_kind():
# an isolated data dir (FRAMEPORT_HOME: tests, screenshots) never takes over the user's link handler, nor does a
# WSL run on the Windows copy's data (it would point the Windows copy's links at WSL)
from .core.paths import shares_windows_data
if (os.environ.get("FRAMEPORT_NO_LINK_HANDLER") or os.environ.get("FRAMEPORT_HOME") or shares_windows_data()
or not platform_kind()):
return None
try:
off = [s for s in SCHEMES if not enabled(s)]
+4 -2
View File
@@ -16,8 +16,10 @@ def launch_test(frame: Frame, package: str, reporter: Reporter, seconds: int = 4
return TriageResult("UNKNOWN", None), ""
log = frame.get_text(res["log"]) if res.get("log_size") else ""
crash = frame.get_text(res["crash_log"]) if res.get("crash_log") else "" # tombstones (backtraces), agent >= 23
result = triage(log, res["state"], package, crash=crash[-256 * 1024:])
reporter.check("Process", res["state"] == "RUNNING", f"{res['state']} after {res['elapsed']}s")
result = triage(log, res["state"], package, crash=crash[-256 * 1024:], headless=True)
reporter.check("Process", res["state"] == "RUNNING" or (None if result.headless_end else False),
f"{res['state']} after {res['elapsed']}s"
+ (f" (after {result.headless_end}: expected without the headset)" if result.headless_end else ""))
for m in result.milestones:
reporter.check(m, True)
if result.fps:
+43 -4
View File
@@ -30,11 +30,12 @@ class TriageResult:
milestones: list[str] = field(default_factory=list)
findings: list[Finding] = field(default_factory=list)
fps: float | None = None
headless_end: str | None = None # launch test: the session ended for lack of a worn headset (label), see triage()
@property
def verdict(self) -> str:
fatal = [f for f in self.findings if f.severity == "fatal"]
if self.state == "RUNNING" and not fatal:
if (self.state == "RUNNING" or self.headless_end) and not fatal:
return "pass"
return "fail" if fatal or self.state in ("EXITED", "NEVER_STARTED") else "unknown"
@@ -114,9 +115,10 @@ def split_suggestion(s: str) -> tuple[str, str | None]:
def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash: str = "",
kernel: str = "") -> TriageResult:
kernel: str = "", headless: bool = False) -> TriageResult:
"""`crash` = the container's crash logcat (tombstones come from crash_dump's pid, so it isn't pid-filtered);
`kernel` = kernel log lines of a play session ("kernel: …", GPU hangs; agent session_log)."""
`kernel` = kernel log lines of a play session ("kernel: …", GPU hangs; agent session_log); `headless` = a launch
test (no headset worn): what happens after a `headless_end` line doesn't count against the game."""
db = database()
kind = "pcvr" if package and package.startswith("rift.") else "quest" # Proton/Revive logs vs Lepton logcat
lines = game_lines(log, package) if kind == "quest" else [ANSI.sub("", ln) for ln in log.splitlines()]
@@ -149,11 +151,14 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
hidden = {h for sig in db["signatures"] if any(f.id == sig["id"] for f in res.findings)
for h in sig.get("supersedes") or []}
res.findings = [f for f in res.findings if f.id not in hidden]
if headless:
_forgive_headless_end(res, lines, db.get("headless_end") or [])
fps = re.findall(r"pacing: ([0-9.]+) fps", text)
if fps:
res.fps = float(fps[-1])
stopped = frames_stopped(lines)
if stopped and state == "RUNNING" and not any(f.severity == "fatal" for f in res.findings):
if (stopped and state == "RUNNING" and not res.headless_end
and not any(f.severity == "fatal" for f in res.findings)):
res.findings.append(Finding(
"frames-stopped", "fatal",
f"The game stopped sending frames {stopped:.0f} s before the test ended although its process kept "
@@ -162,6 +167,40 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
return res
def _forgive_headless_end(res: TriageResult, lines: list[str], markers: list[dict]) -> None:
"""A launch test whose game sent paced frames until a `headless_end` line (the VR session ended because nobody
wears the headset, e.g. the VrApi bridge's tracking deadline): an exit or crash after it is an artifact of the
test (BlazeRush: zink DEVICE LOST + SIGSEGV in the GPU driver thread as the renderer tore down, GitHub #57).
Findings first seen after that line become one info finding; the verdict then passes (TriageResult.verdict)."""
hits = ((m, next((i for i, ln in enumerate(lines) if re.search(m["pattern"], ln)), None)) for m in markers)
marker, at = next(((m, i) for m, i in hits if i is not None), (None, None))
if at is None or not any("FrameBridge: pacing:" in ln for ln in lines[:at]):
return # no such line, or no frames before it: the game never really ran
when = _secs(lines[at])
def after(f: Finding) -> bool:
i = next((i for i, ln in enumerate(lines) if f.evidence and f.evidence in ln.strip()), None)
if i is None:
return False
t = _secs(lines[i])
return t >= when if t is not None and when is not None else i > at
late = [f for f in res.findings if after(f)]
res.findings = [f for f in res.findings if f not in late]
res.headless_end = marker["label"]
what = ", ".join(f.id for f in late) or "the game ended"
res.findings.append(Finding(
"headless-end", "info",
f"The game ran (paced frames) until the VR session ended for lack of a worn headset ({marker['label']}). What "
f"came after ({what}) is a side effect of this headless test and doesn't happen in the headset.", [],
lines[at].strip()[:300]))
def _secs(line: str) -> float | None:
m = LOGCAT_TIME.match(line)
return int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
MISSING_OBB = ("The game keeps its content in a data file (.obb) and none was installed: FramePort found no .obb "
"files next to its APK. Unreal games then stop right after start without an error (GitHub #85). Put "
"the .obb files in a folder named like the package (or obb/) next to the APK, add the folder again "
+1
View File
@@ -9,6 +9,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
# modules that read settings at import time (UI) run during collection, before the per-test fixture below
os.environ["FRAMEPORT_HOME"] = tempfile.mkdtemp(prefix="frameport-tests-")
os.environ.pop("FRAMEPORT_WINDOWS_DATA", None) # a WSL dev shell may share the Windows data folder: never in tests
@pytest.fixture(autouse=True)
+8
View File
@@ -324,3 +324,11 @@ def test_agent_collect_diag(monkeypatch, tmp_path):
assert f["launch.log"].startswith("[... first 4000 bytes cut") and "files" not in json.loads(f["deployment.json"])
assert res["listing"]["roots"]
assert a.cmd_collect_diag({"package": "com.not.installed"})["installed"] is False
def test_updater_never_uses_page_launch_url():
"""GitHub #194: Flet 1.0's Page has no launch_url ("All releases" raised AttributeError)."""
from pathlib import Path
src = Path(__file__).resolve().parents[1] / "src" / "frameport"
assert not [p for p in src.rglob("*.py") if "page.launch_url(" in p.read_text(encoding="utf-8")]
+52
View File
@@ -0,0 +1,52 @@
"""One FramePort window at a time (core/instance.py)."""
import subprocess
import sys
import textwrap
from frameport.core import instance
def _hold_lock(path):
"""A separate process holding the GUI lock (like a running FramePort)."""
code = textwrap.dedent(f"""
import sys, time
sys.path.insert(0, {str(instance.__file__.rsplit('/frameport/', 1)[0])!r})
from pathlib import Path
from frameport.core import instance
assert instance.acquire(Path({str(path)!r}))
print("locked", flush=True)
time.sleep(30)
""")
p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
assert p.stdout.readline().strip() == "locked"
return p
def test_second_start_is_refused_while_a_window_is_open(tmp_path, monkeypatch):
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
holder = _hold_lock(instance.lock_file())
try:
assert instance.holder() == holder.pid
assert not instance.acquire()
assert instance.claim(lambda: True) is False # a window answers: it's asked to come to the front
assert instance.take_focus_request() and not instance.take_focus_request()
# no window answers, but the holder isn't a FramePort GUI (or is still starting): it's left alone
assert instance.claim(lambda: False, retries=1) is False
assert holder.poll() is None
finally:
holder.kill()
holder.wait()
assert instance.acquire() # released by the OS when the holder ends
def test_leftover_frameport_gui_is_ended(tmp_path, monkeypatch):
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path))
holder = _hold_lock(instance.lock_file())
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: pid == holder.pid)
monkeypatch.setattr(instance, "STARTUP_GRACE", 0.0)
try:
assert instance.claim(lambda: False) is True # no window: the leftover process is ended, the lock taken
assert holder.wait(5) is not None
finally:
if holder.poll() is None:
holder.kill()
+39
View File
@@ -108,6 +108,9 @@ def frame_tree(root, home):
proc(root, 302, "lus4xvrplayerov", 301, cpu=50, fds={3: ("/dev/kgsl-3d0", 5000)},
cgroup="/user.slice/libpod-abc.scope/container", cmdline=["cn.vr4p.oculus4xvrplayerov"])
proc(root, 400, "bash", 1, cmdline=["bash", "-c", "sleep 9"])
# the agent's shortcut worker during an uninstall names the game's launch.sh: not the game
proc(root, 500, "python3", 1, cmdline=["python3", "{agent}", "_shortcuts_worker",
json.dumps({"remove": [{"exe": f'"{launcher}"'}]})])
return base
@@ -119,6 +122,9 @@ def fa(monkeypatch, tmp_path):
a = load_agent(monkeypatch, home)
root = tmp_path / "frame"
frame_tree(root, home)
agent_file = next(iter(a.AGENT_FILES))
cmd = root / "proc" / "500" / "cmdline"
cmd.write_text(cmd.read_text().replace("{agent}", agent_file))
monkeypatch.setattr(a, "PROC", str(root / "proc"))
monkeypatch.setattr(a, "SYS", str(root / "sys"))
monkeypatch.setattr(a, "POWER_SUPPLY", str(root / "power_supply"))
@@ -177,6 +183,7 @@ def test_processes_games_and_filters(fa):
assert 3 not in names and 2 not in names # kernel threads
assert {names[pid]["game"] for pid in (200, 300, 301, 302)} == {GAME}
assert names[100]["group"] == "steam" and names[101]["group"] == "steamvr" and names[400]["group"] == "other"
assert names[500]["game"] is None
assert names[302]["name"] == "cn.vr4p.oculus4xvrplayerov" # the full name, not the truncated comm
assert names[101]["critical"] and names[1]["locked"]
# default filter: the game's processes + the busiest others as context
@@ -356,3 +363,35 @@ def test_fast_ticks_scan_processes_every_2_s(fa):
seen.append("procs" in m.sample(wall=2000.0 + clock["t"] - 100.0))
assert seen == [False] * 7 + [True]
assert 0.1 in fa.MON_INTERVALS and fa.MON_DEFAULT_INTERVAL == 0.5
def test_steam_games_and_overlay_apps(fa):
"""A Steam store game (Job Simulator in Lepton, started by Steam's reaper) counts as a game with its Steam name;
an overlay app (Temporal Reality) is flagged and listed after it, and isn't the game in front."""
from frameport.frame.monitor import main_game
from frameport.ui.frame_card import now_playing
root, home = fa.ROOT, fa.ROOT.parent / "home"
w(home / ".local/share/Steam/steamapps/appmanifest_448280.acf",
'"AppState"\n{\n\t"appid"\t\t"448280"\n\t"name"\t\t"Job Simulator"\n\t"installdir"\t\t"Job Simulator"\n}\n')
ov = home / "Applications/quest-frame/linux.overlay"
w(ov / "deployment.json", json.dumps({"package": "linux.overlay", "appid": 1234, "title": "Temporal Reality",
"kind": "linux", "overlay": {"registered": True}}))
proc(root, 600, "bash", 1, start=50, cmdline=["bash", str(ov / "launch.sh")]) # started long before the game
proc(root, 601, "TemporalReality", 600, start=50, cmdline=[str(ov / "app/TemporalReality")])
proc(root, 700, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", "AppId=448280", "--", "lepton"])
proc(root, 701, "lepton", 700, start=150, cmdline=["/bin/bash", "lepton", "waitforexitandrun"])
proc(root, 702, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", f"AppId={fa.STEAMVR_APPID}"])
m, _ = monitor(fa)
s = m.sample(wall=2000.0)
games = {g["package"]: g for g in s["games"]}
steam = games["steam:448280"]
assert steam["title"] == "Job Simulator" and steam["kind"] == "steam" and not steam["overlay"]
assert games["linux.overlay"]["overlay"] and f"steam:{fa.STEAMVR_APPID}" not in games
assert s["games"][-1]["package"] == "linux.overlay" # overlay apps last
assert {p["pid"]: p["game"] for p in m.procs}[701] == "steam:448280"
# the newest game first (the fixture's game started at 100, Job Simulator at 150)
assert main_game(s)["package"] == "steam:448280" and now_playing(s)["title"] == "Job Simulator"
# an overlay alone is nobody's game
alone = {"games": [g for g in s["games"] if g["overlay"]]}
assert main_game(alone) is None and now_playing(alone) is None
+73
View File
@@ -0,0 +1,73 @@
"""A data folder shared by a Windows copy and WSL source runs (FRAMEPORT_WINDOWS_DATA)."""
import json
from frameport.core import paths
from frameport.tools import cpp2il, toolchain
def test_local_path_on_windows(monkeypatch):
monkeypatch.setattr(paths.sys, "platform", "win32")
assert paths.local_path("/mnt/d/Games/X/a.apk") == "D:\\Games\\X\\a.apk"
assert paths.local_path("/mnt/c") == "C:\\"
assert paths.local_path("/home/steamos/Applications/x") == "/home/steamos/Applications/x" # Frame paths stay
assert paths.local_path("D:\\Games") == "D:\\Games"
def test_local_path_on_wsl(monkeypatch):
monkeypatch.setattr(paths.sys, "platform", "linux")
monkeypatch.setattr(paths, "shares_windows_data", lambda: True)
assert paths.local_path("D:\\Games\\X\\a.apk") == "/mnt/d/Games/X/a.apk"
assert paths.local_path("C:/Users/A") == "/mnt/c/Users/A"
assert paths.local_path("/mnt/d/Games") == "/mnt/d/Games"
monkeypatch.setattr(paths, "shares_windows_data", lambda: False)
assert paths.local_path("D:\\Games") == "D:\\Games" # own data folder: left alone
def test_localize_walks_json(monkeypatch):
monkeypatch.setattr(paths.sys, "platform", "win32")
data = {"games": {"a": {"apk": "/mnt/d/x.apk", "build": {"checks": [{"detail": "see /mnt/d/x"}]},
"list": ["/mnt/e/y"], "n": 3, "title": "Cool"}}}
paths.localize(data)
g = data["games"]["a"]
assert g["apk"] == "D:\\x.apk" and g["list"] == ["E:\\y"]
assert g["build"]["checks"][0]["detail"] == "see /mnt/d/x" # only whole-string paths
assert g["n"] == 3 and g["title"] == "Cool"
def test_windows_data_only_when_asked(monkeypatch):
monkeypatch.delenv("FRAMEPORT_WINDOWS_DATA", raising=False)
assert not paths.shares_windows_data()
monkeypatch.setenv("FRAMEPORT_WINDOWS_DATA", "1")
assert not paths.shares_windows_data() # FRAMEPORT_HOME (set by the test fixtures) wins
def test_java_entry_per_os(monkeypatch, tmp_path):
monkeypatch.setattr(toolchain, "tools_dir", lambda: tmp_path)
monkeypatch.delenv("FRAMEPORT_JAVA", raising=False)
monkeypatch.setattr(toolchain.sys, "platform", "linux")
linux_java = tmp_path / "jre-21" / "bin" / "java"
linux_java.parent.mkdir(parents=True)
linux_java.write_text("")
# a legacy single entry written by Windows isn't this OS's java
(tmp_path / "tools.json").write_text(json.dumps({"java": {"version": "21", "path": "C:\\\\x\\\\java.exe"}}))
assert toolchain.java_path() is None
(tmp_path / "tools.json").write_text(json.dumps({"java": {"version": "21", "path": str(linux_java)}}))
assert toolchain.java_path() == linux_java
(tmp_path / "tools.json").write_text(json.dumps({
"java": {"version": "21", "path": "C:\\\\x\\\\java.exe"},
toolchain._java_key(): {"version": "21", "path": str(linux_java)}}))
assert toolchain.java_path() == linux_java
def test_cpp2il_picks_this_os_exe(monkeypatch, tmp_path):
monkeypatch.setattr(cpp2il, "tools_dir", lambda: tmp_path)
monkeypatch.delenv("FRAMEPORT_CPP2IL", raising=False)
d = tmp_path / "cpp2il-2022.1"
d.mkdir()
(d / "Cpp2IL.exe").write_text("")
monkeypatch.setattr(cpp2il.sys, "platform", "linux")
assert cpp2il.path() is None
(d / "Cpp2IL").write_text("")
assert cpp2il.path() == d / "Cpp2IL"
monkeypatch.setattr(cpp2il.sys, "platform", "win32")
assert cpp2il.path() == d / "Cpp2IL.exe"
+31
View File
@@ -330,3 +330,34 @@ LeptonProcessObserver: Target app (com.example.game) did not start.
real = "09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1150:com.example.game/u0a55 for activity\n" \
"09-28 17:39:05.000 1150 1150 I GameActivity: System.exit called, status: 0\n"
assert "force-quit" in [f.id for f in triage(real, "EXITED", package="com.example.game").findings]
HEADLESS_END = """
10-11 16:35:09.900 1000 1000 I ActivityManager: Start proc 1192:ru.targem.blazerush/u0a14 for activity
10-11 16:35:12.282 1192 1222 I FrameBridge: xrCreateInstance result=0
10-11 16:35:18.219 1192 1222 I FrameBridge: pacing: 68.7 fps, displayTime vs predicted: avg 0.00 ms, max 0.00 ms
10-11 16:35:38.316 1192 1222 I FrameBridge: pacing: 72.2 fps, displayTime vs predicted: avg 0.00 ms, max 0.00 ms
10-11 16:35:43.150 1192 1222 I OVRPortVrApi: EnterVrMode: no tracked head pose before deadline
10-11 16:35:52.086 1192 1248 E MESA : zink: DEVICE LOST!
10-11 16:35:52.087 1192 1248 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x1a08
"""
def test_crash_after_the_headless_tracking_deadline_passes_a_launch_test():
"""BlazeRush (GitHub #57): its launch test failed on the GPU crash that follows the VrApi bridge ending the session
for lack of a worn headset; that never happens in the headset."""
r = triage(HEADLESS_END, "EXITED", "ru.targem.blazerush", headless=True)
assert r.verdict == "pass" and r.headless_end
assert [f.id for f in r.findings] == ["headless-end"] and r.findings[0].severity == "info"
assert "zink-device-lost" in r.findings[0].diagnosis
# a play session (not headless) keeps the crash
assert triage(HEADLESS_END, "EXITED", "ru.targem.blazerush").verdict == "fail"
# a crash before the deadline still fails
early = HEADLESS_END.replace("16:35:52.08", "16:35:40.08")
early_lines = early.strip().splitlines()
early = "\n".join(early_lines[:4] + early_lines[5:] + early_lines[4:5])
r = triage(early, "EXITED", "ru.targem.blazerush", headless=True)
assert r.verdict == "fail" and {"zink-device-lost", "native-crash"} <= {f.id for f in r.findings}
# no frames before the deadline: the game never ran
no_frames = "\n".join(ln for ln in HEADLESS_END.splitlines() if "pacing:" not in ln)
assert triage(no_frames, "EXITED", "ru.targem.blazerush", headless=True).verdict == "fail"
+10
View File
@@ -411,3 +411,13 @@ def test_pending_news_shows_once_and_waits_while_offline():
library.set_setting("update.last_seen_version", None)
library.set_setting("update.last_check", 1.0) # updated from an older FramePort
assert [e.version for e in updates.pending_news("0.9.2", entries)] == ["0.9.2"]
def test_relaunch_environment_drops_the_running_apps_own_variables():
"""GitHub #195: the relaunched app inherited the old app's Flet/embedded-Python variables and stayed black."""
from frameport import updates
env = updates.clean_env({"PATH": "/bin", "FLET_SERVER_UDS_PATH": "/tmp/old.sock", "flet_platform": "windows",
"SERIOUS_PYTHON_SITE_PACKAGES": "x", "PYTHONHOME": "C:/Temp/serious_python_temp",
"PYTHONPATH": "y", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"})
assert env == {"PATH": "/bin", "USERPROFILE": "C:/Users/u", "PSModulePath": "z"}