mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-14 09:01:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310863900f | ||
|
|
7462317dcb | ||
|
|
32a2694b05 | ||
|
|
421ee8cb5f | ||
|
|
693e75b19a | ||
|
|
bdbe9550ba | ||
|
|
c1653f21b1 | ||
|
|
54fdeaca14 | ||
|
|
1182f8f275 | ||
|
|
90248b6742 | ||
|
|
c8f399da57 |
No files matched your search
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 85
|
||||
AGENT_VERSION = 86
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -902,6 +902,32 @@ def shortcut_appid(exe, title):
|
||||
return zlib.crc32((exe + title).encode("utf-8")) | 0x80000000
|
||||
|
||||
|
||||
class VdfMap(dict):
|
||||
"""Steam's KeyValues names are case-insensitive; keep their spelling when writing back (#263)."""
|
||||
|
||||
def _key(self, key):
|
||||
return next((k for k in self if k.casefold() == key.casefold()), key)
|
||||
|
||||
def __contains__(self, key):
|
||||
return super().__contains__(self._key(key))
|
||||
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(self._key(key))
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(self._key(key), value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(self._key(key), default)
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
return super().setdefault(self._key(key), default)
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
for key, value in dict(*args, **kwargs).items():
|
||||
self[key] = value
|
||||
|
||||
|
||||
def vdf_decode(data):
|
||||
pos = 0
|
||||
|
||||
@@ -914,7 +940,7 @@ def vdf_decode(data):
|
||||
|
||||
def node():
|
||||
nonlocal pos
|
||||
out = {}
|
||||
out = VdfMap()
|
||||
while True:
|
||||
kind = data[pos]
|
||||
pos += 1
|
||||
|
||||
@@ -16,11 +16,12 @@ frame:
|
||||
adapter:
|
||||
sync_guard: 1
|
||||
surface_native: 1
|
||||
fade_quad: 1
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
|
||||
updated: '2026-10-10'
|
||||
min_app: 0.12.1
|
||||
updated: '2026-10-13'
|
||||
min_app: 1.1.0
|
||||
source_hint: Batman- Arkham Shadow
|
||||
@@ -8,6 +8,8 @@ frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
fade_quad: 1
|
||||
verified:
|
||||
date: '2026-10-05'
|
||||
known_good_sha256: 41e0ab90f622afd50accb0b6e2553520d30a6cc82e5ab4cbb2341b48f7316a71
|
||||
@@ -15,3 +17,5 @@ verified:
|
||||
overport_cli: 1.2.5
|
||||
issue: 47
|
||||
source_hint: Marvel's Iron Man VR
|
||||
updated: '2026-10-13'
|
||||
min_app: 1.1.0
|
||||
@@ -367,6 +367,12 @@ signatures:
|
||||
here, the OBB doesn't match this APK version - copy the APK and the OBB from the same install, add the game
|
||||
again and reinstall.
|
||||
suggest: [frame.unreal_skip_obb_check]
|
||||
- id: tbxr-fdm-shutdown-crash
|
||||
pattern: '(?s)\A(?=.*Fatal signal 11)(?=.*fdm_injection_xrDestroySwapchain)(?=.*TBXR_LeaveVR)'
|
||||
severity: error
|
||||
diagnosis: The game crashed while leaving VR, inside Valve's foveation layer when it destroys a swapchain (for example Lambda1VR). This can happen after manually quitting; it is not a startup crash and does not explain distorted geometry seen before quitting.
|
||||
suggest: []
|
||||
supersedes: [native-crash]
|
||||
- id: native-crash
|
||||
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
|
||||
severity: fatal
|
||||
|
||||
@@ -23,6 +23,9 @@ manifest.json versions (app, agent, tools, SteamOS build, Proton)
|
||||
app/app.log the app's log (<data>/logs/app.log, rotated 2 MB × 3)
|
||||
app/jobs/*.log saved GUI job logs (<data>/logs/jobs, last 30): build, install, test, ...
|
||||
app/settings.json library settings, catalog sources
|
||||
app/display.json Flet/Flutter versions and last window state; on native Windows, display layout,
|
||||
resolution, refresh rate, GPU driver metadata and up to 60 recent display/device
|
||||
System event records (last 30 minutes; timestamps, providers and IDs only)
|
||||
frame/info.json agent `info` (SteamOS build, Lepton, Proton, installed games, kernel keys)
|
||||
frame/host.json OpenXR runtime + explicit layers, podman containers, containers.conf, uptime
|
||||
frame/XRService-*.log the Frame's OpenXR runtime (SteamVR XRService) log, newest
|
||||
@@ -57,6 +60,12 @@ after the logs were cut); OpenGL ES (no index): the newest modules (by time writ
|
||||
the kernel's `hangcheck detected gpu lockup` line (this-boot-kernel.txt). All modules stay in the game's storage on
|
||||
the Frame (`Android/data/<pkg>/files/fp_vk_shaders/`, Files tab → the game's storage).
|
||||
|
||||
Display queries run only while collecting logs. The Windows event query has a five-second timeout and runs without
|
||||
a console window; the result distinguishes no matching events from a failed or timed-out query. Window visibility,
|
||||
focus and completed move/resize events are recorded in `app.log`, with UTC timestamps for comparison with Windows
|
||||
events. There is no display polling loop or change to display settings. Empty System event results do not rule out
|
||||
a rendering or monitor-link problem.
|
||||
|
||||
## Redaction
|
||||
`diag/redact.py` runs on every file and on the issue text.
|
||||
- **Exact values:** the data dir → `<data>`, home dirs → `<home>`, user names → `<user>`, PC and Frame host names and
|
||||
|
||||
@@ -172,6 +172,8 @@
|
||||
## Steam integration
|
||||
- Non-Steam shortcut: binary `userdata/<id>/config/shortcuts.vdf`; appid = crc32(exe+title) | 0x80000000;
|
||||
artwork in `config/grid/<appid>p.*` (portrait), `<appid>.*` (landscape), `<appid>_hero.*`, `<appid>_logo.*`.
|
||||
KeyValues names are case-insensitive: Steam may save `Shortcuts` / `AppName` after a devkit registration
|
||||
(GitHub #263). Agent v86 reads and updates keys case-insensitively, keeping Steam's spelling and existing appids.
|
||||
Steam reads the file only at start → stop steam.service, edit, start (FramePort does it once per batch).
|
||||
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
|
||||
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
|
||||
|
||||
@@ -19,6 +19,9 @@ On first start FramePort downloads the tools it uses (Settings → Tools shows t
|
||||
|
||||
## First launch
|
||||
|
||||
Starting FramePort again brings its existing window to the front. If a previous instance was left running after
|
||||
its window closed, FramePort recovers it automatically, including on Windows where the GUI lock cannot be read.
|
||||
|
||||
FramePort isn't signed with a paid certificate, so the first start shows a warning:
|
||||
|
||||
- **Windows:** "Windows protected your PC" → **More info** → **Run anyway**.
|
||||
|
||||
+3
-2
@@ -110,7 +110,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [360° sharpness](#adapter-equirect_res) | `adapter.equirect_res` | Game settings | |
|
||||
| [360° 3D](#adapter-equirect_stereo) | `adapter.equirect_stereo` | Game settings | |
|
||||
| [Per-eye diagnostics (log)](#adapter-eye_debug) | `adapter.eye_debug` | Game settings | |
|
||||
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | |
|
||||
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | 2 |
|
||||
| [Fix upside-down menus](#adapter-flip_emul) | `adapter.flip_emul` | Game settings | |
|
||||
| [Rotate panels 180°](#adapter-flip_quads) | `adapter.flip_quads` | Game settings | |
|
||||
| [Don't pause on short interruptions](#adapter-focus_hold) | `adapter.focus_hold` | Game settings | 4 |
|
||||
@@ -847,7 +847,7 @@ Lets Team Beef ports (for example Lambda1VR) start on the Frame.
|
||||
- **Catalog games (1):** Lambda1VR.
|
||||
- **Source:** [patches/frame/tbxr_vendor.py](../src/frameport/patches/frame/tbxr_vendor.py)
|
||||
|
||||
> Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for "valve" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path.
|
||||
> Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the headset maker and pick their VR setup by maker; for "valve" they stop at start (libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take the Meta path. Keeps GL4ES in charge of its desktop OpenGL draw calls so animated models don't turn into stretched triangles.
|
||||
|
||||
### <a id="frame-unity_gl_shim"></a>GL shim for Unity (no multisampled render-to-texture)
|
||||
|
||||
@@ -1378,6 +1378,7 @@ For games whose fades to black don't show, so you see the level loading (for exa
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Picture → "Draw fades over the picture" (advanced).
|
||||
- **Catalog games (2):** Batman: Arkham Shadow, Marvel's Iron Man VR.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on the Frame. FrameBridge then draws the fade itself: a black quad in front of the eyes whose opacity follows the game's dimming. 2 also removes the game's own dimming (if both show, the fade is doubled).
|
||||
|
||||
+6
-1
@@ -13,6 +13,10 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
## Startup failures (visible in launch.log)
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| A GL4ES Team Beef game draws stretched triangles or spikes across characters and the scene (GitHub #191, Lambda1VR) | The newer GL shim exports GLES draw functions that intercept GL4ES's desktop OpenGL calls, bypassing its client-array and immediate-mode translation | `frame.tbxr_vendor` keeps the shim's five draw symbols private when the APK bundles `libgl4es.so`. GL queries and single-sampled framebuffer fixes remain active; other games retain the draw hooks. A/B eye captures on SteamOS 0.4.5: old shim normal, current shim spikes, current shim with private draw symbols normal at ~72 fps. Worn-headset gameplay still needs confirmation |
|
||||
| A Team Beef game reports a crash after manually quitting: `fdm_injection_xrDestroySwapchain` → `TBXR_LeaveVR` (GitHub #191, Lambda1VR) | Valve's foveation layer dereferences a null pointer during swapchain cleanup | Triage `tbxr-fdm-shutdown-crash` identifies this as a teardown crash, separate from startup and distorted geometry. No verified cleanup fix yet |
|
||||
| Conversion fails with `NoSuchElementException: Sequence is empty` in `PatcherContext.appName` (GitHub #269, Accounting+) | OVRPort assumes an application label exists; some APKs only label their activity | FramePort retries that exact failure once using a temporary APK whose application inherits the activity's label; existing labels and the original APK are kept |
|
||||
| Updating a flat Android app after saving its recipe fails with `no OVRPort patches selected` (GitHub #271, Plezy) | older FramePort loads the saved recipe without restoring its unchanged-install setting or applying the flat-app build rules, then tries an empty VR conversion | update FramePort: saved recipes keep `as_is`, and flat apps still skip OVRPort; existing catalog-derived recipes refresh at the next app update. Until then, enable "Install as is" for an app whose APK needs no fixes |
|
||||
| A launch test suggests a patch (for example triage `sdl-no-clipboard` → `frame.sdl_clipboard`) but the game page doesn't show it / lists it as not applicable; or a game added long ago lacks warnings newer ones get (Android version, web wrapper, missing OBB) (GitHub #104, Dramatic Shape) | the library entry was analyzed by an older FramePort: the analysis field the patch reads (`sdl_java`, `min_sdk`, `web_wrapper`, `expects_obb`, …) is missing, so `applies()` is false | entries whose `analysis.extra.analysis_version` is below `detect.ANALYSIS_VERSION` are analyzed again at the GUI's start (background) and before a build (`pipeline.refresh_analyses`); by hand: game menu → "Analyze again", then Update on Frame. An unreadable APK is marked `analysis_failed` (retried after the next version bump). Adding an analysis field: bump `ANALYSIS_VERSION` |
|
||||
| FDM layer loads but diagnostic logging reports `fdm_injection_CreateInstance: skipped` | Layer loading alone does not enable injection on the tested SteamOS 0.4.5 build; a skipped helper instance alone also does not establish game-wide inactivity | Opt-in `device.foveation` mode `tracked`: loads Valve's layer and sets `FDM_DEBUG=enable`. Batman probe reached foveated render passes with changing per-eye offsets. Validate graphics and matched gameplay GPU time; do not enable globally. Use `debug` only for brief diagnostics, preserving existing flags; never benchmark with debug spam. See FRAME_RUNTIME.md. |
|
||||
| One eye (usually the right) shimmers or jitters in the 3D view while menus and panels look steady; pacing is clean (GitHub #69, e.g. RTCWQuest, Moss) | Valve's eye-tracked foveation layer (`VALVE_fdm_injection`) moves its low-density regions with each eye's gaze, and the right eye's gaze filter restarts often (`CEyePoseUKF R: Large dt … Bootstrapped` in `~/.local/share/Steam/logs/eyetracking.txt`) | Customize → "Eye-tracked foveation (Valve)" (`device.foveation`): **Fixed** (`FDM_DEBUG=disable_offsets`; verify injection remains active on the installed runtime) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
|
||||
@@ -39,7 +43,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| 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) |
|
||||
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
|
||||
| FramePort's games disappear after Steam restarts, especially after a devkit registration (GitHub #263) | Steam saves `Shortcuts` / `AppName` with capital letters; older FramePort reads them case-sensitively and writes a second shortcut map that Steam ignores | agent v86 reads and updates keys case-insensitively, keeping Steam's spelling, appids and tags; update FramePort and add the missing games to the library again |
|
||||
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); see the casing fix above, other causes remain unconfirmed (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
|
||||
| Steam's Exit game leaves the game running (container `lepton-steamlaunch-<appid>` still up) | Steam stopped only its `reaper`; launch.sh and Lepton (setsid) never got a signal (GitHub #36) | agent v44: launch.sh's 2 s loop ends the game when its parent is gone (`upgrade_launchers` adds it to existing launchers); by hand: `podman kill lepton-steamlaunch-<appid>` |
|
||||
| Black screen at start while audio/ExoPlayer runs; log `xrEndFrame: dropped N unusable layer(s)`, a quad layer with swapchain 0x0 (for example I Am Monkey's intro video) | The game plays video into an Android-surface swapchain (XR_KHR_android_surface_swapchain); the Frame's runtime lists the extension but returns FUNCTION_UNSUPPORTED | adapter `surface_emul` (default on): FrameBridge gives the player a SurfaceTexture-backed Surface and copies each frame into an ordinary swapchain (log `surface_emul: …`) |
|
||||
| The game quits right after start: `Something failed to initialize. Quitting!` / `We don't have write permission to …/files/cloud/data` (for example SUPERHOT); or stays at loading with `UnauthorizedAccessException` in `StartupLoading.Start` (GitHub #239, Jurassic World Aftermath) | The game created a cloud-save folder with mode 1700; inside Lepton the app writes through the folder's group. Its startup routine can fail before the permission watchdog repairs the newly created folder | launch.sh `fix_perms` adds owner + group write to every folder in the game's storage (agent 41). Close and start the game again without uninstalling: the folders are now repaired (Aftermath confirmed in the headset). Agent 84 creates `files/cloud/data` before `fix_perms` and startup, including in upgraded launchers, to prevent the first-start race. Triage `startup-save-permission` fails the launch test despite rendering frames |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 217 KiB |
+130
-40
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-12",
|
||||
"updated": "2026-10-13",
|
||||
"people": [
|
||||
{
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 542,
|
||||
"commits": 578,
|
||||
"issues": 5,
|
||||
"prs": 3,
|
||||
"total": 572
|
||||
"total": 608
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
@@ -46,6 +46,24 @@
|
||||
"prs": 0,
|
||||
"total": 33
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
|
||||
"url": "https://github.com/Lucas-Mathieu",
|
||||
"commits": 9,
|
||||
"issues": 0,
|
||||
"prs": 4,
|
||||
"total": 29
|
||||
},
|
||||
{
|
||||
"login": "UnicronNL",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
|
||||
"url": "https://github.com/UnicronNL",
|
||||
"commits": 5,
|
||||
"issues": 6,
|
||||
"prs": 1,
|
||||
"total": 28
|
||||
},
|
||||
{
|
||||
"login": "inseend",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
|
||||
@@ -65,31 +83,22 @@
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "UnicronNL",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
|
||||
"url": "https://github.com/UnicronNL",
|
||||
"commits": 5,
|
||||
"issues": 6,
|
||||
"prs": 0,
|
||||
"total": 23
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
|
||||
"url": "https://github.com/Lucas-Mathieu",
|
||||
"commits": 7,
|
||||
"issues": 0,
|
||||
"login": "nleach7",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1406346?v=4",
|
||||
"url": "https://github.com/nleach7",
|
||||
"commits": 3,
|
||||
"issues": 2,
|
||||
"prs": 3,
|
||||
"total": 22
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "jim2011",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/15848374?v=4",
|
||||
"url": "https://github.com/jim2011",
|
||||
"commits": 0,
|
||||
"issues": 6,
|
||||
"issues": 7,
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
"total": 21
|
||||
},
|
||||
{
|
||||
"login": "Jmckenzie84",
|
||||
@@ -109,6 +118,15 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
"url": "https://github.com/eepykith",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "Scoota23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
|
||||
@@ -119,13 +137,13 @@
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "eepykith",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
|
||||
"url": "https://github.com/eepykith",
|
||||
"login": "ssgod101",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
|
||||
"url": "https://github.com/ssgod101",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "nickwilde77555",
|
||||
@@ -208,6 +226,15 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "greggray24",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/255593097?v=4",
|
||||
"url": "https://github.com/greggray24",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "m4ddh7jm7c-bit",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/338352097?v=4",
|
||||
@@ -262,6 +289,15 @@
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Loafmeister",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
|
||||
"url": "https://github.com/Loafmeister",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "maxfvd",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/337777776?v=4",
|
||||
@@ -316,6 +352,24 @@
|
||||
"prs": 1,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "alicecatpaw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/7423859?v=4",
|
||||
"url": "https://github.com/alicecatpaw",
|
||||
"commits": 0,
|
||||
"issues": 0,
|
||||
"prs": 1,
|
||||
"total": 5
|
||||
},
|
||||
{
|
||||
"login": "Akimya49",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46827872?v=4",
|
||||
"url": "https://github.com/Akimya49",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "AlexanderCowell",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/225416878?v=4",
|
||||
@@ -334,6 +388,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "astromaddie",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/5488456?v=4",
|
||||
"url": "https://github.com/astromaddie",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "bcain2005-gif",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/258048498?v=4",
|
||||
@@ -343,6 +406,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "chaseflow",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/235484785?v=4",
|
||||
"url": "https://github.com/chaseflow",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "coah80",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
|
||||
"url": "https://github.com/coah80",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "darrenltw",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/151712415?v=4",
|
||||
@@ -452,18 +533,18 @@
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "leoloopfiets",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/215600281?v=4",
|
||||
"url": "https://github.com/leoloopfiets",
|
||||
"login": "JMQuehl",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/110048375?v=4",
|
||||
"url": "https://github.com/JMQuehl",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Loafmeister",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
|
||||
"url": "https://github.com/Loafmeister",
|
||||
"login": "leoloopfiets",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/215600281?v=4",
|
||||
"url": "https://github.com/leoloopfiets",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
@@ -523,15 +604,6 @@
|
||||
"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",
|
||||
@@ -550,6 +622,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "tirehabitat",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/97759168?v=4",
|
||||
"url": "https://github.com/tirehabitat",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "TylerJaacks",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1505374?v=4",
|
||||
@@ -576,6 +657,15 @@
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "xenon2",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/4399658?v=4",
|
||||
"url": "https://github.com/xenon2",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-12",
|
||||
"updated": "2026-10-13",
|
||||
"releases": [
|
||||
{
|
||||
"tag": "v1.1.0",
|
||||
"name": "FramePort v1.1.0",
|
||||
"date": "2026-10-13",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.1.0",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.1.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Games on an SD card whose name has a space start again** (they got no storage before).\n- **Experimental eye-tracked foveation** per game, through Valve's foveation layer (thanks Lucas-Mathieu, #247).\n- **Game menu: \"Update with a newer APK…\" and \"Update app only\"** (keeps modded game data on the Frame).\n- **Game fixes:** Wander 0.3570, Vermillion, Star Wars: Tales from the Galaxy's Edge, The Climb, Jurassic World Aftermath backups, Yupitergrad, Beyond Sandbox, and an optional fade fix for Batman's loading screens (thanks nleach7 and Klownicle).\n- **Controllers switched on after a 2D app started** now reach it as a gamepad.\n- **Fixes:** a changed Frame SSH key can be trusted with one click, uninstalling no longer stops on its own lock file, file pickers work without a desktop portal on Linux, separate desktop-icon switch for Linux apps, and the low-space warning checks the right drive.\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.4",
|
||||
"name": "FramePort v1.0.4",
|
||||
|
||||
@@ -202,6 +202,55 @@ class Axml:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- high-level queries/fixes
|
||||
def copy_activity_label_to_application(manifest: bytes) -> bytes | None:
|
||||
"""Fill a missing application label from an activity, retaining its resource/string value (#269).
|
||||
|
||||
OVRPort's appName() assumes the application has a label; Android permits activity-only labels. Existing
|
||||
application labels and the activity itself are untouched. No strings or resource ids are added.
|
||||
"""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
|
||||
def label(el):
|
||||
return next((a for a in el.attrs if a.ns < len(names) and a.name < len(names) and names[a.ns] == ANDROID_NS
|
||||
and names[a.name] == "label"), None)
|
||||
|
||||
elements = list(x.elements())
|
||||
app = next((el for el in elements if el.name == "application"), None)
|
||||
if app is None or label(app) is not None:
|
||||
return None
|
||||
source = next((a for el in elements if el.name in ("activity", "activity-alias")
|
||||
and (a := label(el)) is not None), None)
|
||||
if source is None:
|
||||
return None
|
||||
# Attributes are sorted by resource id in Android's binary XML. Reuse the existing resource map.
|
||||
ids = {}
|
||||
pos = x.pool_off + x.pool_size
|
||||
while pos < app.offset:
|
||||
typ, hsz, size = struct.unpack_from("<HHI", x.data, pos)
|
||||
if size < hsz or size == 0:
|
||||
raise ValueError("invalid binary XML chunk")
|
||||
if typ == 0x0180: # RES_XML_RESOURCE_MAP
|
||||
ids = dict(enumerate(struct.unpack_from(f"<{(size - hsz) // 4}I", x.data, pos + hsz)))
|
||||
break
|
||||
pos += size
|
||||
ext = app.offset + struct.unpack_from("<H", x.data, app.offset + 2)[0]
|
||||
start, stride, count = struct.unpack_from("<HHH", x.data, ext + 8)
|
||||
index = next((i for i, a in enumerate(app.attrs)
|
||||
if ids.get(a.name, 0xFFFFFFFF) > ids.get(source.name, 0xFFFFFFFF)), count)
|
||||
at = ext + start + index * stride
|
||||
record = x.data[source.offset:source.offset + 20] + bytes(stride - 20)
|
||||
data = x.data[:at] + record + x.data[at:]
|
||||
struct.pack_into("<I", data, app.offset + 4, app.size + stride)
|
||||
struct.pack_into("<H", data, ext + 12, count + 1)
|
||||
for offset in (14, 16, 18): # one-based id/class/style attribute indices
|
||||
value = struct.unpack_from("<H", data, ext + offset)[0]
|
||||
if value > index:
|
||||
struct.pack_into("<H", data, ext + offset, value + 1)
|
||||
struct.pack_into("<I", data, 4, len(data))
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def used_and_declared_permissions(manifest: bytes) -> tuple[list[str], set[str]]:
|
||||
x = Axml(manifest)
|
||||
used, declared = [], set()
|
||||
|
||||
@@ -9,12 +9,13 @@ play sessions (the Library flickered under a stream of pop-ups). The GUI holds a
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import user_data_dir
|
||||
from .paths import user_data_dir, write_atomic
|
||||
|
||||
_held = None # the open lock file (kept for the process lifetime)
|
||||
|
||||
@@ -27,6 +28,10 @@ def focus_file() -> Path:
|
||||
return user_data_dir() / "gui.focus"
|
||||
|
||||
|
||||
def _owner_file(path: Path) -> Path:
|
||||
return path.with_suffix(".pid")
|
||||
|
||||
|
||||
def _try_lock(f) -> bool:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
@@ -57,6 +62,10 @@ def acquire(path: Path | None = None) -> bool:
|
||||
f.write(str(os.getpid()))
|
||||
f.flush()
|
||||
_held = f
|
||||
import psutil
|
||||
|
||||
# Windows denies reading the locked byte, so another launch cannot read the PID from gui.lock itself.
|
||||
write_atomic(_owner_file(path), json.dumps({"pid": os.getpid(), "created": psutil.Process().create_time()}))
|
||||
return True
|
||||
|
||||
|
||||
@@ -67,6 +76,11 @@ def release() -> None:
|
||||
f, _held = _held, None
|
||||
if f is None:
|
||||
return
|
||||
path = Path(f.name)
|
||||
try:
|
||||
_owner_file(path).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
@@ -75,7 +89,6 @@ def release() -> None:
|
||||
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
path = Path(f.name)
|
||||
f.close()
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -84,8 +97,18 @@ def release() -> None:
|
||||
|
||||
|
||||
def holder(path: Path | None = None) -> int | None:
|
||||
path = path or lock_file()
|
||||
try:
|
||||
return int((path or lock_file()).read_text().strip() or 0) or None
|
||||
import psutil
|
||||
|
||||
owner = json.loads(_owner_file(path).read_text())
|
||||
pid = int(owner["pid"])
|
||||
if abs(psutil.Process(pid).create_time() - float(owner["created"])) < 0.01:
|
||||
return pid # creation time guards against stale metadata and recycled PIDs
|
||||
except (OSError, ValueError, KeyError, TypeError, psutil.Error):
|
||||
pass
|
||||
try:
|
||||
return int(path.read_text().strip() or 0) or None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -94,10 +117,30 @@ def _is_frameport_gui(pid: int) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
cmd = " ".join(psutil.Process(pid).cmdline()).lower()
|
||||
cmd = psutil.Process(pid).cmdline()
|
||||
except Exception: # noqa: BLE001 (gone, or not ours to inspect)
|
||||
return False
|
||||
return "frameport" in cmd
|
||||
if any(a == "-m" and b == "frameport.ui.app" for a, b in zip(cmd, cmd[1:], strict=False)):
|
||||
return True
|
||||
return any(Path(arg).name.lower() in ("frameport", "frameport.exe", "frameport-gui", "frameport-gui.exe")
|
||||
for arg in cmd[:2])
|
||||
|
||||
|
||||
def _legacy_holders(path: Path) -> list[int]:
|
||||
"""Older Windows GUIs have no readable PID record. Only recover processes holding this exact lock."""
|
||||
import psutil
|
||||
|
||||
found = []
|
||||
wanted = os.path.normcase(str(path.resolve()))
|
||||
for process in psutil.process_iter(["pid"]):
|
||||
if process.pid == os.getpid() or not _is_frameport_gui(process.pid):
|
||||
continue
|
||||
try:
|
||||
if any(os.path.normcase(f.path) == wanted for f in process.open_files()):
|
||||
found.append(process.pid)
|
||||
except (OSError, psutil.Error):
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
STARTUP_GRACE = 30.0 # a GUI this young may still be opening its window (no heartbeat yet): never end it
|
||||
@@ -153,6 +196,9 @@ def claim(window_open, retries: int = 20) -> bool:
|
||||
pid = holder()
|
||||
if pid:
|
||||
end_leftover(pid)
|
||||
elif sys.platform == "win32":
|
||||
for pid in _legacy_holders(lock_file()):
|
||||
end_leftover(pid)
|
||||
for _ in range(retries): # the OS releases the lock when the process is gone
|
||||
if acquire():
|
||||
return True
|
||||
|
||||
@@ -34,6 +34,7 @@ Created by FramePort {version} on {created}. Personal data was replaced by place
|
||||
| manifest.json | app / agent / tool versions, OS, which games are included, warnings |
|
||||
| app/app.log, app/jobs/*.log | the app's log and recent GUI job logs (build, install, launch test, ...) |
|
||||
| app/settings.json | library settings and catalog sources |
|
||||
| app/display.json | GUI versions, window state; Windows display modes, GPU drivers and display/device event metadata |
|
||||
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel"""
|
||||
""" keys |
|
||||
| games/<pkg>/entry.json | the library entry: analysis, recipe, build checks, installs, last launch test |
|
||||
@@ -372,6 +373,12 @@ def collect(packages: list[str] | None, target=None, reporter: Reporter | None =
|
||||
red.add("source", d)
|
||||
w = _Writer(red)
|
||||
# app-wide
|
||||
try:
|
||||
from . import display
|
||||
|
||||
w.json("app/display.json", display.collect())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
w.warnings.append(f"display diagnostics: {exc}")
|
||||
for p in sorted(applog.logs_dir().glob("app.log*"))[:2]:
|
||||
w.text("app/" + p.name, _read_tail(p, 2 << 20))
|
||||
jobs = sorted(applog.jobs_dir().glob("*.log"))
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Small, read-only desktop diagnostics. Native Windows display queries run only when collecting a bundle."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
|
||||
EVENT_TIMEOUT = 5
|
||||
EVENT_LIMIT = 60
|
||||
_lock = threading.Lock()
|
||||
_window: dict = {}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def record_window(event: str, window, *, theme: str, reduce_motion: bool) -> None:
|
||||
"""Cache and log existing window events; no display queries, timers or extra UI updates."""
|
||||
if not _is_windows():
|
||||
return
|
||||
state = {"event": event, "time_utc": _now(), "size": [window.width, window.height],
|
||||
"position": [window.left, window.top], "minimized": window.minimized,
|
||||
"maximized": window.maximized, "full_screen": window.full_screen,
|
||||
"theme": theme, "reduce_motion": reduce_motion}
|
||||
with _lock:
|
||||
state["focused"] = (event == "focus") if event in ("focus", "blur") else _window.get("focused")
|
||||
if event in ("minimize", "restore"):
|
||||
state["minimized"] = event == "minimize"
|
||||
if event in ("maximize", "unmaximize"):
|
||||
state["maximized"] = event == "maximize"
|
||||
if event in ("enter-full-screen", "leave-full-screen"):
|
||||
state["full_screen"] = event == "enter-full-screen"
|
||||
_window.clear()
|
||||
_window.update(state)
|
||||
from ..core import applog
|
||||
|
||||
applog.log.info("desktop window: %s", json.dumps(state, separators=(",", ":")))
|
||||
|
||||
|
||||
def _runtime() -> dict:
|
||||
# Flet's constants are available in bundles even when wheel metadata is absent. Do not import the GUI here.
|
||||
version = sys.modules.get("flet.version")
|
||||
return {"flet": getattr(version, "flet_version", None),
|
||||
"flutter": getattr(version, "flutter_version", None),
|
||||
"executable": os.path.basename(sys.executable),
|
||||
"engine_switches": [os.environ[f"FLUTTER_ENGINE_SWITCH_{i}"][:256] for i in range(1, 17)
|
||||
if f"FLUTTER_ENGINE_SWITCH_{i}" in os.environ]}
|
||||
|
||||
|
||||
def _driver(device_key: str) -> dict:
|
||||
"""Read driver metadata only, without device IDs, registry paths or monitor serials."""
|
||||
import winreg
|
||||
|
||||
prefix = "\\Registry\\Machine\\"
|
||||
if not device_key.startswith(prefix):
|
||||
return {"status": "unavailable"}
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, device_key[len(prefix):]) as key:
|
||||
out = {}
|
||||
for value, field in (("DriverVersion", "version"), ("DriverDate", "date"),
|
||||
("ProviderName", "provider")):
|
||||
try:
|
||||
out[field] = str(winreg.QueryValueEx(key, value)[0])
|
||||
except OSError:
|
||||
pass
|
||||
return {"status": "ok" if out else "unavailable", **out}
|
||||
except OSError as exc:
|
||||
return {"status": "error", "error": str(exc)}
|
||||
|
||||
|
||||
def _windows_displays() -> dict:
|
||||
import ctypes as C
|
||||
|
||||
WORD, DWORD, LONG = C.c_uint16, C.c_uint32, C.c_int32
|
||||
|
||||
class Device(C.Structure):
|
||||
_fields_ = [("cb", DWORD), ("name", C.c_wchar * 32), ("description", C.c_wchar * 128),
|
||||
("flags", DWORD), ("device_id", C.c_wchar * 128), ("key", C.c_wchar * 128)]
|
||||
|
||||
class Mode(C.Structure):
|
||||
_fields_ = [("device", C.c_wchar * 32), ("spec", WORD), ("driver", WORD), ("size", WORD),
|
||||
("extra", WORD), ("fields", DWORD), ("x", LONG), ("y", LONG),
|
||||
("orientation", DWORD), ("fixed_output", DWORD), ("color", WORD), ("duplex", WORD),
|
||||
("y_resolution", WORD), ("tt_option", WORD), ("collate", WORD),
|
||||
("form", C.c_wchar * 32), ("log_pixels", WORD), ("bits", DWORD), ("width", DWORD),
|
||||
("height", DWORD), ("flags", DWORD), ("frequency", DWORD), ("icm_method", DWORD),
|
||||
("icm_intent", DWORD), ("media_type", DWORD), ("dither", DWORD),
|
||||
("reserved1", DWORD), ("reserved2", DWORD), ("panning_width", DWORD),
|
||||
("panning_height", DWORD)]
|
||||
|
||||
user = C.WinDLL("user32", use_last_error=True)
|
||||
enum = user.EnumDisplayDevicesW
|
||||
enum.argtypes, enum.restype = [C.c_wchar_p, DWORD, C.POINTER(Device), DWORD], C.c_int
|
||||
settings = user.EnumDisplaySettingsW
|
||||
settings.argtypes, settings.restype = [C.c_wchar_p, DWORD, C.POINTER(Mode)], C.c_int
|
||||
adapters = []
|
||||
for i in range(16):
|
||||
device = Device(cb=C.sizeof(Device))
|
||||
if not enum(None, i, C.byref(device), 0):
|
||||
break
|
||||
adapter = {"name": device.name, "description": device.description,
|
||||
"attached": bool(device.flags & 1), "primary": bool(device.flags & 4),
|
||||
"driver": _driver(device.key)}
|
||||
mode = Mode(size=C.sizeof(Mode))
|
||||
if settings(device.name, 0xFFFFFFFF, C.byref(mode)): # ENUM_CURRENT_SETTINGS
|
||||
adapter["mode"] = {"size": [mode.width, mode.height], "position": [mode.x, mode.y],
|
||||
"refresh_hz": mode.frequency, "bits_per_pixel": mode.bits,
|
||||
"orientation": mode.orientation}
|
||||
else:
|
||||
adapter["mode"] = {"status": "unavailable"}
|
||||
monitors = []
|
||||
for j in range(16):
|
||||
monitor = Device(cb=C.sizeof(Device))
|
||||
if not enum(device.name, j, C.byref(monitor), 0):
|
||||
break
|
||||
monitors.append({"name": monitor.name, "description": monitor.description,
|
||||
"active": bool(monitor.flags & 1)})
|
||||
adapter["monitors"] = monitors
|
||||
adapters.append(adapter)
|
||||
return {"status": "ok", "adapters": adapters, "limit_reached": len(adapters) == 16}
|
||||
|
||||
|
||||
# Filter on the event-log service, not by scanning every System event. Metadata is enough to correlate resets
|
||||
# and device changes; event messages can contain unrelated device IDs and personal data.
|
||||
_EVENT_SCRIPT = r"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$providers = "Provider[@Name='Display'] or Provider[@Name='nvlddmkm'] or " +
|
||||
"Provider[@Name='Microsoft-Windows-Kernel-PnP'] or Provider[@Name='Microsoft-Windows-Dwm-Core'] or " +
|
||||
"Provider[@Name='Microsoft-Windows-DxgKrnl']"
|
||||
$xpath = "*[System[TimeCreated[timediff(@SystemTime) <= 1800000] and ($providers)]]"
|
||||
try {
|
||||
$events = @(Get-WinEvent -LogName System -FilterXPath $xpath -MaxEvents 60 -ErrorAction Stop | ForEach-Object {
|
||||
@{time_utc=$_.TimeCreated.ToUniversalTime().ToString('o'); provider=$_.ProviderName;
|
||||
id=$_.Id; level=$_.Level; record_id=$_.RecordId}
|
||||
})
|
||||
@{status='ok'; events=$events} | ConvertTo-Json -Depth 4 -Compress
|
||||
} catch {
|
||||
if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') {
|
||||
@{status='ok'; events=@()} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
@{status='error'; error=$_.FullyQualifiedErrorId; events=@()} | ConvertTo-Json -Compress
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _recent_events() -> dict:
|
||||
try:
|
||||
result = subprocess.run(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
|
||||
_EVENT_SCRIPT], capture_output=True, encoding="utf-8", errors="replace",
|
||||
timeout=EVENT_TIMEOUT, creationflags=0x08000000)
|
||||
if result.returncode:
|
||||
return {"status": "error", "error": (result.stderr or "PowerShell query failed")[-512:]}
|
||||
data = json.loads(result.stdout.lstrip("\ufeff"))
|
||||
if not isinstance(data, dict) or data.get("status") not in ("ok", "error"):
|
||||
raise ValueError("unexpected event query result")
|
||||
if not isinstance(data.get("events"), list):
|
||||
raise ValueError("unexpected event list")
|
||||
data["events"] = data["events"][:EVENT_LIMIT]
|
||||
return data
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "timeout", "timeout_seconds": EVENT_TIMEOUT}
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
return {"status": "error", "error": str(exc)[:512]}
|
||||
|
||||
|
||||
def collect() -> dict:
|
||||
"""One on-demand snapshot. Unsupported platforms and query failures are explicit, never empty successes."""
|
||||
with _lock:
|
||||
window = dict(_window)
|
||||
out = {"collected_at_utc": _now(), "platform": sys.platform, "runtime": _runtime(), "window": window}
|
||||
if not _is_windows():
|
||||
out["windows"] = {"status": "not_applicable"}
|
||||
return out
|
||||
try:
|
||||
displays = _windows_displays()
|
||||
except Exception as exc: # noqa: BLE001 diagnostics must not prevent collecting the rest of the bundle
|
||||
displays = {"status": "error", "error": str(exc)[:512]}
|
||||
out["windows"] = {"displays": displays, "system_events": {"lookback_minutes": 30, **_recent_events()}}
|
||||
return out
|
||||
@@ -11,6 +11,10 @@ TBXR also draws through multisampled render-to-texture (glFramebufferTexture2DMu
|
||||
glRenderbufferStorageMultisampleEXT) whatever its --msaa setting; on the Frame that framebuffer is incomplete
|
||||
(GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE, black eyes). The GL shim (libglshim.so, linked into the TBXR library) hands
|
||||
it single-sampled versions of both.
|
||||
|
||||
GL4ES implements desktop GL draw calls, including client arrays and immediate-mode conversion. The shim's GLES
|
||||
draw exports must not take those calls away from GL4ES: doing so submits its untranslated arrays and stretches
|
||||
animated models into spikes (#191). Keep those five symbols private only in APKs that bundle GL4ES.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +25,8 @@ from . import artifact
|
||||
from .vrapi_bridge import SHIM
|
||||
|
||||
VENDOR_LOADER = "libopenxr_loader_valve.so"
|
||||
GL4ES_DRAW_EXPORTS = ("glDrawArrays", "glDrawElements", "glDrawArraysInstanced",
|
||||
"glDrawElementsInstanced", "glDrawRangeElements")
|
||||
|
||||
|
||||
class TbxrVendor(Patch):
|
||||
@@ -29,7 +35,8 @@ class TbxrVendor(Patch):
|
||||
description = ("Team Beef's ports (for example Lambda1VR, RTCWQuest) load an OpenXR library named after the "
|
||||
"headset maker and pick their VR setup by maker; for \"valve\" they stop at start "
|
||||
"(libopenxr_loader_valve.so not found) or take the Pico path. Adds that library and lets them take "
|
||||
"the Meta path.")
|
||||
"the Meta path. Keeps GL4ES in charge of its desktop OpenGL draw calls so animated models "
|
||||
"don't turn into stretched triangles.")
|
||||
order = 47
|
||||
|
||||
def applies(self, a):
|
||||
@@ -60,11 +67,25 @@ class TbxrVendor(Patch):
|
||||
ctx.notes.append(f"GL shim loaded by {lib} (single-sampled render-to-texture)")
|
||||
changed = True
|
||||
if changed or ws.has(ws.lib(SHIM)):
|
||||
ws.put(ws.lib(SHIM), artifact(ws.abi, SHIM))
|
||||
shim = artifact(ws.abi, SHIM)
|
||||
if ws.has(ws.lib("libgl4es.so")):
|
||||
shim, hidden = elf.hide_exports(shim, GL4ES_DRAW_EXPORTS)
|
||||
if hidden:
|
||||
ctx.notes.append("GL4ES keeps its desktop GL draw calls (shim draw exports made private)")
|
||||
if not ws.has(ws.lib(SHIM)) or ws.read_lib(SHIM) != shim:
|
||||
ws.put(ws.lib(SHIM), shim)
|
||||
changed = True
|
||||
if not ws.has(ws.lib(VENDOR_LOADER)):
|
||||
ws.put(ws.lib(VENDOR_LOADER), build_stub_library([], soname=VENDOR_LOADER))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not ws.has(ws.lib("libgl4es.so")) or not ws.has(ws.lib(SHIM)):
|
||||
return []
|
||||
_, public = elf.hide_exports(ws.read_lib(SHIM), GL4ES_DRAW_EXPORTS)
|
||||
return [("GL4ES handles desktop GL draw calls", not public, ", ".join(public))]
|
||||
|
||||
|
||||
register(TbxrVendor)
|
||||
@@ -55,7 +55,7 @@ class CatalogEntry:
|
||||
pcvr_remove: list[str] = field(default_factory=list)
|
||||
proton_env: dict = field(default_factory=dict)
|
||||
proton_tool: str = ""
|
||||
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
|
||||
as_is: bool = False # install the APK or Rift dump unchanged; device and launch settings still apply
|
||||
verified: dict = field(default_factory=dict)
|
||||
updated: str = "" # date of the last maintainer change without a new headset test (e.g. a fix from a report)
|
||||
min_app: str = "" # oldest FramePort version this entry is for (remote entries for a newer app are skipped)
|
||||
|
||||
@@ -37,6 +37,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
elif entry:
|
||||
recipe.source = f"catalog ({entry.origin})"
|
||||
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
|
||||
recipe.as_is = entry.as_is
|
||||
why = f"Known-good recipe for {entry.title} (tested {entry.verified.get('date', '?')})."
|
||||
for pid in entry.overport_remove:
|
||||
recipe.patches.pop(pid, None)
|
||||
@@ -88,7 +89,9 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
|
||||
if not rift:
|
||||
_static_blockers(analysis, recipe)
|
||||
_non_quest(analysis, recipe)
|
||||
if not rift and (not entry or analysis.vr_kind == "none"):
|
||||
# Saved recipes still need the flat-app build rules: no OVRPort, and APK fixes only when needed (#271).
|
||||
_non_quest(analysis, recipe)
|
||||
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
|
||||
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
|
||||
recipe.as_is = True
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.events import Reporter
|
||||
@@ -59,15 +60,36 @@ def patch(apk: Path, outdir: Path, name: str, patches: list[str], reporter: Repo
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
target = outdir / name
|
||||
target.unlink(missing_ok=True)
|
||||
args = ["-jar", _jar(), "patch", f"--input={apk}", f"--output={outdir}", f"--output-name={name}",
|
||||
f"--workspace={workspace()}", f"--version={version}", "--patches=" + ";".join(patches)]
|
||||
reporter.log("OVRPort " + " ".join(a for a in args[3:] if not a.startswith("--patches")))
|
||||
p = toolchain.run_java(args)
|
||||
# overport draws spinners with \r; keep only final line states
|
||||
for line in (p.stdout + p.stderr).splitlines():
|
||||
line = line.split("\r")[-1].strip()
|
||||
if line:
|
||||
reporter.log(" " + line)
|
||||
jar, work = _jar(), workspace()
|
||||
|
||||
def run(input_apk):
|
||||
args = ["-jar", jar, "patch", f"--input={input_apk}", f"--output={outdir}", f"--output-name={name}",
|
||||
f"--workspace={work}", f"--version={version}", "--patches=" + ";".join(patches)]
|
||||
reporter.log("OVRPort " + " ".join(a for a in args[3:] if not a.startswith("--patches")))
|
||||
result = toolchain.run_java(args)
|
||||
# overport draws spinners with \r; keep only final line states
|
||||
for line in (result.stdout + result.stderr).splitlines():
|
||||
line = line.split("\r")[-1].strip()
|
||||
if line:
|
||||
reporter.log(" " + line)
|
||||
return result
|
||||
|
||||
p = run(apk)
|
||||
output = p.stdout + p.stderr
|
||||
if p.returncode and "java.util.NoSuchElementException: Sequence is empty." in output \
|
||||
and "PatcherContext.appName(" in output:
|
||||
from ..apk.axml import copy_activity_label_to_application
|
||||
from ..apk.workspace import ApkWorkspace
|
||||
|
||||
with ApkWorkspace(apk) as staged:
|
||||
manifest = copy_activity_label_to_application(staged.read("AndroidManifest.xml"))
|
||||
if manifest is not None:
|
||||
reporter.log("OVRPort could not read the app name; retrying with the activity's label")
|
||||
with tempfile.TemporaryDirectory(prefix="ovrport-label-", dir=outdir) as tmp:
|
||||
staged.put("AndroidManifest.xml", manifest)
|
||||
prepared = staged.write(Path(tmp) / apk.name)
|
||||
target.unlink(missing_ok=True)
|
||||
p = run(prepared)
|
||||
if p.returncode or not target.exists():
|
||||
raise RuntimeError(f"OVRPort failed (exit {p.returncode}); see log")
|
||||
return target
|
||||
|
||||
@@ -164,6 +164,7 @@ 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
|
||||
self._log_window("ready")
|
||||
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
|
||||
@@ -263,10 +264,18 @@ class FramePortApp:
|
||||
self.activity.set_open(open_activity)
|
||||
self.render()
|
||||
|
||||
def _log_window(self, event: str) -> None:
|
||||
if sys.platform == "win32":
|
||||
from ..diag import display
|
||||
|
||||
display.record_window(event, self.page.window, theme=T.THEME, reduce_motion=self.reduce_motion)
|
||||
|
||||
def _on_window_event(self, e) -> None:
|
||||
"""Remember the window's size and position (once a resize/move ends; not while maximized or full screen) and
|
||||
whether it is maximized."""
|
||||
win, change = self.page.window, None
|
||||
if e.type not in (ft.WindowEventType.MOVE, ft.WindowEventType.RESIZE):
|
||||
self._log_window(getattr(e.type, "value", str(e.type)))
|
||||
if e.type == ft.WindowEventType.CLOSE:
|
||||
_stop_link_watch() # also when maximized (that returns below)
|
||||
if e.type in (ft.WindowEventType.MAXIMIZE, ft.WindowEventType.UNMAXIMIZE):
|
||||
|
||||
@@ -37,6 +37,65 @@ def test_vdf_roundtrip_and_upsert(monkeypatch, tmp_path):
|
||||
assert a.vdf_decode(vdf.read_bytes())["shortcuts"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("root_key", ["Shortcuts", "SHORTCUTS", "shortcuts"])
|
||||
def test_shortcuts_keep_steams_key_spelling_and_existing_games(monkeypatch, tmp_path, root_key):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
exe = '"/game/launch.sh"'
|
||||
other = {"AppID": 123, "AppName": "Other", "EXE": '"/other"', "Tags": {"0": "Favorite"}}
|
||||
original = {root_key: {"0": {"AppID": 456, "AppName": "Old", "EXE": exe, "ICON": "/old.png",
|
||||
"Tags": {"0": "Favorite"}, "LastPlayTime": 99}, "1": other}}
|
||||
data = a.vdf_encode(original)
|
||||
vdf.write_bytes(data)
|
||||
assert a.vdf_encode(a.vdf_decode(data)) == data
|
||||
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"], write=False) == (456, True)
|
||||
assert vdf.read_bytes() == data
|
||||
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"]) == 456
|
||||
root = a.vdf_decode(vdf.read_bytes())
|
||||
assert list(root) == [root_key]
|
||||
entry = root["shortcuts"]["0"]
|
||||
assert entry["appname"] == "New" and "AppName" in list(entry) and "appname" not in list(entry)
|
||||
assert entry["appid"] == 456 and entry["icon"] == "/old.png" and entry["LastPlayTime"] == 99
|
||||
assert list(entry["tags"].values()) == ["Favorite", "Quest on Frame", "Action"]
|
||||
assert root["shortcuts"]["1"] == other
|
||||
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"], write=False) == (456, False)
|
||||
a.upsert_shortcut(str(vdf), '"/third"', "Third", "/")
|
||||
assert len(a.vdf_decode(vdf.read_bytes())["shortcuts"]) == 3
|
||||
assert a.prune_shortcuts(str(vdf), "New", '"/replacement"', "Quest on Frame") == [456]
|
||||
assert a.remove_shortcut(str(vdf), '"/other"')
|
||||
root = a.vdf_decode(vdf.read_bytes())
|
||||
assert list(root) == [root_key] and len(root["shortcuts"]) == 1
|
||||
assert root["shortcuts"]["0"]["appname"] == "Third"
|
||||
|
||||
|
||||
def test_capitalized_shortcuts_are_found_by_library_and_devkit_checks(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
cfg = Path(a.STEAM) / "userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
exe = '"/game/launch.sh"'
|
||||
vdf = cfg / "shortcuts.vdf"
|
||||
vdf.write_bytes(a.vdf_encode({"Shortcuts": {"0": {"APPID": 0xF0000001, "EXE": exe,
|
||||
"APPNAME": "Game", "DEVKITGAMEID": "fp_game"}}}))
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "shortcut_args", lambda pkg: (exe,))
|
||||
monkeypatch.setattr(a.time, "sleep", lambda seconds: None)
|
||||
assert a.shortcut_appid_for(exe) == 0xF0000001
|
||||
assert a.devkit_appid("fp_game") == 0xF0000001
|
||||
assert a.shortcut_names("42") == {str(0xF0000001): "Game"}
|
||||
assert a.shortcuts_lost(["42"], ["com.x.y"], wait=0) == []
|
||||
|
||||
|
||||
def test_case_variant_duplicate_vdf_keys_are_not_overwritten(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
data = a.vdf_encode({"Shortcuts": {"0": {"appid": 1}}, "shortcuts": {"0": {"appid": 2}}})
|
||||
vdf.write_bytes(data)
|
||||
with pytest.raises(a.AgentError, match="duplicate VDF key"):
|
||||
a.upsert_shortcut(str(vdf), '"/game"', "Game", "/")
|
||||
assert vdf.read_bytes() == data
|
||||
|
||||
|
||||
def test_launcher_template(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "anchor"
|
||||
|
||||
@@ -1,6 +1,76 @@
|
||||
from frameport.apk import axml
|
||||
|
||||
|
||||
def test_missing_application_label_uses_activity_resource():
|
||||
from conftest import build_axml
|
||||
|
||||
manifest = build_axml([
|
||||
("start", "manifest", [("package", "str", "org.example.game")]),
|
||||
("start", "application", [("debuggable", "bool", False)]),
|
||||
("start", "activity", [("name", "str", "Main"), ("label", "ref", 0x7F020000)]),
|
||||
("end", "activity"), ("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
fixed = axml.copy_activity_label_to_application(manifest)
|
||||
x = axml.Axml(fixed)
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
label = next(a for a in app.attrs if x.string(a.name) == "label")
|
||||
assert label.dtype == axml.TYPE_REFERENCE and label.value == 0x7F020000
|
||||
assert len(fixed) == len(manifest) + 20 and x.get_bool("application", "debuggable") is False
|
||||
original = axml.Axml(manifest)
|
||||
activity = next(el for el in original.elements() if el.name == "activity")
|
||||
new_activity = next(el for el in x.elements() if el.name == "activity")
|
||||
assert manifest[activity.offset:activity.offset + activity.size] == fixed[
|
||||
new_activity.offset:new_activity.offset + new_activity.size]
|
||||
assert axml.copy_activity_label_to_application(fixed) is None
|
||||
|
||||
|
||||
def test_application_label_is_never_replaced():
|
||||
from conftest import build_axml
|
||||
|
||||
for kind, value in [("str", "Original"), ("ref", 0x7F020001)]:
|
||||
manifest = build_axml([
|
||||
("start", "manifest", []), ("start", "application", [("label", kind, value)]),
|
||||
("start", "activity", [("label", "str", "Other")]), ("end", "activity"),
|
||||
("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
assert axml.copy_activity_label_to_application(manifest) is None
|
||||
assert axml.copy_activity_label_to_application(build_axml([
|
||||
("start", "manifest", []), ("start", "application", []), ("end", "application"),
|
||||
("end", "manifest"),
|
||||
])) is None
|
||||
|
||||
|
||||
def test_copied_label_preserves_resource_order_and_special_attribute_indices():
|
||||
import struct
|
||||
|
||||
from conftest import build_axml
|
||||
|
||||
manifest = build_axml([
|
||||
("start", "manifest", []),
|
||||
("start", "application", [("theme", "ref", 0x0103000A), ("debuggable", "bool", False)]),
|
||||
("start", "activity", [("label", "str", "Game")]), ("end", "activity"),
|
||||
("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
x = axml.Axml(manifest)
|
||||
ids = [0] * x.count
|
||||
for name, resource in [("theme", 0x01010000), ("label", 0x01010001), ("debuggable", 0x0101000F)]:
|
||||
ids[x.index(name)] = resource
|
||||
resource_map = struct.pack("<HHI", 0x0180, 8, 8 + len(ids) * 4) + struct.pack(f"<{len(ids)}I", *ids)
|
||||
pos = x.pool_off + x.pool_size
|
||||
data = bytearray(manifest[:pos] + resource_map + manifest[pos:])
|
||||
struct.pack_into("<I", data, 4, len(data))
|
||||
x = axml.Axml(data)
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
# All three special indices point at the second attribute, which the insertion moves to third.
|
||||
ext = app.offset + 16
|
||||
struct.pack_into("<HHH", data, ext + 14, 2, 2, 2)
|
||||
fixed = axml.copy_activity_label_to_application(bytes(data))
|
||||
x = axml.Axml(fixed)
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
assert [x.string(a.name) for a in app.attrs] == ["theme", "label", "debuggable"]
|
||||
assert struct.unpack_from("<HHH", fixed, app.offset + 16 + 14) == (3, 3, 3)
|
||||
|
||||
|
||||
def test_parse(quest_manifest):
|
||||
x = axml.Axml(quest_manifest)
|
||||
names = [e.name for e in x.elements()]
|
||||
|
||||
@@ -131,6 +131,19 @@ def test_bundle_offline_and_app_only(tmp_path):
|
||||
assert m["target"] is None and m["games"] == []
|
||||
|
||||
|
||||
def test_display_diagnostics_use_bundle_redaction(tmp_path, monkeypatch):
|
||||
from frameport.diag import display
|
||||
|
||||
monkeypatch.setattr(display, "collect", lambda: {"windows": {"status": "error",
|
||||
"error": r"C:\Users\Alice\display.txt at 192.168.50.2"},
|
||||
"driver": "32.0.15.61742"})
|
||||
out = bundle.collect(None, dest=tmp_path / "display.zip")
|
||||
with zipfile.ZipFile(out) as z:
|
||||
text = z.read("app/display.json").decode()
|
||||
assert "Alice" not in text and "192.168.50.2" not in text
|
||||
assert json.loads(text)["driver"] == "32.0.15.61742"
|
||||
|
||||
|
||||
def test_bundle_size_cap():
|
||||
w = bundle._Writer(redact.Redactor())
|
||||
import os
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Desktop diagnostics stay on demand, bounded and useful when Windows queries fail or return no events."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.diag import display
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_window(monkeypatch):
|
||||
monkeypatch.setattr(display, "_window", {})
|
||||
|
||||
|
||||
def test_non_windows_does_not_start_display_queries(monkeypatch):
|
||||
monkeypatch.setattr(display, "_is_windows", lambda: False)
|
||||
|
||||
def unexpected():
|
||||
pytest.fail("Windows query ran on another platform")
|
||||
|
||||
monkeypatch.setattr(display, "_windows_displays", unexpected)
|
||||
monkeypatch.setattr(display, "_recent_events", unexpected)
|
||||
assert display.collect()["windows"] == {"status": "not_applicable"}
|
||||
|
||||
|
||||
def test_window_events_are_cached_without_running_queries(monkeypatch, caplog):
|
||||
monkeypatch.setattr(display, "_is_windows", lambda: True)
|
||||
monkeypatch.setitem(sys.modules, "flet.version", SimpleNamespace(flet_version="1.0.2", flutter_version="3.44.8"))
|
||||
window = SimpleNamespace(width=1280, height=720, left=3086, top=172,
|
||||
minimized=False, maximized=False, full_screen=False)
|
||||
with caplog.at_level("INFO", logger="frameport"):
|
||||
display.record_window("minimize", window, theme="portal", reduce_motion=True)
|
||||
assert "desktop window:" in caplog.text and '"event":"minimize"' in caplog.text
|
||||
assert display._window["minimized"] is True and display._window["position"] == [3086, 172]
|
||||
monkeypatch.setattr(display, "_windows_displays", lambda: {"status": "ok", "adapters": []})
|
||||
monkeypatch.setattr(display, "_recent_events", lambda: {"status": "ok", "events": []})
|
||||
result = display.collect()
|
||||
assert result["runtime"]["flutter"] == "3.44.8"
|
||||
assert result["window"]["event"] == "minimize"
|
||||
display.record_window("focus", window, theme="portal", reduce_motion=True)
|
||||
display.record_window("restore", window, theme="portal", reduce_motion=True)
|
||||
assert display._window["focused"] is True and display._window["minimized"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response,status", [
|
||||
('{"status":"ok","events":[]}', "ok"),
|
||||
('{"status":"error","error":"AccessDenied","events":[]}', "error"),
|
||||
('{"status":"ok","events":"invalid"}', "error"),
|
||||
("not JSON", "error"),
|
||||
])
|
||||
def test_event_query_distinguishes_empty_results_from_failures(monkeypatch, response, status):
|
||||
def run(args, **kwargs):
|
||||
assert args[:4] == ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive"]
|
||||
assert kwargs["timeout"] == 5 and kwargs["creationflags"] == 0x08000000
|
||||
assert "-FilterXPath" in args[-1] and "-MaxEvents 60" in args[-1]
|
||||
assert ".Message" not in args[-1] # event metadata, not unrelated free text or device IDs
|
||||
return subprocess.CompletedProcess(args, 0, response, "")
|
||||
|
||||
monkeypatch.setattr(display.subprocess, "run", run)
|
||||
result = display._recent_events()
|
||||
assert result["status"] == status
|
||||
if status == "ok":
|
||||
assert result["events"] == []
|
||||
|
||||
|
||||
def test_event_query_timeout_does_not_look_like_no_events(monkeypatch):
|
||||
def run(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(args[0], kwargs["timeout"])
|
||||
|
||||
monkeypatch.setattr(display.subprocess, "run", run)
|
||||
assert display._recent_events() == {"status": "timeout", "timeout_seconds": 5}
|
||||
|
||||
|
||||
def test_display_failure_does_not_discard_event_or_window_data(monkeypatch):
|
||||
monkeypatch.setattr(display, "_is_windows", lambda: True)
|
||||
|
||||
def broken():
|
||||
raise OSError("display API unavailable")
|
||||
|
||||
monkeypatch.setattr(display, "_windows_displays", broken)
|
||||
monkeypatch.setattr(display, "_recent_events", lambda: {"status": "ok", "events": []})
|
||||
result = display.collect()
|
||||
assert result["windows"]["displays"]["status"] == "error"
|
||||
assert result["windows"]["system_events"]["status"] == "ok"
|
||||
json.dumps(result)
|
||||
+85
-8
@@ -1,7 +1,13 @@
|
||||
"""One FramePort window at a time (core/instance.py)."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import instance
|
||||
|
||||
@@ -9,24 +15,36 @@ 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
|
||||
import os, 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)
|
||||
print("locked", os.getpid(), flush=True)
|
||||
time.sleep(30)
|
||||
""")
|
||||
p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
|
||||
assert p.stdout.readline().strip() == "locked"
|
||||
status = p.stdout.readline().strip().split()
|
||||
assert status[0] == "locked"
|
||||
p.gui_pid = int(status[1]) # Windows venv's python.exe can launch another interpreter process
|
||||
return p
|
||||
|
||||
|
||||
def _end_holder(holder):
|
||||
import psutil
|
||||
|
||||
try:
|
||||
psutil.Process(holder.gui_pid).kill()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
holder.wait(5)
|
||||
|
||||
|
||||
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 instance.holder() == holder.gui_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()
|
||||
@@ -34,19 +52,78 @@ def test_second_start_is_refused_while_a_window_is_open(tmp_path, monkeypatch):
|
||||
assert instance.claim(lambda: False, retries=1) is False
|
||||
assert holder.poll() is None
|
||||
finally:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
_end_holder(holder)
|
||||
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, "_is_frameport_gui", lambda pid: pid == holder.gui_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()
|
||||
_end_holder(holder)
|
||||
|
||||
|
||||
def test_holder_is_readable_when_windows_blocks_the_lock_contents(tmp_path, monkeypatch):
|
||||
path = tmp_path / "gui.lock"
|
||||
assert instance.acquire(path)
|
||||
read_text = Path.read_text
|
||||
|
||||
def locked(self, *args, **kwargs):
|
||||
if self == path:
|
||||
raise PermissionError("Windows byte-range lock")
|
||||
return read_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", locked)
|
||||
try:
|
||||
assert instance.holder(path) == os.getpid()
|
||||
owner = instance._owner_file(path)
|
||||
data = json.loads(owner.read_text())
|
||||
data["created"] -= 100 # a PID reused by another process must not be accepted
|
||||
owner.write_text(json.dumps(data))
|
||||
assert instance.holder(path) is None
|
||||
finally:
|
||||
instance.release()
|
||||
assert not instance._owner_file(path).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd,expected", [
|
||||
(["python", "-m", "frameport.ui.app"], True),
|
||||
(["python", "-m", "frameport.cli"], False),
|
||||
(["python", "-c", "import frameport"], False),
|
||||
(["/apps/FramePort.exe"], True),
|
||||
])
|
||||
def test_recovery_only_matches_gui_commands(monkeypatch, cmd, expected):
|
||||
import psutil
|
||||
|
||||
monkeypatch.setattr(psutil, "Process", lambda pid: SimpleNamespace(cmdline=lambda: cmd))
|
||||
assert instance._is_frameport_gui(123) is expected
|
||||
|
||||
|
||||
def test_legacy_recovery_only_finds_the_same_data_folders_lock(tmp_path, monkeypatch):
|
||||
import psutil
|
||||
|
||||
lock = tmp_path / "gui.lock"
|
||||
other = tmp_path / "another-app/gui.lock"
|
||||
processes = [SimpleNamespace(pid=123, open_files=lambda: [SimpleNamespace(path=str(lock))]),
|
||||
SimpleNamespace(pid=124, open_files=lambda: [SimpleNamespace(path=str(other))])]
|
||||
monkeypatch.setattr(psutil, "process_iter", lambda attrs: processes)
|
||||
monkeypatch.setattr(instance, "_is_frameport_gui", lambda pid: True)
|
||||
assert instance._legacy_holders(lock) == [123]
|
||||
|
||||
|
||||
def test_claim_recovers_a_legacy_windows_holder_without_readable_pid(monkeypatch):
|
||||
monkeypatch.setattr(instance, "sys", SimpleNamespace(platform="win32"))
|
||||
attempts = iter([False, True])
|
||||
ended = []
|
||||
monkeypatch.setattr(instance, "acquire", lambda: next(attempts))
|
||||
monkeypatch.setattr(instance, "holder", lambda: None)
|
||||
monkeypatch.setattr(instance, "_legacy_holders", lambda path: [123])
|
||||
monkeypatch.setattr(instance, "end_leftover", lambda pid: ended.append(pid))
|
||||
assert instance.claim(lambda: False)
|
||||
assert ended == [123]
|
||||
@@ -0,0 +1,78 @@
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.apk import axml
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.tools import overport
|
||||
|
||||
NAME_FAILURE = ("java.util.NoSuchElementException: Sequence is empty.\n"
|
||||
"at moe.crx.overport.patching.PatcherContext.appName(PatcherContext.kt:231)")
|
||||
|
||||
|
||||
def manifest(app_label=False):
|
||||
from conftest import build_axml
|
||||
|
||||
return build_axml([
|
||||
("start", "manifest", []), ("start", "application", [("label", "str", "Game")] if app_label else []),
|
||||
("start", "activity", [("label", "str", "Game")]), ("end", "activity"),
|
||||
("end", "application"), ("end", "manifest"),
|
||||
])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retry_fails", [False, True])
|
||||
def test_overport_retries_missing_app_name_with_a_temporary_apk(monkeypatch, tmp_path, retry_fails):
|
||||
apk = tmp_path / "source.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest())
|
||||
z.writestr("assets/unchanged", b"game data")
|
||||
original = apk.read_bytes()
|
||||
calls = []
|
||||
outdir = tmp_path / "out"
|
||||
monkeypatch.setattr(overport, "_jar", lambda: "overport.jar")
|
||||
|
||||
def run(args):
|
||||
source = Path(next(a.removeprefix("--input=") for a in args if a.startswith("--input=")))
|
||||
calls.append(source)
|
||||
if len(calls) == 1:
|
||||
return subprocess.CompletedProcess(args, 1, "", NAME_FAILURE)
|
||||
with zipfile.ZipFile(source) as z:
|
||||
x = axml.Axml(z.read("AndroidManifest.xml"))
|
||||
app = next(el for el in x.elements() if el.name == "application")
|
||||
assert x.attr_str(app, "label") == "Game"
|
||||
assert z.read("assets/unchanged") == b"game data"
|
||||
if retry_fails:
|
||||
return subprocess.CompletedProcess(args, 1, "", NAME_FAILURE)
|
||||
(outdir / "patched.apk").write_bytes(b"output")
|
||||
return subprocess.CompletedProcess(args, 0, "done", "")
|
||||
|
||||
monkeypatch.setattr(overport.toolchain, "run_java", run)
|
||||
if retry_fails:
|
||||
with pytest.raises(RuntimeError, match="OVRPort failed"):
|
||||
overport.patch(apk, outdir, "patched.apk", ["patch_copy_libraries"], Reporter())
|
||||
else:
|
||||
result = overport.patch(apk, outdir, "patched.apk", ["patch_copy_libraries"], Reporter())
|
||||
assert result == outdir / "patched.apk"
|
||||
assert len(calls) == 2
|
||||
assert calls[0] == apk and calls[1] != apk and not calls[1].exists()
|
||||
assert apk.read_bytes() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error,app_label", [("some other failure", False), (NAME_FAILURE, True)])
|
||||
def test_overport_does_not_retry_unrelated_failures(monkeypatch, tmp_path, error, app_label):
|
||||
apk = tmp_path / "source.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest(app_label))
|
||||
calls = []
|
||||
monkeypatch.setattr(overport, "_jar", lambda: "overport.jar")
|
||||
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 1, "", error)
|
||||
|
||||
monkeypatch.setattr(overport.toolchain, "run_java", run)
|
||||
with pytest.raises(RuntimeError, match="OVRPort failed"):
|
||||
overport.patch(apk, tmp_path / "out", "patched.apk", ["patch_copy_libraries"], Reporter())
|
||||
assert len(calls) == 1
|
||||
@@ -1,5 +1,7 @@
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.analysis.stubgen import build_stub_library
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.core.events import Reporter
|
||||
@@ -443,6 +445,53 @@ def test_android_app_without_vr_gets_no_vr_translation():
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("info_activity,sdl_clipboard", [(False, False), (True, False), (False, True)])
|
||||
def test_saved_flat_android_recipe_keeps_its_build_mode(monkeypatch, info_activity, sdl_clipboard):
|
||||
"""Saving Plezy as known-good must not start an empty OVRPort conversion (#271)."""
|
||||
from frameport.core import library
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
analysis = _analysis(package="org.example.savedflat", engine="Other", xr="?", libs=[],
|
||||
has_info_category=info_activity, is_overport_output=False,
|
||||
extra={"size": 1, "vr_kind": "none", "media_codec": True, "sdl_java": sdl_clipboard})
|
||||
original = engine.suggest(analysis, use_catalog=False)
|
||||
original.status, original.notes = "issues", "Some videos have color artifacts."
|
||||
game = {"package": analysis.package, "title": "Flat app", "analysis": analysis.to_dict(),
|
||||
"recipe": library.recipe_to_dict(original)}
|
||||
catalog.save_user_entry(catalog.entry_from_library(game))
|
||||
restored = engine.suggest(analysis)
|
||||
assert restored.source == "catalog (user)"
|
||||
assert not restored.overport and restored.as_is == original.as_is
|
||||
assert restored.patches == original.patches
|
||||
assert restored.status == "issues" and original.notes in restored.notes
|
||||
assert "frame.hw_video_decode" in restored.patches and not engine.warnings(restored)
|
||||
|
||||
# Existing affected entries recover when the next app update re-derives their saved recipe.
|
||||
broken = library.recipe_to_dict(original)
|
||||
broken.update(as_is=False, overport=True, source="catalog (user)")
|
||||
data = {"settings": {"recipes.app_version": "0.0.0"},
|
||||
"games": {analysis.package: {**game, "recipe": broken}}}
|
||||
monkeypatch.setattr(library, "REFRESH_ON_UPDATE", True)
|
||||
assert library._follow_catalog(data)
|
||||
repaired = library.recipe_from_dict(data["games"][analysis.package]["recipe"])
|
||||
assert not repaired.overport and repaired.as_is == original.as_is
|
||||
|
||||
|
||||
def test_saved_already_patched_android_recipe_keeps_install_as_is():
|
||||
from frameport.core import library
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
analysis = _analysis(package="org.example.savedpatched", has_info_category=False,
|
||||
extra={"size": 1, "frame_patched": True})
|
||||
original = engine.suggest(analysis, use_catalog=False)
|
||||
assert original.as_is
|
||||
game = {"package": analysis.package, "title": "Patched app", "analysis": analysis.to_dict(),
|
||||
"recipe": library.recipe_to_dict(original)}
|
||||
catalog.save_user_entry(catalog.entry_from_library(game))
|
||||
restored = engine.suggest(analysis)
|
||||
assert restored.as_is
|
||||
|
||||
|
||||
def test_patch_registry_is_complete_for_every_thread(monkeypatch):
|
||||
"""A lookup from a second thread while the first is still importing the patch modules waits for them."""
|
||||
import threading
|
||||
@@ -776,6 +825,38 @@ def test_tbxr_vendor_on_lambda1vr():
|
||||
assert count == 1 and b"\0alve\0" in fixed and len(fixed) == len(xash)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gl4es", [True, False])
|
||||
def test_tbxr_shim_leaves_desktop_draws_to_gl4es(tmp_path, quest_manifest, gl4es):
|
||||
"""#191: the same shim must preserve GL4ES dispatch without removing other games' GLES hooks."""
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame import artifact
|
||||
from frameport.patches.frame.tbxr_vendor import GL4ES_DRAW_EXPORTS
|
||||
|
||||
original = artifact("arm64-v8a", "libglshim.so")
|
||||
patch = base.get("frame.tbxr_vendor")
|
||||
a = _analysis(libs=["libxash.so"], extra={"tbxr_libs": ["libxash.so"]})
|
||||
with ApkWorkspace(_apk(tmp_path, quest_manifest)) as ws:
|
||||
ws.put(ws.lib("libglshim.so"), original)
|
||||
if gl4es:
|
||||
ws.put(ws.lib("libgl4es.so"), build_stub_library(["glDrawElements"], soname="libgl4es.so"))
|
||||
ctx = base.ApkContext(ws, a, {}, Reporter(), {patch.id: {}})
|
||||
if gl4es:
|
||||
assert patch.validate(ctx)[0][1] is False
|
||||
assert patch.apply(ctx)
|
||||
shim = ws.read_lib("libglshim.so")
|
||||
_, visible = elf.hide_exports(shim, GL4ES_DRAW_EXPORTS)
|
||||
assert bool(visible) is not gl4es
|
||||
if gl4es:
|
||||
assert patch.validate(ctx)[0][1] is True
|
||||
# Functions remain callable inside the shim; its GL queries and framebuffer fix stay intact.
|
||||
assert elf.find_symbols(shim, GL4ES_DRAW_EXPORTS) == elf.find_symbols(original, GL4ES_DRAW_EXPORTS)
|
||||
assert elf.find_symbols(shim, ["eglGetProcAddress", "glGetString", "glGetIntegerv"]) == \
|
||||
elf.find_symbols(original, ["eglGetProcAddress", "glGetString", "glGetIntegerv"])
|
||||
else:
|
||||
assert shim == original
|
||||
assert not patch.apply(ctx)
|
||||
|
||||
|
||||
def test_unity_user_presence_points_the_xr_plugin_at_the_shim(tmp_path, quest_manifest):
|
||||
"""BONELAB: OVRPlugin reported the worn headset as not worn and the Marrow rig froze (no tracking, no controls)."""
|
||||
from frameport.analysis import elf
|
||||
|
||||
@@ -119,6 +119,20 @@ def test_crash_logcat_signatures():
|
||||
assert [(f.id, f.suggest) for f in r.findings] == [("swapchain-size-abort", ["frame.swapchain_limit"])]
|
||||
|
||||
|
||||
def test_tbxr_fdm_crash_is_identified_as_vr_teardown():
|
||||
log = "F libc: Fatal signal 11 (SIGSEGV), fault addr 0x0 in AppThreadFunction\n"
|
||||
crash = ("#00 libVkLayer_VALVE_fdm_injection.so (fdm_injection_xrDestroySwapchain+136)\n"
|
||||
"#01 libopenxr_loader_generic.so (xrDestroySwapchain+2600)\n"
|
||||
"#03 libxash.so (ovrRenderer_Destroy+44)\n"
|
||||
"#04 libxash.so (TBXR_LeaveVR+76)\n")
|
||||
r = triage(log, "EXITED", "com.drbeef.lambda1vr", crash=crash)
|
||||
assert [f.id for f in r.findings] == ["tbxr-fdm-shutdown-crash"]
|
||||
assert r.suggestions() == [] # no unverified graphics or foveation fix
|
||||
assert [f.id for f in triage(log, "EXITED", crash=crash.replace("TBXR_LeaveVR", "OtherCall")).findings] == \
|
||||
["native-crash"]
|
||||
assert not triage(crash, "RUNNING").findings # symbol names alone are not a crash
|
||||
|
||||
|
||||
def test_ovrplugin_extension_probe_is_not_a_scene_game():
|
||||
probe = LOG_OK + "09-28 17:39:02.200 1147 1174 I OVRPlugin: Unavailable OpenXR extension: XR_FB_scene\n"
|
||||
assert "scene-missing" not in {f.id for f in triage(probe, "RUNNING", "com.example.game").findings}
|
||||
|
||||
Reference in new issue
Block a user