mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-12 00:01:18 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6acf9d048e | ||
|
|
cf18b30be9 | ||
|
|
eec0218b1d | ||
|
|
526ba0a684 | ||
|
|
23b6ff1b5e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 +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
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user