mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 15:01:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -323,8 +323,17 @@ Games tests: `pytest -m games` (FRAMEPORT_GAMES=<downloads dir>); on-device chec
|
||||
native layer test: `FRAMEPORT_NATIVE_TESTS=1 pytest -m native` (compiles with the NDK, ~2 min on NTFS).
|
||||
Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattributes`).
|
||||
- `FRAMEPORT_HOME=<dir>` isolates all app data (tests use it); `FRAMEPORT_JAVA/_OVERPORT_JAR/_APKSIGNER_JAR` override
|
||||
managed tools. Real app data (WSL): `~/.local/share/frameport` (tools, overport workspace + game keystores, library,
|
||||
artwork, frames.json, the app's SSH key which the dev Frame authorizes).
|
||||
managed tools. **The owner's real app data is the Windows copy's: `%LOCALAPPDATA%\FramePort`** (= `/mnt/c/Users/
|
||||
<user>/AppData/Local/FramePort`; tools, overport workspace + game keystores, library, artwork, frames.json, the app's
|
||||
SSH key which the dev Frame authorizes; moved from WSL's `~/.local/share/frameport` on 2026-10-11, which is now only
|
||||
a stale backup). The desktop shortcut "FramePort (dev)" runs the source checkout natively on Windows
|
||||
(`%LOCALAPPDATA%\FramePort-dev\launch-frameport-dev.vbs`: venv `FramePort-dev\venv` via `UV_PROJECT_ENVIRONMENT`,
|
||||
`uv sync --frozen`, log `FramePort\logs\dev-gui.log`). **WSL runs on that data need `FRAMEPORT_WINDOWS_DATA=1`**
|
||||
(set in `.claude/settings.local.json`; `paths.shares_windows_data`): the data folder becomes the Windows one, saved
|
||||
paths from the other side are translated on load (`paths.localize`: /mnt/d/X <-> D:\X in library.json, tools.json,
|
||||
PC deployment records), Java/Cpp2IL keep one copy per OS (tools.json `java-<os>-<arch>`), and the link handler isn't
|
||||
registered (it would point the Windows copy's links at WSL). Don't run a WSL GUI and the Windows GUI at once
|
||||
(the gui.lock flock/msvcrt locks don't see each other; only the gui.alive heartbeat does).
|
||||
- Device checks: `frameport test <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
|
||||
[--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`.
|
||||
- Docs screenshots + videos (docs/SHOWCASE.md): never by hand. `uv run python scripts/showcase/render_docs.py`
|
||||
@@ -352,6 +361,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 +841,8 @@ on the dev Frame) in `files/framebridge-vrapi-fov.txt` and answers with it at th
|
||||
r_3dwidth/height, r_width/height from the runtime eye size (FrameBridge logs `runtime recommended eye size`) × scale,
|
||||
written at finalize, set_settings and by launch.sh before Lepton starts (`_config_sync`). Headless runs at scale 1.7
|
||||
end in `zink: DEVICE LOST` right after the bridge's 30 s no-tracking deadline tears the session down (gpu fault at the
|
||||
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline.
|
||||
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline. Launch tests count an exit/crash after
|
||||
such a line as a pass (triage.yaml `headless_end`, finding `headless-end`; GitHub #57 "Failed to launch" on install).
|
||||
**Session triage (agent v70, 2026-10-09):** launch tests never reach FOCUSED, so real play sessions are triaged
|
||||
too. list_installed gives each game `last_play` {start, end, test} from `<anchor>/plays.log` (launch tests write a
|
||||
`test <unix>` line first → test sessions are only marked); `session_log` returns the newest session's launch.log
|
||||
|
||||
@@ -5,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) |
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+46
-5
@@ -53,12 +53,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")
|
||||
@@ -183,6 +187,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.
|
||||
@@ -2356,6 +2367,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 +2381,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
|
||||
@@ -2875,6 +2902,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 +2922,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__":
|
||||
|
||||
@@ -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())])
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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