Compare commits

..
5 Commits
22 changed files with 341 additions and 46 deletions

No files matched your search

+13 -3
View File
@@ -323,8 +323,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`
@@ -832,7 +841,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
+5 -2
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 = 80
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -6604,6 +6604,7 @@ 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__)}
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")),
@@ -7035,7 +7036,9 @@ 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
+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}
+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) |
+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))))
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.3"
__version__ = "1.0.4"
+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()
+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)
+22 -16
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
@@ -160,6 +161,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:
@@ -946,7 +949,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."""
@@ -1671,9 +1678,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))
@@ -1696,12 +1703,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)
@@ -1904,7 +1911,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):
@@ -1932,7 +1939,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),
@@ -2751,12 +2758,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"]
@@ -2792,7 +2798,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),
+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.
+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
+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)
+7
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
+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"