mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-12 06:02:16 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4465fd799 | ||
|
|
80ef9ca6a7 | ||
|
|
c744f8740b | ||
|
|
859f921361 | ||
|
|
6acf9d048e | ||
|
|
cf18b30be9 | ||
|
|
eec0218b1d | ||
|
|
526ba0a684 | ||
|
|
23b6ff1b5e | ||
|
|
00c6863e54 | ||
|
|
4863035a05 | ||
|
|
a30162ea63 | ||
|
|
24eaa97fde | ||
|
|
767b152423 | ||
|
|
10e7669f70 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -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".
|
||||
|
||||
@@ -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 |
@@ -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))))
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+71
-23
@@ -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__":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())])
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user