mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-11 04:06:23 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dccdcaab96 | ||
|
|
99c6a88f90 | ||
|
|
da4f26197d | ||
|
|
02361b7c99 | ||
|
|
d6bbea05ca | ||
|
|
65d3c2ab67 | ||
|
|
327f213f56 | ||
|
|
05c24cc9e0 | ||
|
|
6e31ae52c4 | ||
|
|
7830444c87 | ||
|
|
9ace9d3bb3 | ||
|
|
a994e86ba5 | ||
|
|
e776116162 | ||
|
|
1e814af1d4 | ||
|
|
f3ed77deb0 | ||
|
|
d5245aa496 | ||
|
|
e4a62d6e90 | ||
|
|
dcc57f29c8 | ||
|
|
d87d085842 | ||
|
|
3930617518 | ||
|
|
deaf1f08a6 | ||
|
|
76ba1e4ffa | ||
|
|
62728dc562 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
name: Problem report
|
||||
description: A game or FramePort doesn't work (FramePort fills this in: "Report a problem…").
|
||||
title: "[Problem] "
|
||||
labels: ["bug"]
|
||||
name: Game report
|
||||
description: 'A game doesn''t run right on the Frame, or FramePort itself misbehaves (FramePort fills this in: "Report a problem…").'
|
||||
title: "[Game report] "
|
||||
labels: ["game-compatibility"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Working recipe
|
||||
description: Share a recipe that works on the Steam Frame (FramePort fills this in: game menu → "Share working recipe…").
|
||||
description: 'Share a recipe that works on the Steam Frame (FramePort fills this in: game menu → "Share working recipe…").'
|
||||
title: "[Working recipe] "
|
||||
labels: ["working-config"]
|
||||
body:
|
||||
|
||||
@@ -54,6 +54,9 @@ jobs:
|
||||
- run: npm run build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the contributors list (higher API limit)
|
||||
# analytics (Settings → Secrets and variables → Actions → Variables); unset = none on the site
|
||||
PUBLIC_CF_ANALYTICS_TOKEN: ${{ vars.CF_ANALYTICS_TOKEN }}
|
||||
PUBLIC_GA_ID: ${{ vars.GA_MEASUREMENT_ID }}
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
@@ -68,7 +68,7 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
mouse drags/right-clicks.
|
||||
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
|
||||
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
|
||||
needs agent v65), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
|
||||
needs agent v73), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
|
||||
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
|
||||
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
|
||||
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
|
||||
@@ -249,8 +249,12 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
- `diag/` — user feedback without tokens (docs/DIAGNOSTICS.md): `redact.py` (every file/issue text: IPs, hosts,
|
||||
home dirs, Steam ids, dump folders → placeholders), `bundle.py` (redacted diagnostics zip; agent v21
|
||||
`collect_diag`; `frameport diag collect|inspect|report`), `issue.py` (prefilled GitHub issue-form links, ≤7.5k
|
||||
chars). "Share working config" → `working-config.yml` issue → maintainer label `catalog-accepted` →
|
||||
`catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
|
||||
chars; `*_links` = `IssueLinks(form, plain, body)`: body = the Markdown the form writes (`### <label>`, recipe in
|
||||
a ```yaml block; labels checked against the templates), plain = `?title=&body=` link. GitHub #167: some (Flatpak)
|
||||
browsers opened the form with empty fields → `app.open_issue` copies the body to the clipboard and offers "Form
|
||||
empty? Open a plain issue"; keep the templates valid YAML, quote `: ` in descriptions). "Share working config" →
|
||||
`working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by
|
||||
hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
|
||||
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
|
||||
- Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version
|
||||
(`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use
|
||||
@@ -282,7 +286,7 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
- `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout,
|
||||
launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it.
|
||||
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (app key, podman fix, Developer Mode, Lepton).
|
||||
`bootstrap/setup.sh` — the setup URL (`curl -sL frameport.app/s | bash`, copied to the site by
|
||||
`bootstrap/setup.sh` — the setup URL (`curl -fsSL https://frameport.app/s | bash`, copied to the site by
|
||||
`site/scripts/sync-media.mjs`): finds the PC (stdlib mDNS on 5353 for `_frameport-pair._tcp`, which the pairing
|
||||
server announces while open; else the USB address and a /24 scan of `/ping`), `/hello` → the user clicks Allow (4
|
||||
digits on both sides, `ask_digits`) → `/wait` hands over the code → runs bootstrap.sh exactly like the typed line.
|
||||
@@ -329,7 +333,7 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
|
||||
give new icon-only controls a tooltip. The `showcase` workflow renders on UI pushes to main (+ the videos whose
|
||||
storyboard changed, or the ones named on dispatch) and opens PR `showcase/update`; releases attach every video as
|
||||
`FramePort-<name>.mp4`. `scripts/scrub_library.py` (the owner's own library) is only for private screenshots now.
|
||||
README rules (owner): states the project is a proof of concept, provides no piracy tools, credits the wrapped
|
||||
README rules (owner): says not every game runs (no longer "proof of concept" since 1.0), provides no piracy tools, credits the wrapped
|
||||
projects (most functionality is theirs); neutral technical wording; no Quest2Frame mentions anywhere.
|
||||
- GUI smoke test: `uv pip install flet-web playwright && playwright install chromium`, then
|
||||
`FRAMEPORT_HOME=<test dir> python scripts/ui_smoke.py --out <dir> [--game <pkg>] [--frame steamos@<host>] [--update]` (`--update` = fake release: banner, dialog, Settings → Updates) and look
|
||||
@@ -708,6 +712,7 @@ FrameBridge `snapshot=N` (`snapshot_gl.c`, GLES): every N s the left-eye image t
|
||||
own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless launches show a black headset view, this
|
||||
shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then its loading card (portrait + segmented
|
||||
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
|
||||
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. Autostart at the next SteamVR start: not verified yet. Details: docs/FRAME_RUNTIME.md "Overlay apps".
|
||||
**Lepton's logcat mirror dies (agent v67, 2026-10-09):** occasionally launch.log ends with `logcat: Unexpected EOF!` right after the game starts (about 1 launch in 50, Lepton 2.8.14 and 3.0.5): no dashboard auto-hide, no launch-test result. launch.sh's `_logcat_keeper` then reads `podman exec lepton-steamlaunch-<appid> logcat` itself into launch.log (up to 5 restarts while the game runs).
|
||||
**Double launch (agent v57, 2026-10-06):** a second Play while Lepton still boots (~10 s with nothing to see) made
|
||||
the second Lepton stop the first one's container ("Waiting for steamlaunch-<appid> (PID …) to exit", exit 137
|
||||
@@ -768,6 +773,12 @@ applies where vk_sanitize can: Unreal/Other arm64) writes each distinct SPIR-V m
|
||||
returns `shaders` (newest modules of fp_vk_shaders + fp_spirv, ≤4 MB each, base64) → bundle
|
||||
`games/<pkg>/target/shaders/`. Triage `gpu-hang` suggests both dumps; `triage.graphics_api` (FrameBridge's
|
||||
xrCreateSwapchain formats: <0x1000 Vulkan, else GL) keeps only the session's API's (`API_ONLY`). Host-tested only.
|
||||
Agent v74: a reporter's hang came ~10 s after the last new module, so the culprit wasn't among the newest written →
|
||||
with an index naming modules, `shader_dumps` sends every module of the index's **newest session** (after the last
|
||||
`# start`), deduplicated, last used first, each ≤4 MB, ≤30 MB in all (`session` = how many it names) + that session's
|
||||
index lines; no index (fp_spirv) = the old newest-by-mtime 4 MB. One JSON line (~40 MB base64) over the SSH channel
|
||||
is read whole by `Frame.agent` (fine at that size); `_Writer.fit` drops the least recently used modules last, after
|
||||
cutting logs, to stay under the 24 MB zip limit.
|
||||
**Vivox API 31 (GitHub #101, 2026-10-09):** newer Vivox builds (Green Hell VR) call Android 12 AudioManager
|
||||
communication-device methods from `com.vivox.sdk.AudioChangeListener` with no SDK check → NoSuchMethodError on Lepton's
|
||||
Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION 4) makes every such method return at
|
||||
|
||||
@@ -19,7 +19,8 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
|
||||
> [SideQuest](https://sidequestvr.com/). FramePort downloads published open-source tools (see [Built on](#built-on))
|
||||
> and adds its own patches so games run on the Frame.
|
||||
>
|
||||
> FramePort is a proof of concept.
|
||||
> Not every game runs on the Frame: recipes are tested by the community, and some games need Meta's services or
|
||||
> hardware the Frame lacks.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -31,7 +32,8 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
|
||||
plain words.
|
||||
- **Game settings:** sharpness, refresh rate, controllers, 360° video and mixed reality as simple switches.
|
||||
- **More than Quest games:** Android apps in a window, Linux apps, PC VR games and Windows programs.
|
||||
- **Install links:** "Install with FrameDrop" buttons on websites open in FramePort.
|
||||
- **Install links:** "Install with FramePort" buttons on websites ([frameport.app](https://frameport.app) links) open
|
||||
in FramePort.
|
||||
- **Your Frame from your PC:** Type on Frame (your keyboard on the Frame), Files, Screenshots, Live view (what the
|
||||
headset shows, in your browser) and Monitor (frame rate, temperatures, battery, processes).
|
||||
- **Updates itself**, and reports problems without personal data.
|
||||
@@ -52,7 +54,7 @@ Details for each: [Install and first steps](docs/INSTALL.md).
|
||||
Konsole**, and run:
|
||||
|
||||
```
|
||||
curl -sL frameport.app/s | bash
|
||||
curl -fsSL https://frameport.app/s | bash
|
||||
```
|
||||
|
||||
4. Click **Allow** in FramePort when it shows the same 4-digit code as the Frame. Steam restarts once; if it asks to
|
||||
@@ -93,7 +95,7 @@ FrameDrop and Valve's own tools install an app as it is. FramePort differs in fo
|
||||
| In the Steam library | Yes, with artwork and tags | As a "Devkit Game" shortcut | As "Devkit Game: <title>"; not with adb |
|
||||
| Android apps, Linux apps, Windows programs | All three; Proton (runs Windows programs) is installed for you | All three; install Proton first | Yes; 2D Android apps need an extra file |
|
||||
| PC VR games | On your PC; some also on the Frame | Not supported | Not supported |
|
||||
| "Install with …" buttons on websites | Its own and FrameDrop's | FrameDrop's (it defined them) | No |
|
||||
| "Install with …" buttons on websites | Yes ([frameport.app](https://frameport.app) links) | Its own | No |
|
||||
| A game doesn't start | A launch test reads the logs and suggests a patch | A log viewer | No help |
|
||||
| Use the Frame from your PC | Live view, Monitor, Files, Screenshots, Type on Frame | Not stated | No |
|
||||
|
||||
@@ -101,7 +103,7 @@ FrameDrop and Valve's own tools install an app as it is. FramePort differs in fo
|
||||
|
||||
Other tools as described on their own pages, checked 2026-10-09:
|
||||
[FrameDrop about](https://framedropvr.com/about) · [how-to](https://framedropvr.com/how-to) ·
|
||||
[install buttons](https://framedropvr.com/docs) · [Valve: loading games on Steam Frame](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames).
|
||||
[Valve: loading games on Steam Frame](https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames).
|
||||
Out of date? [Report a problem](https://github.com/spoopyghosty0/frameport/issues/new).
|
||||
|
||||
## Built on
|
||||
|
||||
+409
-30
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 73
|
||||
AGENT_VERSION = 75
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -731,6 +731,12 @@ def ensure_host_fixes():
|
||||
entries = []
|
||||
if entries:
|
||||
changed.append(f"Desktop Mode entries ({len(entries)})")
|
||||
try:
|
||||
overlays = overlay_registrations()
|
||||
except Exception: # noqa: BLE001
|
||||
overlays = []
|
||||
if overlays:
|
||||
changed.append(f"SteamVR overlay apps registered ({len(overlays)})")
|
||||
return changed
|
||||
|
||||
|
||||
@@ -1134,7 +1140,9 @@ def game_running():
|
||||
names = run(["podman", "ps", "--format", "{{.Names}}"]).stdout.split()
|
||||
if any(n.startswith("lepton-steamlaunch-") for n in names):
|
||||
return True
|
||||
return any(pcvr_pids(d["base"]) for d in cmd_list_installed({})["games"] if d.get("kind") in ("pcvr", "linux"))
|
||||
# overlay apps (fpsVR & co.) run next to games, often all the time: they don't hold up installs
|
||||
return any(pcvr_pids(d["base"]) for d in cmd_list_installed({})["games"]
|
||||
if d.get("kind") in ("pcvr", "linux") and not is_overlay(d))
|
||||
|
||||
|
||||
def shortcuts_worker(payload):
|
||||
@@ -1455,6 +1463,18 @@ def deployment(pkg):
|
||||
return dep
|
||||
|
||||
|
||||
def write_deployment(pkg, dep):
|
||||
path = os.path.join(ANCHORS, pkg, "deployment.json")
|
||||
with open(path + ".tmp", "w") as f:
|
||||
json.dump(dep, f, indent=2)
|
||||
os.replace(path + ".tmp", path)
|
||||
|
||||
|
||||
def is_overlay(dep):
|
||||
"""An installed SteamVR overlay app (runs next to games: no launch test, not a "running game")."""
|
||||
return bool(isinstance(dep, dict) and dep.get("overlay"))
|
||||
|
||||
|
||||
def cmd_list_installed(args):
|
||||
games = []
|
||||
table = mounts()
|
||||
@@ -2283,6 +2303,284 @@ def cmd_take_screenshot(args):
|
||||
return {"taken": False, "path": None, "reason": reason, "hmd": hmd_state()}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ SteamVR overlay apps (v75)
|
||||
# OpenVR overlay applications (VRApplication_Overlay: fpsVR, wrist watches, ...) draw no scene; SteamVR's compositor
|
||||
# draws their overlays over whatever runs, Lepton (Quest) games and Proton (PC VR) games included (verified on the
|
||||
# device 2026-10-10, docs/FRAME_RUNTIME.md "Overlay apps"). FramePort installs one like a PC VR game (Proton) or a
|
||||
# Linux app and registers an application manifest with SteamVR (IVRApplications, live: SteamVR keeps the path in its
|
||||
# appconfig.json) whose binary is the app's launch.sh, so SteamVR can start it, also at every SteamVR start
|
||||
# ("autostart" = SteamVR's own auto-launch flag). The app's Steam shortcut still starts it from the library.
|
||||
OVERLAY_MANIFEST = "frameport-overlay.vrmanifest"
|
||||
VR_APP_UTILITY = 4
|
||||
# openvr_capi.h VR_IVRApplications_FnTable (IVRApplications_007) slots used here
|
||||
VR_APPS = {"AddApplicationManifest": 0, "RemoveApplicationManifest": 1, "IsApplicationInstalled": 2,
|
||||
"LaunchApplication": 6, "GetApplicationProcessId": 12, "GetApplicationsErrorNameFromEnum": 13,
|
||||
"SetApplicationAutoLaunch": 17, "GetApplicationAutoLaunch": 18}
|
||||
|
||||
|
||||
def overlay_key(pkg, app=None):
|
||||
"""The overlay's SteamVR application key: the app's own (from its bundled manifest, so registering it by hand
|
||||
too can't make a second entry), else "frameport.<package>"."""
|
||||
key = (app or {}).get("app_key")
|
||||
if isinstance(key, str) and re.fullmatch(r"[A-Za-z0-9_.-]{3,100}", key):
|
||||
return key
|
||||
return "frameport." + re.sub(r"[^a-z0-9_.-]", "_", pkg.lower())
|
||||
|
||||
|
||||
def overlay_app_dir(dep):
|
||||
"""Where the app's files are on the Frame (its bundled manifest's paths are relative to manifest_dir there)."""
|
||||
tree = "app" if dep.get("kind") == "linux" else "game"
|
||||
rel = os.path.normpath((dep.get("overlay") or {}).get("manifest_dir") or ".")
|
||||
if rel.startswith("..") or os.path.isabs(rel):
|
||||
rel = "."
|
||||
return os.path.normpath(os.path.join(dep["base"], tree, rel))
|
||||
|
||||
|
||||
def overlay_manifest(anchor, pkg, dep):
|
||||
"""SteamVR application manifest for an installed overlay app: the app's own bundled manifest entry (key, name,
|
||||
image) when it has one, with its binary replaced by FramePort's launch.sh (Proton or the Linux launcher, which
|
||||
set up the environment) and every path absolute."""
|
||||
title = dep.get("title") or pkg
|
||||
app = dict((dep.get("overlay") or {}).get("app") or {})
|
||||
src = overlay_app_dir(dep)
|
||||
image = app.get("image_path")
|
||||
image = os.path.join(src, image) if isinstance(image, str) and image else ""
|
||||
if not (image and os.path.isfile(image)):
|
||||
image = os.path.join(anchor, "artwork", "icon.png")
|
||||
strings = app.get("strings") if isinstance(app.get("strings"), dict) else \
|
||||
{"en_us": {"name": title, "description": f"{title} (installed by FramePort)"}}
|
||||
# SteamVR on the Frame (linuxarm64) only reads binary_path_linux_arm ("must specify binary_path for launch_type
|
||||
# binary. Skipping" for a manifest with binary_path_linux alone); both for SteamVR builds that read the other
|
||||
launcher = os.path.join(anchor, "launch.sh")
|
||||
entry = {"app_key": overlay_key(pkg, app), "launch_type": "binary",
|
||||
"binary_path_linux_arm": launcher, "binary_path_linux": launcher, "working_directory": anchor,
|
||||
"is_dashboard_overlay": True, "image_path": image, "strings": strings}
|
||||
if dep.get("kind") == "linux" and app.get("arguments"): # the Linux launcher passes them on ("$@")
|
||||
entry["arguments"] = app["arguments"]
|
||||
return {"source": "builtin", "applications": [entry]}
|
||||
|
||||
|
||||
class SteamVRApps:
|
||||
"""IVRApplications of the running SteamVR, through its libopenvr_api.so (ctypes) as a utility app (never starts
|
||||
SteamVR). `None` from open() = SteamVR isn't running."""
|
||||
|
||||
@classmethod
|
||||
def open(cls):
|
||||
import ctypes
|
||||
|
||||
lib = next((p for p in OPENVR_LIBS if os.path.isfile(p)), None)
|
||||
if lib is None:
|
||||
return None
|
||||
vr = ctypes.CDLL(lib)
|
||||
vr.VR_InitInternal2.restype = ctypes.c_uint32
|
||||
vr.VR_InitInternal2.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_char_p]
|
||||
vr.VR_GetGenericInterface.restype = ctypes.c_void_p
|
||||
vr.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
|
||||
vr.VR_IsRuntimeInstalled.restype = ctypes.c_bool
|
||||
vr.VR_ShutdownInternal.restype = None
|
||||
err = ctypes.c_int(0)
|
||||
vr.VR_InitInternal2(ctypes.byref(err), VR_APP_UTILITY, None)
|
||||
if err.value:
|
||||
return None
|
||||
ptr = vr.VR_GetGenericInterface(b"FnTable:IVRApplications_007", ctypes.byref(err))
|
||||
if not ptr:
|
||||
vr.VR_ShutdownInternal()
|
||||
return None
|
||||
self = cls()
|
||||
self.vr, self.ct = vr, ctypes
|
||||
self.table = (ctypes.c_void_p * 31).from_address(ptr)
|
||||
return self
|
||||
|
||||
def fn(self, name, restype, *argtypes):
|
||||
return self.ct.CFUNCTYPE(restype, *argtypes)(self.table[VR_APPS[name]])
|
||||
|
||||
def error(self, code):
|
||||
if not code:
|
||||
return None
|
||||
name = self.fn("GetApplicationsErrorNameFromEnum", self.ct.c_char_p, self.ct.c_int)(code)
|
||||
return (name or b"").decode() or str(code)
|
||||
|
||||
def add(self, path):
|
||||
return self.error(self.fn("AddApplicationManifest", self.ct.c_int, self.ct.c_char_p, self.ct.c_bool)(
|
||||
path.encode(), False))
|
||||
|
||||
def remove(self, path):
|
||||
return self.error(self.fn("RemoveApplicationManifest", self.ct.c_int, self.ct.c_char_p)(path.encode()))
|
||||
|
||||
def installed(self, key):
|
||||
return bool(self.fn("IsApplicationInstalled", self.ct.c_bool, self.ct.c_char_p)(key.encode()))
|
||||
|
||||
def set_autolaunch(self, key, on):
|
||||
return self.error(self.fn("SetApplicationAutoLaunch", self.ct.c_int, self.ct.c_char_p, self.ct.c_bool)(
|
||||
key.encode(), bool(on)))
|
||||
|
||||
def autolaunch(self, key):
|
||||
return bool(self.fn("GetApplicationAutoLaunch", self.ct.c_bool, self.ct.c_char_p)(key.encode()))
|
||||
|
||||
def launch(self, key):
|
||||
return self.error(self.fn("LaunchApplication", self.ct.c_int, self.ct.c_char_p)(key.encode()))
|
||||
|
||||
def pid(self, key):
|
||||
return int(self.fn("GetApplicationProcessId", self.ct.c_uint32, self.ct.c_char_p)(key.encode()))
|
||||
|
||||
def close(self):
|
||||
self.vr.VR_ShutdownInternal()
|
||||
|
||||
|
||||
def register_overlay(pkg, autostart=False):
|
||||
"""Write the overlay app's manifest next to its launcher and register it with SteamVR (live). Returns
|
||||
{key, manifest, registered, autostart, error}; registered False + error "steamvr" = SteamVR isn't running (the
|
||||
deployment keeps "overlay" and ensure_host_fixes registers it at the next connection)."""
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
path = os.path.join(anchor, OVERLAY_MANIFEST)
|
||||
manifest = overlay_manifest(anchor, pkg, dep)
|
||||
with open(path + ".tmp", "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
os.replace(path + ".tmp", path)
|
||||
key = manifest["applications"][0]["app_key"]
|
||||
out = {"key": key, "manifest": path, "registered": False, "autostart": False, "error": None}
|
||||
apps = SteamVRApps.open()
|
||||
if apps is None:
|
||||
out["error"] = "steamvr"
|
||||
return out
|
||||
try:
|
||||
out["error"] = apps.add(path)
|
||||
out["registered"] = apps.installed(key)
|
||||
if out["registered"]:
|
||||
out["error"] = apps.set_autolaunch(key, autostart) or out["error"]
|
||||
out["autostart"] = apps.autolaunch(key)
|
||||
finally:
|
||||
apps.close()
|
||||
return out
|
||||
|
||||
|
||||
def dep_overlay_key(pkg, dep=None):
|
||||
dep = dep if dep is not None else deployment(pkg)
|
||||
return overlay_key(pkg, ((dep or {}).get("overlay") or {}).get("app"))
|
||||
|
||||
|
||||
def unregister_overlay(pkg):
|
||||
"""Remove the overlay app's manifest from SteamVR (and the file). True when SteamVR was reached."""
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
path = os.path.join(anchor, OVERLAY_MANIFEST)
|
||||
key = None
|
||||
try:
|
||||
key = json.load(open(path))["applications"][0]["app_key"]
|
||||
except (OSError, ValueError, KeyError, IndexError, TypeError):
|
||||
key = dep_overlay_key(pkg)
|
||||
apps = SteamVRApps.open()
|
||||
reached = apps is not None
|
||||
if apps is not None:
|
||||
try:
|
||||
apps.set_autolaunch(key, False)
|
||||
apps.remove(path)
|
||||
finally:
|
||||
apps.close()
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return reached
|
||||
|
||||
|
||||
def overlay_entry(app):
|
||||
"""The overlay application entry of an app's bundled .vrmanifest (validated; None if there is none)."""
|
||||
if not isinstance(app, dict):
|
||||
return None
|
||||
keep = {k: app[k] for k in ("app_key", "image_path", "strings", "arguments") if k in app}
|
||||
if not isinstance(keep.get("image_path", ""), str) or not isinstance(keep.get("arguments", ""), str):
|
||||
return None
|
||||
return keep
|
||||
|
||||
|
||||
def cmd_register_vr_overlay(args):
|
||||
"""Register an installed app as a SteamVR overlay app: {package, autostart, app (its bundled manifest's overlay
|
||||
entry, optional), manifest_dir (that manifest's folder in the app's files)} → register_overlay()'s result. The
|
||||
deployment records it (overlay apps skip launch tests and don't count as a running game)."""
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
old = dep.get("overlay") or {}
|
||||
app = overlay_entry(args["app"]) if "app" in args else old.get("app")
|
||||
dep["overlay"] = {"autostart": bool(args.get("autostart")), "app": app,
|
||||
"manifest_dir": args.get("manifest_dir", old.get("manifest_dir")) or ".", "registered": False}
|
||||
dep["overlay"]["key"] = overlay_key(pkg, app)
|
||||
if old.get("key") and old["key"] != dep["overlay"]["key"]:
|
||||
unregister_overlay(pkg)
|
||||
write_deployment(pkg, dep)
|
||||
out = register_overlay(pkg, bool(args.get("autostart")))
|
||||
dep["overlay"]["registered"] = out["registered"]
|
||||
write_deployment(pkg, dep)
|
||||
return out
|
||||
|
||||
|
||||
def cmd_unregister_vr_overlay(args):
|
||||
pkg = check_pkg(args["package"])
|
||||
reached = unregister_overlay(pkg)
|
||||
dep = deployment(pkg)
|
||||
if dep and "overlay" in dep:
|
||||
dep.pop("overlay")
|
||||
write_deployment(pkg, dep)
|
||||
return {"unregistered": True, "steamvr": reached}
|
||||
|
||||
|
||||
def cmd_launch_vr_overlay(args):
|
||||
"""Start an installed overlay app through SteamVR (LaunchApplication of its manifest), not as a Steam game, so a
|
||||
game can be played next to it. {started, pid, error}."""
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg)
|
||||
if not dep or not dep.get("overlay"):
|
||||
raise AgentError(f"{pkg} is not installed as an overlay app")
|
||||
if not dep["overlay"].get("registered"):
|
||||
register_overlay(pkg, dep["overlay"].get("autostart"))
|
||||
apps = SteamVRApps.open()
|
||||
if apps is None:
|
||||
raise AgentError("SteamVR isn't running on the Frame")
|
||||
try:
|
||||
key = dep_overlay_key(pkg, dep)
|
||||
if not apps.installed(key):
|
||||
apps.add(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST))
|
||||
err = apps.launch(key)
|
||||
pid = 0
|
||||
for _ in range(20):
|
||||
pid = apps.pid(key)
|
||||
if pid or err:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
finally:
|
||||
apps.close()
|
||||
return {"started": not err, "pid": pid or None, "error": err}
|
||||
|
||||
|
||||
def overlay_registrations():
|
||||
"""Register overlay apps whose registration is missing (SteamVR wasn't running at install, or forgot it)."""
|
||||
done = []
|
||||
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
|
||||
try:
|
||||
dep = json.load(open(dep_path))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
ov = dep.get("overlay") if isinstance(dep, dict) else None
|
||||
if not ov or not dep.get("package"):
|
||||
continue
|
||||
apps = SteamVRApps.open()
|
||||
if apps is None:
|
||||
return done
|
||||
try:
|
||||
ok = apps.installed(dep_overlay_key(dep["package"], dep))
|
||||
finally:
|
||||
apps.close()
|
||||
if not ok:
|
||||
r = register_overlay(dep["package"], ov.get("autostart"))
|
||||
if r["registered"]:
|
||||
ov["registered"] = True
|
||||
write_deployment(dep["package"], dep)
|
||||
done.append(dep["package"])
|
||||
return done
|
||||
|
||||
|
||||
def cmd_prepare(args):
|
||||
"""Where to upload, and what the Frame already has (so unchanged data is not re-sent)."""
|
||||
pkg = check_pkg(args["package"])
|
||||
@@ -2776,7 +3074,7 @@ def upgrade_linux_launcher(text):
|
||||
text = text.replace(old_if, 'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then', 1)
|
||||
if "\nparent=$PPID\n" in text:
|
||||
text = text.replace("\nparent=$PPID\n",
|
||||
'\nparent=$PPID\n[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1\n', 1)
|
||||
'\nparent=$PPID\n[[ -n "${FRAMEPORT_DESKTOP:-}${FRAMEPORT_OVERLAY:-}" ]] && parent=1\n', 1)
|
||||
return text
|
||||
|
||||
|
||||
@@ -3579,7 +3877,19 @@ def cmd_finalize_pcvr(args):
|
||||
"agent_version": AGENT_VERSION, "time": time.time()}
|
||||
with open(os.path.join(anchor, "deployment.json"), "w") as f:
|
||||
json.dump(dep, f, indent=2)
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "proton": tool["name"]}
|
||||
overlay = finalize_overlay(pkg, args.get("overlay"))
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "proton": tool["name"], "overlay": overlay}
|
||||
|
||||
|
||||
def finalize_overlay(pkg, overlay):
|
||||
"""An install's overlay choice ({autostart, app, manifest_dir} = a SteamVR overlay app, None = not one):
|
||||
register or unregister it with SteamVR. Returns register_vr_overlay's result, or None."""
|
||||
if overlay:
|
||||
return cmd_register_vr_overlay({"package": pkg, "autostart": bool(overlay.get("autostart")),
|
||||
"app": overlay.get("app"), "manifest_dir": overlay.get("manifest_dir")})
|
||||
if os.path.exists(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST)): # no longer one (the user turned it off)
|
||||
cmd_unregister_vr_overlay({"package": pkg})
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Linux apps (arm64)
|
||||
@@ -3603,8 +3913,9 @@ if [[ -z "${{FRAMEPORT_DESKTOP:-}}" && -z "${{DISPLAY:-}}" ]]; then
|
||||
fi
|
||||
fi
|
||||
{extra_env}parent=$PPID
|
||||
# Desktop Mode's launcher exits right after starting this: no Steam parent to watch
|
||||
[[ -n "${{FRAMEPORT_DESKTOP:-}}" ]] && parent=1
|
||||
# Desktop Mode's launcher exits right after starting this: no Steam parent to watch; SteamVR overlay apps
|
||||
# (FRAMEPORT_OVERLAY) are started by SteamVR and run next to games
|
||||
[[ -n "${{FRAMEPORT_DESKTOP:-}}${{FRAMEPORT_OVERLAY:-}}" ]] && parent=1
|
||||
{exe_q} "$@" >{log_q} 2>&1 &
|
||||
child=$!
|
||||
trap 'kill -TERM $child 2>/dev/null' INT TERM
|
||||
@@ -3736,7 +4047,10 @@ def cmd_finalize_linux(args):
|
||||
# ldd can't read x86_64 programs here: their libraries come from FEX's x86 root (/usr/share/guestos/fex-mesa)
|
||||
missing = [] if x86 else missing_libraries(app, appimage_programs(run_dir) if appimage else [exe])
|
||||
os.makedirs(anchor, exist_ok=True)
|
||||
write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, args.get("env"))
|
||||
env = dict(args.get("env") or {})
|
||||
if args.get("overlay"):
|
||||
env["FRAMEPORT_OVERLAY"] = "1"
|
||||
write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, env)
|
||||
art_in = os.path.join(base, "incoming-artwork")
|
||||
if os.path.isdir(art_in):
|
||||
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
|
||||
@@ -3762,8 +4076,9 @@ def cmd_finalize_linux(args):
|
||||
remove_desktop_entries(pkg)
|
||||
except OSError:
|
||||
pass
|
||||
overlay = finalize_overlay(pkg, args.get("overlay"))
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "missing_libraries": missing,
|
||||
"desktop_entry": desktop, "app_icon": app_icon_result(own_icon)}
|
||||
"desktop_entry": desktop, "app_icon": app_icon_result(own_icon), "overlay": overlay}
|
||||
|
||||
|
||||
def write_linux_launcher(anchor, base, pkg, title, exe_rel, appimage, prefix, env):
|
||||
@@ -4259,6 +4574,8 @@ def cmd_uninstall(args):
|
||||
linux = dep.get("kind") == "linux"
|
||||
if (pcvr_pids(base) if pcvr or linux else container_running(dep["appid"])):
|
||||
raise AgentError("the game is running")
|
||||
if is_overlay(dep) or os.path.exists(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST)):
|
||||
unregister_overlay(pkg) # SteamVR forgets the overlay app (before its manifest's folder goes)
|
||||
keep_data = args.get("keep_data", True)
|
||||
names = ("game", "revive", "xrlayer", "shadercache", "incoming", "incoming-artwork") if pcvr else \
|
||||
("app", "incoming", "incoming-artwork", "launch.log", "session.log") if linux else \
|
||||
@@ -4713,6 +5030,9 @@ def cmd_launch_test(args):
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
log = os.path.join(dep["base"], "launch.log")
|
||||
appid = dep["appid"]
|
||||
if is_overlay(dep): # nothing to see without a game; register_vr_overlay's SteamVR check is the test
|
||||
return {"state": "SKIPPED", "skipped": "overlay app", "elapsed": 0, "log": None, "log_size": 0,
|
||||
"crash_log": None, "kind": dep.get("kind")}
|
||||
if dep.get("kind") == "pcvr":
|
||||
return launch_test_pcvr(dep, anchor, log, seconds)
|
||||
if dep.get("kind") == "linux":
|
||||
@@ -5017,14 +5337,54 @@ def _tail(path, max_bytes):
|
||||
|
||||
# shader dumps in a game's files dir: the Vulkan shim's vk_shader_dump and the shader-fix layer's zink_shader_dump
|
||||
SHADER_DUMP_DIRS = ("fp_vk_shaders", "fp_spirv")
|
||||
SHADER_DUMP_BYTES = 4 << 20 # newest modules per diagnostics run, base64 on the wire
|
||||
SHADER_DUMP_BYTES = 4 << 20 # without an index: newest modules per folder (by time written), base64 on the wire
|
||||
SHADER_DUMP_FILES = 200
|
||||
SHADER_SESSION_BYTES = 30 << 20 # with an index: the newest session's modules, up to this much in all
|
||||
SHADER_MODULE_BYTES = 4 << 20 # larger modules are left out
|
||||
SHADER_INDEX_READ = 64 << 20 # index.txt bytes looked at (from the end) for the newest session
|
||||
SHADER_INDEX_BYTES = 4 << 20 # index text sent (the newest session's lines)
|
||||
_SPV_NAME = re.compile(r"\d+_[0-9a-f]{64}\.spv")
|
||||
|
||||
|
||||
def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES):
|
||||
"""{dir: {"index": tail of index.txt, "modules": {name: base64}, "total": n, "skipped": n}} for the dump folders
|
||||
in a game's files dir: the newest modules (by time written) up to max_bytes, so the shader created right before a
|
||||
GPU hang comes along. Unreadable files are skipped (the app writes them inside its container)."""
|
||||
def shader_session(index_path):
|
||||
"""(index text from the newest "# start" line on, [module names of that session, last used first]) from a
|
||||
vk_shader_dump index.txt (`<seq> <ms> <unix ms> <size>_<sha256>.spv new|known|again|failed` lines, one
|
||||
"# start <unix s> pid <pid>" per process), or None without an index or a module line in its newest session."""
|
||||
try:
|
||||
size = os.path.getsize(index_path)
|
||||
with open(index_path, "rb") as f:
|
||||
if size > SHADER_INDEX_READ:
|
||||
f.seek(size - SHADER_INDEX_READ)
|
||||
text = f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return None
|
||||
lines = text.splitlines()
|
||||
first = max((i for i, ln in enumerate(lines) if ln.startswith("# start")), default=0)
|
||||
session = lines[first:]
|
||||
names, seen = [], set()
|
||||
for ln in reversed(session):
|
||||
parts = ln.split()
|
||||
if len(parts) >= 4 and _SPV_NAME.fullmatch(parts[3]) and parts[3] not in seen:
|
||||
seen.add(parts[3])
|
||||
names.append(parts[3])
|
||||
if not names:
|
||||
return None
|
||||
body = "\n".join(session) + "\n"
|
||||
if len(body) > SHADER_INDEX_BYTES:
|
||||
body = f"[... first {len(body) - SHADER_INDEX_BYTES} characters cut ...]\n" + body[-SHADER_INDEX_BYTES:]
|
||||
elif first > 0 or size > SHADER_INDEX_READ:
|
||||
body = "[... earlier sessions left out ...]\n" + body
|
||||
return body, names
|
||||
|
||||
|
||||
def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES, session_bytes=SHADER_SESSION_BYTES):
|
||||
"""{dir: {"index": index text, "modules": {name: base64}, "total": n, "skipped": n[, "session": n]}} for the
|
||||
dump folders in a game's files dir. With an index.txt naming modules (vk_shader_dump): every module named in its
|
||||
newest session (= process start), last used first, each <= SHADER_MODULE_BYTES, up to session_bytes in all
|
||||
("session" = how many it names). A GPU hang can come seconds after the culprit was created, so "newest written"
|
||||
missed it (GitHub #140). Without one (fp_spirv): the newest modules (by time written) up to max_bytes. Unreadable
|
||||
files are skipped (the app writes them inside its container). The reply is one JSON line on the SSH channel's
|
||||
stdout (~40 MB base64 at most): the PC reads it whole, which is fine at that size."""
|
||||
out = {}
|
||||
for d in SHADER_DUMP_DIRS:
|
||||
path = os.path.join(files_dir, d)
|
||||
@@ -5032,27 +5392,38 @@ def shader_dumps(files_dir, max_bytes=SHADER_DUMP_BYTES):
|
||||
names = [n for n in os.listdir(path) if n.endswith(".spv")]
|
||||
except OSError:
|
||||
continue
|
||||
mods = []
|
||||
for n in names:
|
||||
try:
|
||||
mods.append((os.path.getmtime(os.path.join(path, n)), n))
|
||||
except OSError:
|
||||
pass
|
||||
mods.sort(reverse=True)
|
||||
res = {"total": len(names), "modules": {}, "skipped": 0}
|
||||
index = _tail(os.path.join(path, "index.txt"), 1 << 20)
|
||||
if index is not None:
|
||||
res["index"] = index
|
||||
session = shader_session(os.path.join(path, "index.txt"))
|
||||
if session is not None:
|
||||
res["index"], order = session
|
||||
res["session"] = len(order)
|
||||
budget, per_module = session_bytes, SHADER_MODULE_BYTES
|
||||
else:
|
||||
index = _tail(os.path.join(path, "index.txt"), 1 << 20)
|
||||
if index is not None:
|
||||
res["index"] = index
|
||||
mods = []
|
||||
for n in names:
|
||||
try:
|
||||
mods.append((os.path.getmtime(os.path.join(path, n)), n))
|
||||
except OSError:
|
||||
pass
|
||||
mods.sort(reverse=True)
|
||||
order = [n for _, n in mods[:SHADER_DUMP_FILES]]
|
||||
budget, per_module = max_bytes, max_bytes
|
||||
used = 0
|
||||
for _, n in mods[:SHADER_DUMP_FILES]:
|
||||
for n in order:
|
||||
try:
|
||||
with open(os.path.join(path, n), "rb") as f:
|
||||
data = f.read(max_bytes - used + 1)
|
||||
data = f.read(min(per_module, budget - used) + 1)
|
||||
except OSError:
|
||||
res["skipped"] += 1
|
||||
continue
|
||||
if used + len(data) > max_bytes:
|
||||
break
|
||||
if used + len(data) > budget or len(data) > per_module:
|
||||
if session is None:
|
||||
break
|
||||
res["skipped"] += 1 # too big: a smaller, older one may still fit
|
||||
continue
|
||||
used += len(data)
|
||||
res["modules"][n] = base64.b64encode(data).decode("ascii")
|
||||
out[d] = res
|
||||
@@ -5139,7 +5510,8 @@ def cmd_collect_diag(args):
|
||||
for p in sorted(cands, key=lambda p: next((i for i, k in enumerate(order) if k in os.path.basename(p)), 9)):
|
||||
name = os.path.basename(p)
|
||||
files[name if name.startswith("logcat") else "logcat-" + name] = _tail(p, max_bytes)
|
||||
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)))
|
||||
shaders = shader_dumps(data_files_dir(base, pkg), int(args.get("shader_bytes", SHADER_DUMP_BYTES)),
|
||||
int(args.get("shader_session_bytes", SHADER_SESSION_BYTES)))
|
||||
if shaders:
|
||||
out["shaders"] = shaders
|
||||
try:
|
||||
@@ -5181,6 +5553,13 @@ def purge_worker(payload):
|
||||
try:
|
||||
games = cmd_list_installed({})["games"]
|
||||
users = steam_users()
|
||||
for d in games: # SteamVR overlay apps: SteamVR forgets their manifests (while it still runs)
|
||||
if is_overlay(d) or os.path.exists(os.path.join(ANCHORS, d["package"], OVERLAY_MANIFEST)):
|
||||
try:
|
||||
unregister_overlay(d["package"])
|
||||
result["removed"].append(f"SteamVR overlay: {d.get('title')}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{d.get('title')}: {exc}")
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError as exc:
|
||||
@@ -5599,7 +5978,7 @@ MON_INTERVALS = (0.1, 0.25, 0.5, 1, 2, 5) # seconds between samples; processes
|
||||
MON_DEFAULT_INTERVAL = 0.5
|
||||
SCAN_SECONDS = 2.0
|
||||
MON_FILTERS = ("game", "steam", "all")
|
||||
# what a client can ask for ({"modules": [...]}, agent v65); without that message every module is collected
|
||||
# what a client can ask for ({"modules": [...]}, agent v73); without that message every module is collected
|
||||
MON_MODULES = ("games", "procs", "cpu", "gpu", "mem", "temps", "power", "battery", "net", "disk")
|
||||
MON_PROC_LIMIT = 150
|
||||
MON_CONTEXT = 3 # "game" filter: the busiest other processes, shown for context
|
||||
@@ -6330,7 +6709,7 @@ def monitor_end_game(pkg, wait=6.0):
|
||||
|
||||
def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=None):
|
||||
"""Long-lived: prints {"ready": 1, "static": {...}}, then one sample per tick. Control lines on stdin:
|
||||
{"interval": 1|2|5}, {"procs": "game"|"steam"|"all"}, {"pause": bool}, {"modules": [...]|"all"} (v63: only those
|
||||
{"interval": 1|2|5}, {"procs": "game"|"steam"|"all"}, {"pause": bool}, {"modules": [...]|"all"} (v73: only those
|
||||
collectors run, see monitor_plan), {"id": n, "kill": pid, "sig": "TERM"|"KILL", "force": bool}, {"id": n,
|
||||
"end_game": package} (each with an id gets {"reply": n, "ok": …}). Ends at EOF or when the SSH session (parent)
|
||||
is gone."""
|
||||
|
||||
+13
-5
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# FramePort setup from the project page: the same for every Frame and every PC, nothing to copy from the PC. Run in
|
||||
# the Frame's desktop terminal (SteamVR dashboard -> Launch a program -> Desktop, then System -> Konsole):
|
||||
# curl -sL frameport.app/s | bash
|
||||
# curl -fsSL https://frameport.app/s | bash
|
||||
# with FramePort open on your PC at Steam Frame -> Connect. Options: --pc <address[:port]> (skip the search).
|
||||
#
|
||||
# What it does:
|
||||
@@ -201,15 +201,23 @@ fi
|
||||
|
||||
nonce=$(python3 -c 'import secrets; print(secrets.token_hex(12))')
|
||||
digits=$(python3 -c 'import hashlib, sys; print(f"{int(hashlib.sha256(sys.argv[1].encode()).hexdigest()[:8], 16) % 10000:04d}")' "$nonce")
|
||||
reply=$(curl -fsS -G --data-urlencode "host=$(hostname)" --data-urlencode "nonce=$nonce" "http://$PC/hello") || {
|
||||
echo "FramePort at $PC didn't answer. Is it still open at Steam Frame → Start setup?"; exit 1; }
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
status=$(curl -sS -o "$tmp" -w '%{http_code}' -G --data-urlencode "host=$(hostname)" --data-urlencode "nonce=$nonce" \
|
||||
"http://$PC/hello") || true
|
||||
case $status in
|
||||
200) reply=$(cat "$tmp") ;;
|
||||
429) echo "FramePort at $PC already has several setup requests waiting. Allow or deny them there (requests from"
|
||||
echo "setups that ended go away by themselves within a few minutes), then run this again."; exit 1 ;;
|
||||
000|"") echo "FramePort at $PC didn't answer. Is it still open at Steam Frame → Start setup?"; exit 1 ;;
|
||||
*) echo "FramePort at $PC refused the request (HTTP $status). Run this again, or run the setup command FramePort shows."
|
||||
exit 1 ;;
|
||||
esac
|
||||
id=$(python3 -c 'import json, sys; print(json.loads(sys.argv[1])["id"])' "$reply")
|
||||
WORDS=$(python3 -c 'import json, sys; print(json.loads(sys.argv[1]).get("words", ""))' "$reply")
|
||||
|
||||
say "FramePort on your PC asks to set up this Frame"
|
||||
printf ' Click Allow there if it shows \033[1;33m%s\033[0m (PC: %s)\n' "$digits" "${WORDS:-?}"
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
code=""
|
||||
for _ in $(seq 12); do # up to about 20 minutes
|
||||
status=$(curl -sS -o "$tmp" -w '%{http_code}' -m 115 "http://$PC/wait?id=$id" || echo 000)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package: com.fyian.TheThrillOfTheFight
|
||||
title: The Thrill Of The Fight
|
||||
status: issues
|
||||
notes: Right eye doesn't draw the opponent or the referee.
|
||||
tested_version: '251113.0'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
- frame.unity_no_overlay_copy
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: af86dfbd6f7d3ab504d1f4f92f4dbc4c0f0cb36e30443b80fa0b91322f481394
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 164
|
||||
source_hint: ThrillOfTheFight_Release
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.newfoldergames.fruitsalon
|
||||
title: Fruit Salon
|
||||
status: works
|
||||
tested_version: 1.0.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 1c2e6e44e046e1683670a6b0f97e7766fd5edd0c888443910ce3c8e2291a9865
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 165
|
||||
source_hint: Fruit Salon
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.owlchemylabs.vacationsimulator
|
||||
title: Vacation Simulator
|
||||
status: issues
|
||||
notes: Controllers aren't tracked (reported; not yet diagnosed).
|
||||
tested_version: 1.3.0.40142
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
refresh_rate: 90.0
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: b02e0a24292a8305ca752fef1884356f0191b53793d356b999cd50bba3fe7a3c
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 163
|
||||
source_hint: com.owlchemylabs.vacationsimulator
|
||||
+14
-30
@@ -1,20 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
```
|
||||
┌──────── UI (Flet, ui/: app shell + views/) ─────┐ ┌── CLI (cli.py) ──┐
|
||||
└───────────────────────┬─────────────────────────┘ └────────┬─────────┘
|
||||
▼ ▼
|
||||
pipeline.py (add → suggest → build → install → test)
|
||||
┌──────────────┬──────────────┼───────────────┬───────────────┬──────────────┐
|
||||
sources/ analysis/ recommend/ build.py targets/ validate/
|
||||
quest_dump detect, elf catalog, engine overport → base.Target static, device,
|
||||
rift_dump stubgen, rift (+ catalog/*.yaml) patches(apk) → frame_lepton triage (+triage.yaml)
|
||||
apk/sign pc_revive
|
||||
│ │ │
|
||||
patches/ registry tools/ toolchain frame/ ssh, discovery, pairing
|
||||
overport, frame/*, (JRE, overport, install/installer ──► agent (on Frame)
|
||||
settings apksigner)
|
||||
```
|
||||

|
||||
|
||||
## Data flow for one game
|
||||
1. **Source** (`sources/quest_dump.py`): folder with an APK and optional `<package>/` or `obb/` data.
|
||||
@@ -65,21 +51,19 @@ apart. "Build" = `pipeline.prepare_rift` (checks + Revive). Revive is a portable
|
||||
run `frameport parity` to see which games change.
|
||||
|
||||
## Self-update (`updates.py`, `ui/updater.py`, `cli.py update`)
|
||||
```
|
||||
check() GitHub releases/latest (cached 6 h, drafts/prereleases skipped, "skipped" version remembered)
|
||||
│ → Update(version, notes = release body incl. "What's new" from the annotated tag, asset for this OS, sums, wheel)
|
||||
▼
|
||||
install_kind() bundle (running exe inside FramePort.exe's folder / FramePort.app / FramePort/FramePort)
|
||||
│ source (git checkout) → git pull --ff-only + uv sync wheel (uv tool / pipx / pip) → reinstall wheel
|
||||
▼ bundle
|
||||
prepare() <data>/updates/<ver>/: download → SHA256SUMS.txt check → extract to staged/ (ditto on macOS) → layout check
|
||||
│ → Windows: same Authenticode signer as the running exe → ready.json
|
||||
▼
|
||||
apply() writes <data>/updates/apply.{ps1,sh}, starts it detached; FramePort quits. The script waits for the pid,
|
||||
Windows: backs up the files it replaces (updates/<ver>/previous) and copies over the folder (the zip has
|
||||
no folder of its own); macOS/Linux: mv target → .old, staged → target, rollback on failure, clears the
|
||||
quarantine; then relaunches. Log: <data>/logs/update.log.
|
||||
```
|
||||

|
||||
|
||||
- `check()`: GitHub releases/latest, cached 6 h → `Update(version, notes = the release body incl. "What's new" from
|
||||
the annotated tag, the asset for this OS, sums, wheel)`.
|
||||
- `install_kind()`: **bundle** = the running exe sits in FramePort.exe's folder / FramePort.app / FramePort/FramePort;
|
||||
**source** = a git checkout (`git pull --ff-only` + `uv sync`); **wheel** = uv tool / pipx / pip (reinstalls the
|
||||
release's wheel).
|
||||
- `prepare()` (bundle): `<data>/updates/<ver>/`: download → SHA256SUMS.txt check → extract to `staged/` (ditto on
|
||||
macOS) → layout check → on Windows the same Authenticode signer as the running exe → `ready.json`.
|
||||
- `apply()`: writes `<data>/updates/apply.{ps1,sh}` and starts it detached; FramePort quits. The script waits for the
|
||||
pid. Windows: backs up the files it replaces (`updates/<ver>/previous`) and copies over the folder (the zip has no
|
||||
folder of its own). macOS/Linux: `mv` target → `.old`, staged → target, rollback on failure, clears the quarantine.
|
||||
Then it relaunches FramePort. Log: `<data>/logs/update.log`.
|
||||
GUI: background check 10 s after start + every 6 h → sidebar card + Library bar → dialog with notes → job
|
||||
`app-update` (waits for other jobs) → restart. "Install updates automatically": prepare in the background, apply in
|
||||
`main()` before the window opens. CLI: once-a-day hint from the cached result (a daemon thread refreshes it), `frameport
|
||||
|
||||
+6
-3
@@ -44,9 +44,12 @@ games/<pkg>/target/shaders/ shader dumps (agent v72), when the game wrote any:
|
||||
fp_vk_shaders/ adapter vk_shader_dump=1 (Vulkan shim): <size>_<sha256>.spv + index.txt
|
||||
fp_spirv/ adapter zink_shader_dump=1 (OpenGL ES, shader-fix layer under Zink): <size>_<sha256>.spv
|
||||
```
|
||||
Each log keeps at most its last 4 MB (2 MB per file from the Frame). Shader dumps: the newest modules (by time
|
||||
written) up to 4 MB per folder, SPIR-V unchanged (compiled game shaders, not redacted); a module the game created
|
||||
for the first time right before a GPU hang is among them. `index.txt` (Vulkan) lists every vkCreateShaderModule as
|
||||
Each log keeps at most its last 4 MB (2 MB per file from the Frame). Shader dumps, SPIR-V unchanged (compiled game
|
||||
shaders, not redacted): Vulkan (agent v74) every module the game's newest run (the last `# start` in `index.txt`)
|
||||
created, last used first, each up to 4 MB and up to 30 MB in all, with that run's index lines, so a module created
|
||||
well before a GPU hang comes along too (if the zip would pass 24 MB, the least recently used ones are left out
|
||||
after the logs were cut); OpenGL ES (no index): the newest modules (by time written) up to 4 MB.
|
||||
`index.txt` (Vulkan) lists every vkCreateShaderModule as
|
||||
`<seq> <ms since the first module> <unix ms> <size>_<sha256>.spv new|known|again|failed`; match the unix time with
|
||||
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).
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
Some Quest games come as an APK (the app file) plus `.obb` files (the game's data). Give every game its own folder,
|
||||
put the APK in it and put the data in a folder named after the game's **package name** (for example `com.Armature.VR4`):
|
||||
|
||||
```
|
||||
```tree
|
||||
Games/ ← scan this folder (Add games → Scan a folder)
|
||||
├── Resident Evil 4/ ← one folder per game (any name)
|
||||
│ ├── VR4.apk ← the APK (any file name)
|
||||
|
||||
@@ -178,6 +178,48 @@
|
||||
sha256, no `.picked`); folder apps get it on the PC at add time (`analysis/linux.find_icon`). Not yet seen in
|
||||
Desktop Mode on the device.
|
||||
|
||||
## Overlay apps (SteamVR / OpenVR overlays, checked 2026-10-10 on the dev Frame, SteamOS 0.4.5)
|
||||
OpenVR overlay applications (`VRApplication_Overlay`: fpsVR, wrist watches, ...) work on the Frame itself, also
|
||||
over Quest games.
|
||||
- The Frame's host SteamVR (`/opt/steamvr/bin/linuxarm64`, always running in the VR session) serves `IVROverlay`
|
||||
010-028 / `IVRApplications_007` to native Linux arm64 clients through its own `libopenvr_api.so` (ctypes is
|
||||
enough: `native/vroverlay_probe/vroverlay_probe.py`). Overlay apps connect from SSH; vrserver logs
|
||||
`New Connect message from … (VRApplication_Overlay)`, `vrcmd --overlays` (`/opt/steamvr/bin/linuxarm64/vrcmd`,
|
||||
LD_LIBRARY_PATH=that dir) lists them `visible`, Steam's VR UI logs `[Overlays] Created: <key>` and loads a
|
||||
dashboard overlay's thumbnail.
|
||||
- **Composited over Lepton (Quest) games**: with 4XVR running headless (FrameBridge 72 fps), the probe's head-locked
|
||||
overlay appeared in the headset view (`/dev/video99`, see "Video of the headset view") on top of 4XVR's theatre.
|
||||
The Android SteamVR runtime inside Lepton submits to the same host compositor, which draws host overlays on top.
|
||||
- Seeing pixels without a worn headset: the compositor pauses in standby (headset view = black) and fades to a
|
||||
solid colour without tracking. For a few seconds after setting `power/pauseCompositorOnStandby` and
|
||||
`steamvr/forceFadeOnBadTracking` to false (IVRSettings; neither key is in the user's steamvr.vrsettings, so
|
||||
`RemoveKeyInSection` restores the default) the headset view shows the real composition (2 fps grabs: frames 7-10
|
||||
of 16 had the picture, the rest the fade colour). Restore both keys afterwards.
|
||||
- **Windows overlay apps under Proton (ARM64)**: Temporal Reality's Windows build (Python/pyopenvr, x64) under
|
||||
Proton 11 (SteamGameId set, FramePort's timefix layer) created its overlays on the Frame's SteamVR (`vrcmd
|
||||
--overlays`: `temporalreality.watch`, `.settings` dashboard + 512x512 thumbnail), so Proton's vrclient bridges
|
||||
`VRApplication_Overlay` too. Not seen as pixels (that watch only shows on a tracked left controller). fpsVR (.NET,
|
||||
wine-mono) ran 60 s under Proton (SteamAPI ok: "Game process added: AppID 908520") but never loaded
|
||||
openvr_api.dll and quit by itself (presumably it waits for a Windows SteamVR process). A freestanding CRT-less x64
|
||||
exe (`native/vroverlay_probe/fp_vroverlay_probe.c`) died at its first kernel32 call (`c000001d` in the x64
|
||||
emulation thunk); a normal MSVC/MinGW build should be used for Windows probes.
|
||||
- **Registration** (what SteamVR honours on the Frame): `IVRApplications::AddApplicationManifest(path, false)` from
|
||||
a utility client, live; the path is kept in `~/.config/openvr/config/appconfig.json` `manifest_paths`.
|
||||
`SetApplicationAutoLaunch` is accepted (GetApplicationAutoLaunch → true; vrserver has
|
||||
`CAppInfoManager::StartAutolaunchOverlays`) but wasn't written to `steamvr.vrsettings` right away; whether it
|
||||
starts the app at the next SteamVR start is **not yet verified**. The linuxarm64 vrserver only reads
|
||||
**`binary_path_linux_arm`**: a manifest with `binary_path_linux` alone is skipped ("must specify binary_path for
|
||||
launch_type binary. Skipping"; Steam's own steamapps.vrmanifest entries are skipped the same way), so an app's
|
||||
own Linux manifest/`--install` that only writes binary_path_linux can't be launched by SteamVR on the Frame.
|
||||
`LaunchApplication(key)` then starts the binary (Temporal Reality's launch.sh: process up, overlays created).
|
||||
- A Steam shortcut of an overlay app and a game shortcut run at the same time (Temporal Reality, then 4XVR, both
|
||||
through `steam://rungameid`: both "Game process added", both kept running).
|
||||
- FramePort (agent v75): `register_vr_overlay` writes `<anchor>/frameport-overlay.vrmanifest` (the app's own key,
|
||||
name and image from its bundled manifest, binary = launch.sh in `binary_path_linux_arm` + `binary_path_linux`,
|
||||
absolute paths) and registers it; `unregister_vr_overlay`, uninstall and purge remove it; `ensure_host_fixes`
|
||||
registers ones SteamVR missed (it wasn't running). Overlay apps skip launch tests, the Linux launcher's
|
||||
Steam-parent watchdog (`FRAMEPORT_OVERLAY=1`) and don't count as a running game.
|
||||
|
||||
## Video of the headset view (surveyed 2026-10-05; used by the Live view tab)
|
||||
- `steamvr-v4l2cam.service` (user unit, part of gamescope-session.target, `Restart=always`) runs SteamVR's
|
||||
`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`: it reads the compositor's "Headset View" (IVRHeadsetView) and
|
||||
|
||||
+9
-5
@@ -36,7 +36,7 @@ The Frame and your PC must be on the same network (or connected with a [USB cabl
|
||||
3. Type this setup line and press **Enter** (on-screen keyboard or any USB or Bluetooth keyboard):
|
||||
|
||||
```
|
||||
curl -sL frameport.app/s | bash
|
||||
curl -fsSL https://frameport.app/s | bash
|
||||
```
|
||||
|
||||
No keyboard? Open [the setup page](https://frameport.app/setup/) in Chromium on the Frame,
|
||||
@@ -119,16 +119,15 @@ refresh rate, controllers, menus, 360° video and mixed reality. Changes are use
|
||||
- **Add games → Add a Windows program…** adds a single Windows program. The Frame runs it as a window through Proton
|
||||
(Valve's tool for running Windows programs).
|
||||
|
||||
### Install links ("Install with FrameDrop" buttons)
|
||||
### Install links ("Install with FramePort" buttons)
|
||||
|
||||
Some websites have an **Install with FrameDrop** button. FramePort understands these buttons too:
|
||||
Some websites have an **Install with FramePort** button (a `https://frameport.app/install?…` link):
|
||||
|
||||
- **Click a button** on Windows or Linux: FramePort opens, shows what it would download and asks first. Then it adds
|
||||
the game and installs it on the Frame.
|
||||
- **Add games → Add from a link…** takes a button's address (right-click → Copy link) or a direct download link. Use
|
||||
it on macOS, where buttons can't open FramePort yet.
|
||||
- **Settings → Install links** turns the buttons on or off. If FrameDrop is installed too, it keeps its buttons until
|
||||
you click **Use FramePort for these links**.
|
||||
- **Settings → Install links** turns the buttons on or off.
|
||||
- Only `https://` links to public servers are used. Only install from sites you trust.
|
||||
|
||||
Website owners: see [Install button](INSTALL_BUTTON.md).
|
||||
@@ -191,6 +190,11 @@ the Steam library.
|
||||
- If the app needs libraries the Frame doesn't have, the game page lists them: look for a build that includes them.
|
||||
- Linux apps also appear in **Desktop Mode**'s app menu. Turn this off on the game
|
||||
page (**Desktop Mode**).
|
||||
- **SteamVR overlay apps** (a wrist watch, a performance display: programs that draw over games instead of being
|
||||
one) are registered with the Frame's SteamVR, which shows them over whatever you play, Quest and PC VR games
|
||||
alike. FramePort recognises them by the `.vrmanifest` file they ship. By default SteamVR starts them by itself
|
||||
(**SteamVR overlay → Start with SteamVR** on the game page); otherwise start the app from your library before the
|
||||
game. Windows overlay apps get the patch **SteamVR overlay app (Frame)**.
|
||||
|
||||
## Files on the Frame
|
||||
|
||||
|
||||
+19
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
A button for web pages and READMEs that opens a game straight in FramePort. Clicking it hands FramePort an install
|
||||
link; FramePort shows what it would download and asks before it does anything (see
|
||||
[Install links](INSTALL.md#install-links-install-with-framedrop-buttons)).
|
||||
[Install links](INSTALL.md#install-links-install-with-frameport-buttons)).
|
||||
|
||||
<p>
|
||||
<img src="badges/install-with-frameport-animated.svg" alt="Install with FramePort" width="216" height="60">
|
||||
@@ -23,14 +23,23 @@ Both animated versions stop moving for visitors who turn on reduced motion in th
|
||||
|
||||
## The link
|
||||
|
||||
The button links to an install link, which names either a manifest or one file:
|
||||
The button links to FramePort's install page, which names either a manifest or one file:
|
||||
|
||||
```
|
||||
https://frameport.app/install?manifest=<URL-encoded https address of a manifest .json>
|
||||
https://frameport.app/install?url=<URL-encoded https address of an .apk, a Linux build (.zip, .tar.*, .AppImage) or a Windows .exe>
|
||||
```
|
||||
|
||||
The page shows what the link installs, opens FramePort with the matching `frameport://` link and, when FramePort
|
||||
isn't installed, offers the download. Pages that only target people who already have FramePort can link to the
|
||||
`frameport://` form directly:
|
||||
|
||||
```
|
||||
frameport://install?manifest=<URL-encoded https address of a manifest .json>
|
||||
frameport://install?url=<URL-encoded https address of an .apk, a Linux build (.zip, .tar.*, .AppImage) or a Windows .exe>
|
||||
```
|
||||
|
||||
The manifest is the FrameDrop format, which FramePort reads as it is:
|
||||
The manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -42,7 +51,7 @@ The manifest is the FrameDrop format, which FramePort reads as it is:
|
||||
```
|
||||
|
||||
`name` becomes the game's title in Steam. `sha256` is optional but recommended: FramePort checks the download
|
||||
against it. The `frameport` object is optional and ignored by FrameDrop; its icon and description are shown in the
|
||||
against it. The `frameport` object is optional; its icon and description are shown in the
|
||||
install question. Only public `https://` addresses are accepted.
|
||||
|
||||
To build a link from Python: `frameport.deeplink.make_link(manifest_url="https://…/my-game.json")`.
|
||||
@@ -68,26 +77,24 @@ give the link an accessible name:
|
||||
|
||||
Host the files yourself, or load them from jsDelivr, which serves them with the right content type:
|
||||
`https://cdn.jsdelivr.net/gh/spoopyghosty0/frameport@main/docs/badges/install-with-frameport.svg` (replace `main`
|
||||
with a release tag such as `v0.12.0` to pin a version; jsDelivr caches `@main` for up to 12 hours).
|
||||
with a release tag such as `v1.0.0` to pin a version; jsDelivr caches `@main` for up to 12 hours).
|
||||
|
||||
## In a GitHub README
|
||||
|
||||
GitHub removes links that don't start with `https://` or `http://` from READMEs, so a `frameport://` link there is
|
||||
not clickable. Link the button to an `https://` page instead, for example your release page with instructions, or
|
||||
FrameDrop's install page, which opens `framedrop://` links (FramePort opens those too while Settings → Install links
|
||||
→ `framedrop://` is on):
|
||||
not clickable. Use the `https://frameport.app/install?…` link:
|
||||
|
||||
```markdown
|
||||
[](https://framedropvr.com/install?manifest=https%3A%2F%2Fexample.com%2Fmy-game.json)
|
||||
[](https://frameport.app/install?manifest=https%3A%2F%2Fexample.com%2Fmy-game.json)
|
||||
```
|
||||
|
||||
Use the animated or still SVG: GitHub shows SVGs as images, so the hover version doesn't react there.
|
||||
|
||||
## If a visitor doesn't have FramePort
|
||||
|
||||
A `frameport://` link does nothing visible (or shows the browser's "no app for this link" message) when FramePort
|
||||
isn't installed. Put a line next to the button such as "Needs [FramePort](https://github.com/spoopyghosty0/frameport)
|
||||
on Windows or Linux." On macOS, web pages can't hand links to FramePort yet: users paste the link into Add games →
|
||||
The `https://frameport.app/install?…` link handles this: its page offers the download and a second try. A bare
|
||||
`frameport://` link does nothing visible (or shows the browser's "no app for this link" message) when FramePort
|
||||
isn't installed; put a line next to such a button, for example "Needs [FramePort](https://frameport.app)". On macOS, web pages can't hand links to FramePort yet: users paste the link into Add games →
|
||||
Add from a link….
|
||||
|
||||
## Usage rules
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Play or a launch test fails at once: `Game files missing at <path> (storage not mounted?)` in launch.log, or "… files are on a drive that isn't inserted" (GitHub #90) | the game was installed or moved to a microSD card (`<mount>/FramePort/<pkg>`) that isn't inserted or mounted now | insert the card; or move the game back (game menu → Move to…, `frameport frame move <pkg> --to internal`) once the card is back. `frameport frame drives` shows what the agent sees (exFAT/NTFS cards are refused: format in SteamOS) |
|
||||
| A Linux app started from Desktop Mode's menu closes after ~2 s, or opens on the wrong display (GitHub #84) | its launcher predates agent v63: the "Steam parent gone" watchdog ended it when Plasma's launcher exited, and it took gamescope's DISPLAY from Steam | connect FramePort once (agent v63's `upgrade_launchers` adds the `FRAMEPORT_DESKTOP` checks; the menu entry runs `env FRAMEPORT_DESKTOP=1 <anchor>/launch.sh`). Game Mode controller problems in such apps are Steam Input's (a separate issue) |
|
||||
| A Linux app (AppImage) shows no/FramePort's placeholder icon in Desktop Mode or Steam (GitHub #99) | before agent v64 only FramePort's art set's icon was used, and a lone AppImage has no store art | connect FramePort once (agent v64 copies the app's `.DirIcon` / `.desktop` `Icon=` to `<anchor>/artwork/app-icon.*` and refreshes the menu entry); reinstall to also get it into the library + Steam shortcut. Still the placeholder: check `<base>/app/squashfs-root/.DirIcon` and the `.desktop` file's `Icon=` (an icon outside the app folder or only an XPM isn't used); a chosen icon (`artwork/.icon-source` = `custom`) always wins |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
@@ -121,7 +122,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| OpenGL ES game: the GPU hangs at one effect (freeze/exit, compositor watchdog, for example Vader Immortal's lightspeed jump) | a shader (as Zink compiles it) reads loop counters/accumulators before setting them | capture the SPIR-V (adapter `zink_shader_dump=1` writes every module to `files/fp_spirv/`, or a GPU capture), then a `zink_shader_fix` entry (same format as `vk_shader_fix`) + `frame.zink_shader_fix` (Vulkan layer under Zink, activated through GraphicsEnv's debug layer list); log `shader fix layer: fixed shader module N`; triage `zink-shader-fix-mismatch` when a Frame update changed the driver's output |
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
| An install button opens another program (or nothing) instead of FramePort | another program owns the link type, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
| 2D Android game ignores the controllers: they only move a pointer, an SDL game keeps its touch controls ("no controller attached"); Android's log lists only `wayland_touch`/`wayland_keyboard`/`wayland_pointer` (GitHub #162, Skate 3) | Lepton's Android gets keyboard, pointer and touch from the Wayland seat only: no gamepad device | patch `device.steam_gamepad` (2D apps; suggested when the manifest declares `android.hardware.gamepad` or `LEANBACK_LAUNCHER`; no APK change, reinstall): the launcher puts FramePort's Podman wrapper (`~/.local/share/frameport/agent/bin/podman`) first on PATH, which bind-mounts Steam Input's virtual pads (uinput, 28de, "Microsoft X-Box 360 pad N") at `/dev/input/eventN` plus an Xbox 360 key layout, and exports `LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=1`. Check: launch.log `FramePort gamepad: Steam Input virtual gamepads for this container: event5 (…)` ("none" = Steam had no pad when the game started: Steam only makes it while the controllers are on), `podman exec lepton-steamlaunch-<appid> dumpsys input` lists the pad. Steam Input must be on for the shortcut. A pad Steam makes after the start (controllers woke later, reconnect) isn't seen until the next start. Off for one start: `FRAMEPORT_NO_GAMEPAD=1 %command%` |
|
||||
| 2D Android app: back/home/recents buttons cover the app's own buttons | Android's navigation bar | patch `device.hide_navbar` (on by default for apps without VR) sets `qemu.hw.mainkeys=1`; reinstall to apply |
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ Short rules so every screen, help text, doc and web page sounds the same. Code s
|
||||
- Tools by their names: OVRPort, Revive, Proton, Lepton, SteamVR.
|
||||
- **Install** (first time), **Update** (a newer build), **Reinstall** (the same build again); **Play** starts a game.
|
||||
- **Set up** = the one-time setup of a Frame; **connect** = linking FramePort to it. **Pair** only in Valve's own
|
||||
**Pair new host**. The **setup line** is the fixed `curl -sL frameport.app/s | bash`; the
|
||||
**Pair new host**. The **setup line** is the fixed `curl -fsSL https://frameport.app/s | bash`; the
|
||||
**setup command** is the one with this PC's address and a one-time code. FramePort's button is **Allow**.
|
||||
- **Konsole** is the Frame's terminal. The way there is "SteamVR dashboard → Launch a program → Desktop, then app
|
||||
menu → System → Konsole": say it in full once per page, then just "Konsole".
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 660" width="1120" height="660" role="img" aria-labelledby="t d">
|
||||
<title id="t">FramePort architecture</title>
|
||||
<desc id="d">The desktop app (ui/) and the command line (cli.py) both drive pipeline.py: add, suggest, build, install, test. The pipeline uses sources/, analysis/, recommend/, build.py, targets/ and validate/. Builds use the patches/ registry and the tools/ toolchain; targets reach the Steam Frame through frame/ and install/, which talk to the agent on the Frame over SSH.</desc>
|
||||
<defs>
|
||||
<linearGradient id="portal" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#3aa8ff"/>
|
||||
<stop offset="1" stop-color="#ff8a1f"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.25" r="0.75">
|
||||
<stop offset="0" stop-color="#3aa8ff" stop-opacity="0.10"/>
|
||||
<stop offset="0.5" stop-color="#ff8a1f" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#0d0e12" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="frameglow" cx="0.5" cy="0.5" r="0.6">
|
||||
<stop offset="0" stop-color="#ff8a1f" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#ff8a1f" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="#717583"/>
|
||||
</marker>
|
||||
<marker id="arrow-o" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="#ff8a1f"/>
|
||||
</marker>
|
||||
<style>
|
||||
.t { font: 700 16px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; fill: #edeef2; }
|
||||
.s { font: 400 13px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; fill: #a7abb7; }
|
||||
.m { font: 500 12.5px ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace; fill: #a7abb7; }
|
||||
.mt { font: 700 15px ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace; fill: #edeef2; }
|
||||
.k { font: 600 11px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; letter-spacing: 0.12em; fill: #717583; }
|
||||
.card { fill: #16181e; stroke: #2a2d37; stroke-width: 1.5; }
|
||||
.pc { fill: #16181e; stroke: #3aa8ff; stroke-opacity: 0.55; stroke-width: 1.5; }
|
||||
.line { stroke: #4a4e5c; stroke-width: 1.8; fill: none; }
|
||||
.chip { fill: #1d1f27; stroke: #393d49; stroke-width: 1; }
|
||||
.ct { font: 600 13px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; fill: #edeef2; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1120" height="660" rx="18" fill="#0d0e12"/>
|
||||
<rect width="1120" height="660" rx="18" fill="url(#glow)"/>
|
||||
|
||||
<!-- entry points -->
|
||||
<text class="k" x="40" y="34">ON YOUR PC</text>
|
||||
<rect class="pc" x="250" y="48" width="300" height="70" rx="12"/>
|
||||
<text class="t" x="400" y="78" text-anchor="middle">Desktop app</text>
|
||||
<text class="m" x="400" y="100" text-anchor="middle">ui/ · Flet · shell + views</text>
|
||||
<rect class="pc" x="590" y="48" width="280" height="70" rx="12"/>
|
||||
<text class="t" x="730" y="78" text-anchor="middle">Command line</text>
|
||||
<text class="m" x="730" y="100" text-anchor="middle">cli.py</text>
|
||||
<path class="line" d="M400 118 V150" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M730 118 V150" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- the pipeline -->
|
||||
<rect x="40" y="156" width="1040" height="78" rx="14" fill="#16181e" stroke="url(#portal)" stroke-width="2"/>
|
||||
<text class="mt" x="66" y="190">pipeline.py</text>
|
||||
<text class="s" x="66" y="212">one API for the app and the CLI</text>
|
||||
<g>
|
||||
<rect class="chip" x="352" y="177" width="92" height="36" rx="18"/>
|
||||
<text class="ct" x="398" y="200" text-anchor="middle">add</text>
|
||||
<path class="line" d="M448 195 H480" marker-end="url(#arrow)"/>
|
||||
<rect class="chip" x="486" y="177" width="112" height="36" rx="18"/>
|
||||
<text class="ct" x="542" y="200" text-anchor="middle">suggest</text>
|
||||
<path class="line" d="M602 195 H634" marker-end="url(#arrow)"/>
|
||||
<rect class="chip" x="640" y="177" width="96" height="36" rx="18"/>
|
||||
<text class="ct" x="688" y="200" text-anchor="middle">build</text>
|
||||
<path class="line" d="M740 195 H772" marker-end="url(#arrow)"/>
|
||||
<rect class="chip" x="778" y="177" width="104" height="36" rx="18"/>
|
||||
<text class="ct" x="830" y="200" text-anchor="middle">install</text>
|
||||
<path class="line" d="M886 195 H918" marker-end="url(#arrow)"/>
|
||||
<rect x="924" y="177" width="132" height="36" rx="18" fill="#3a2512" stroke="#ff8a1f" stroke-opacity="0.6"/>
|
||||
<text class="ct" x="990" y="200" text-anchor="middle">launch test</text>
|
||||
</g>
|
||||
|
||||
<!-- connectors to the modules -->
|
||||
<path class="line" d="M120 234 V284" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M296 234 V284" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M472 234 V284" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M648 234 V284" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M824 234 V284" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M1000 234 V284" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- the modules -->
|
||||
<g>
|
||||
<rect class="card" x="40" y="290" width="160" height="128" rx="12"/>
|
||||
<text class="mt" x="58" y="320">sources/</text>
|
||||
<text class="s" x="58" y="342">finds games</text>
|
||||
<text class="m" x="58" y="370">quest_dump</text>
|
||||
<text class="m" x="58" y="390">rift_dump</text>
|
||||
|
||||
<rect class="card" x="216" y="290" width="160" height="128" rx="12"/>
|
||||
<text class="mt" x="234" y="320">analysis/</text>
|
||||
<text class="s" x="234" y="342">reads the game</text>
|
||||
<text class="m" x="234" y="370">detect · elf</text>
|
||||
<text class="m" x="234" y="390">stubgen · rift</text>
|
||||
|
||||
<rect class="card" x="392" y="290" width="160" height="128" rx="12"/>
|
||||
<text class="mt" x="410" y="320">recommend/</text>
|
||||
<text class="s" x="410" y="342">picks the recipe</text>
|
||||
<text class="m" x="410" y="370">catalog · engine</text>
|
||||
<text class="m" x="410" y="390">catalog/*.yaml</text>
|
||||
|
||||
<rect class="card" x="568" y="290" width="160" height="128" rx="12"/>
|
||||
<text class="mt" x="586" y="320">build.py</text>
|
||||
<text class="s" x="586" y="342">makes the build</text>
|
||||
<text class="m" x="586" y="370">OVRPort → patches</text>
|
||||
<text class="m" x="586" y="390">→ apk/sign</text>
|
||||
|
||||
<rect class="card" x="744" y="290" width="160" height="128" rx="12"/>
|
||||
<text class="mt" x="762" y="320">targets/</text>
|
||||
<text class="s" x="762" y="342">where it runs</text>
|
||||
<text class="m" x="762" y="370">frame_lepton</text>
|
||||
<text class="m" x="762" y="390">pc_revive</text>
|
||||
|
||||
<rect class="card" x="920" y="290" width="160" height="128" rx="12"/>
|
||||
<text class="mt" x="938" y="320">validate/</text>
|
||||
<text class="s" x="938" y="342">checks the result</text>
|
||||
<text class="m" x="938" y="370">static · device</text>
|
||||
<text class="m" x="938" y="390">triage.yaml</text>
|
||||
</g>
|
||||
|
||||
<!-- what the build and the targets use -->
|
||||
<path class="line" d="M612 418 V448 H464 V484" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M648 418 V484" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M824 418 V484" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect class="card" x="216" y="490" width="336" height="118" rx="12"/>
|
||||
<text class="mt" x="234" y="520">patches/</text>
|
||||
<text class="s" x="234" y="542">the registry: one module per patch</text>
|
||||
<text class="m" x="234" y="570">overport · frame/* · settings</text>
|
||||
<text class="m" x="234" y="590">pcvr · upstream</text>
|
||||
|
||||
<rect class="card" x="568" y="490" width="160" height="118" rx="12"/>
|
||||
<text class="mt" x="586" y="520">tools/</text>
|
||||
<text class="s" x="586" y="542">fetched on demand</text>
|
||||
<text class="m" x="586" y="570">Java · OVRPort</text>
|
||||
<text class="m" x="586" y="590">apksigner · Revive</text>
|
||||
|
||||
<rect class="card" x="744" y="490" width="160" height="118" rx="12"/>
|
||||
<text class="mt" x="762" y="520">frame/</text>
|
||||
<text class="s" x="762" y="542">+ install/installer</text>
|
||||
<text class="m" x="762" y="570">ssh · pairing</text>
|
||||
<text class="m" x="762" y="590">discovery</text>
|
||||
|
||||
<!-- the Steam Frame -->
|
||||
<rect x="926" y="448" width="160" height="196" rx="16" fill="url(#frameglow)" stroke="#ff8a1f" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="6 6"/>
|
||||
<text class="k" x="1006" y="470" text-anchor="middle" style="fill:#ff8a1f">STEAM FRAME</text>
|
||||
<path d="M904 549 H932" stroke="#ff8a1f" stroke-width="2" fill="none" marker-end="url(#arrow-o)"/>
|
||||
<text class="m" x="915" y="538" text-anchor="middle" style="fill:#ff8a1f;font-size:10px">SSH</text>
|
||||
<rect x="936" y="490" width="140" height="118" rx="12" fill="#1d150d" stroke="#ff8a1f" stroke-width="1.5"/>
|
||||
<text class="mt" x="952" y="520">agent</text>
|
||||
<text class="s" x="952" y="542">runs on the Frame</text>
|
||||
<text class="m" x="952" y="570">install · Steam</text>
|
||||
<text class="m" x="952" y="590">tests · logs</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,88 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 400" width="1120" height="400" role="img" aria-labelledby="t d">
|
||||
<title id="t">How FramePort updates itself</title>
|
||||
<desc id="d">check() asks GitHub for the latest release every 6 hours. install_kind() tells a packaged app from a source checkout or a Python wheel: a checkout runs git pull and uv sync, a wheel is reinstalled. For the packaged app, prepare() downloads the release, checks SHA256SUMS and the Windows signer and unpacks it, then apply() starts a script that waits for FramePort to quit, swaps the folders with a rollback and starts the new version.</desc>
|
||||
<defs>
|
||||
<linearGradient id="portal" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#3aa8ff"/>
|
||||
<stop offset="1" stop-color="#ff8a1f"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.2" r="0.8">
|
||||
<stop offset="0" stop-color="#3aa8ff" stop-opacity="0.09"/>
|
||||
<stop offset="0.55" stop-color="#ff8a1f" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#0d0e12" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="#717583"/>
|
||||
</marker>
|
||||
<style>
|
||||
.n { font: 800 30px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
||||
.mt { font: 700 16px ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace; fill: #edeef2; }
|
||||
.s { font: 400 13.5px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; fill: #a7abb7; }
|
||||
.m { font: 500 12.5px ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace; fill: #a7abb7; }
|
||||
.lab { font: 600 12px "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; fill: #717583; letter-spacing: .04em; }
|
||||
.card { fill: #16181e; stroke: #2a2d37; stroke-width: 1.5; }
|
||||
.line { stroke: #4a4e5c; stroke-width: 1.8; fill: none; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1120" height="400" rx="18" fill="#0d0e12"/>
|
||||
<rect width="1120" height="400" rx="18" fill="url(#glow)"/>
|
||||
<rect x="40" y="36" width="1040" height="3" rx="1.5" fill="url(#portal)" opacity="0.7"/>
|
||||
|
||||
<!-- 1 check -->
|
||||
<rect class="card" x="40" y="62" width="236" height="178" rx="14"/>
|
||||
<text class="n" x="60" y="104" fill="#3aa8ff">1</text>
|
||||
<text class="mt" x="90" y="100">check()</text>
|
||||
<text class="s" x="60" y="134">Asks GitHub for the latest</text>
|
||||
<text class="s" x="60" y="154">release, every 6 hours.</text>
|
||||
<text class="s" x="60" y="182">Skips drafts, pre-releases</text>
|
||||
<text class="s" x="60" y="202">and versions you skipped.</text>
|
||||
<path class="line" d="M276 151 H306" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 2 install_kind -->
|
||||
<rect class="card" x="312" y="62" width="236" height="178" rx="14"/>
|
||||
<text class="n" x="332" y="104" fill="#7f9bd6">2</text>
|
||||
<text class="mt" x="362" y="100">install_kind()</text>
|
||||
<text class="s" x="332" y="134">How this copy was installed:</text>
|
||||
<text class="m" x="332" y="164">bundle</text>
|
||||
<text class="s" x="388" y="164">the packaged app</text>
|
||||
<text class="m" x="332" y="190">source</text>
|
||||
<text class="s" x="388" y="190">a git checkout</text>
|
||||
<text class="m" x="332" y="216">wheel</text>
|
||||
<text class="s" x="388" y="216">uv tool, pipx, pip</text>
|
||||
<path class="line" d="M548 151 H604" marker-end="url(#arrow)"/>
|
||||
<text class="lab" x="576" y="141" text-anchor="middle">bundle</text>
|
||||
|
||||
<!-- 3 prepare -->
|
||||
<rect class="card" x="610" y="62" width="226" height="178" rx="14"/>
|
||||
<text class="n" x="630" y="104" fill="#c2905a">3</text>
|
||||
<text class="mt" x="660" y="100">prepare()</text>
|
||||
<text class="s" x="630" y="134">Downloads the release to</text>
|
||||
<text class="m" x="630" y="154">updates/<version>/</text>
|
||||
<text class="s" x="630" y="182">Checks SHA256SUMS and, on</text>
|
||||
<text class="s" x="630" y="202">Windows, the code signer.</text>
|
||||
<text class="s" x="630" y="222">Unpacks; writes ready.json.</text>
|
||||
<path class="line" d="M836 151 H862" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- 4 apply -->
|
||||
<rect x="868" y="62" width="212" height="178" rx="14" fill="#1d150d" stroke="#ff8a1f" stroke-width="1.5"/>
|
||||
<text class="n" x="888" y="104" fill="#ff8a1f">4</text>
|
||||
<text class="mt" x="918" y="100">apply()</text>
|
||||
<text class="s" x="888" y="134">A script waits for</text>
|
||||
<text class="s" x="888" y="154">FramePort to quit, swaps</text>
|
||||
<text class="s" x="888" y="174">the folders (rolls back on</text>
|
||||
<text class="s" x="888" y="194">failure) and starts the</text>
|
||||
<text class="s" x="888" y="214">new version.</text>
|
||||
|
||||
<!-- the other two ways -->
|
||||
<path class="line" d="M400 240 V282" marker-end="url(#arrow)"/>
|
||||
<path class="line" d="M470 240 V262 H710 V282" marker-end="url(#arrow)"/>
|
||||
<text class="lab" x="400" y="266" text-anchor="end" dx="-8">source</text>
|
||||
<text class="lab" x="600" y="256" text-anchor="middle">wheel</text>
|
||||
<rect class="card" x="240" y="288" width="320" height="74" rx="12"/>
|
||||
<text class="s" x="260" y="318">Source checkout: pulls and syncs</text>
|
||||
<text class="m" x="260" y="342">git pull --ff-only · uv sync</text>
|
||||
<rect class="card" x="584" y="288" width="320" height="74" rx="12"/>
|
||||
<text class="s" x="604" y="318">Wheel: reinstalls the release's wheel</text>
|
||||
<text class="m" x="604" y="342">uv tool · pipx · pip</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -79,7 +79,7 @@ scenes:
|
||||
- "In the SteamVR dashboard: **Launch a program** → **Desktop**"
|
||||
- "Open the app menu (bottom left) → **System** → **Konsole**"
|
||||
- "Type the line and press **Enter** (or copy it from **frameport.app/setup** in Chromium)"
|
||||
code: "curl -sL frameport.app/s | bash"
|
||||
code: "curl -fsSL https://frameport.app/s | bash"
|
||||
note: "Nothing changes on the Frame until you allow it on the PC."
|
||||
|
||||
- name: allow
|
||||
|
||||
+24
-3
@@ -10,7 +10,7 @@ timefix OpenXR layer for Proton games (linux-arm64, glibc; the NDK's clang build
|
||||
OculusHMDConnected helper for Rift games under Proton (win-x64 PE; the NDK's clang + lld-link, no Windows SDK), the
|
||||
live view's hardware H.264 encoder fp_venc (linux-arm64-bin, static freestanding executable), and rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
|
||||
|
||||
python native/build.py [--only adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,ovrpshim,vrsettings,venc] [--ndk PATH]
|
||||
python native/build.py [--only adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,ovrpshim,vrsettings,venc,vroverlay_probe] [--ndk PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -227,6 +227,25 @@ def build_vrsettings(tc: Path):
|
||||
f"/out:{out / 'fp_vrsettings.exe'}"])
|
||||
|
||||
|
||||
VROVERLAY_IMPORTS = VRSETTINGS_IMPORTS + ("Sleep",)
|
||||
|
||||
|
||||
def build_vroverlay_probe(tc: Path):
|
||||
"""fp_vroverlay_probe.exe (Windows x64 diagnostic, run under Proton on the Frame): does an OpenVR overlay app show
|
||||
up? Not shipped: written to native/.cache/vroverlay_probe/ (opt-in: --only vroverlay_probe)."""
|
||||
src = HERE / "vroverlay_probe"
|
||||
work = CACHE / "vroverlay_probe"
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
(work / "kernel32.def").write_text("LIBRARY kernel32.dll\nEXPORTS\n" + "".join(f" {n}\n" for n in VROVERLAY_IMPORTS))
|
||||
run([tc / "bin/llvm-dlltool", "-m", "i386:x86-64", "-d", work / "kernel32.def", "-l", work / "kernel32.lib"])
|
||||
run([tc / "bin/clang", "--target=x86_64-pc-windows-msvc", "-ffreestanding", "-nostdlibinc", "-fno-stack-protector",
|
||||
"-fno-builtin", "-O2", "-Wall", "-Wextra", "-Werror", f"-ffile-prefix-map={HERE}=native", "-c",
|
||||
"fp_vroverlay_probe.c", "-o", work / "fp_vroverlay_probe.obj"], cwd=src)
|
||||
run([tc / "bin/lld-link", "/nologo", "/Brepro", "/nodefaultlib", "/entry:start", "/subsystem:console",
|
||||
"/dynamicbase", "/highentropyva", "/nxcompat", work / "fp_vroverlay_probe.obj", work / "kernel32.lib",
|
||||
f"/out:{work / 'fp_vroverlay_probe.exe'}"])
|
||||
|
||||
|
||||
def build_bridge(tc: Path):
|
||||
src = HERE / "vrapi-bridge"
|
||||
inc = openxr_include("bridge")
|
||||
@@ -384,8 +403,10 @@ def main():
|
||||
"oculushmd": lambda: build_oculushmd(tc), "vkshim": lambda: build_vkshim(tc),
|
||||
"zinkfix": lambda: build_zinkfix(tc),
|
||||
"ovrpshim": lambda: build_ovrpshim(tc),
|
||||
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc)}
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "ovrpshim", "dex", "xrlayer", "oculushmd", "vrsettings", "venc"):
|
||||
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc),
|
||||
"vroverlay_probe": lambda: build_vroverlay_probe(tc)}
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "ovrpshim", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
|
||||
"vroverlay_probe"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
steps[name]()
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/* fp_vroverlay_probe.exe — can a Windows OpenVR overlay application show something on the Steam Frame (under Proton)?
|
||||
*
|
||||
* The Windows twin of vroverlay_probe.py: connects as VRApplication_Overlay through the given openvr_api.dll (any
|
||||
* app's copy; Proton's vrclient bridges it to the Frame's SteamVR), creates a head-locked overlay 1.5 m in front of
|
||||
* the headset (magenta/green checker), a world overlay (yellow/blue) and a dashboard overlay, logs every
|
||||
* EVROverlayError and the visible flags every 5 s, and stays alive for the given number of seconds.
|
||||
*
|
||||
* fp_vroverlay_probe.exe <openvr_api.dll> [seconds]
|
||||
*
|
||||
* Exit code 0 when every overlay call succeeded, 1 when one failed, 2 on bad usage / OpenVR init failure.
|
||||
* Freestanding (no CRT, no Windows SDK headers): built by native/build.py (--only vroverlay) like fp_vrsettings.
|
||||
*/
|
||||
typedef void *HANDLE;
|
||||
typedef unsigned long DWORD;
|
||||
typedef int BOOL;
|
||||
typedef unsigned long long u64;
|
||||
typedef unsigned u32;
|
||||
|
||||
#define WINAPI __attribute__((ms_abi))
|
||||
#define IMPORT __declspec(dllimport)
|
||||
IMPORT HANDLE WINAPI LoadLibraryA(const char *);
|
||||
IMPORT void *WINAPI GetProcAddress(HANDLE, const char *);
|
||||
IMPORT char *WINAPI GetCommandLineA(void);
|
||||
IMPORT HANDLE WINAPI GetStdHandle(DWORD);
|
||||
IMPORT BOOL WINAPI WriteFile(HANDLE, const void *, DWORD, DWORD *, void *);
|
||||
IMPORT void WINAPI Sleep(DWORD);
|
||||
IMPORT __attribute__((noreturn)) void WINAPI ExitProcess(unsigned);
|
||||
|
||||
#define STD_OUTPUT_HANDLE ((DWORD)-11)
|
||||
#define VRApplication_Overlay 2
|
||||
int _fltused = 1;
|
||||
|
||||
typedef struct { float m[3][4]; } Mat34;
|
||||
typedef int Err;
|
||||
/* openvr_capi.h (OpenVR 2.5.1) struct VR_IVROverlay_FnTable, IVROverlay_027: only the slots used here are typed */
|
||||
typedef struct {
|
||||
Err (*FindOverlay)(const char *, u64 *); /* 0 */
|
||||
Err (*CreateOverlay)(const char *, const char *, u64 *); /* 1 */
|
||||
void *pad2_6[5];
|
||||
const char *(*GetOverlayErrorNameFromEnum)(Err); /* 7 */
|
||||
void *pad8_20[13];
|
||||
Err (*SetOverlayWidthInMeters)(u64, float); /* 21 */
|
||||
void *pad22_31[10];
|
||||
Err (*SetOverlayTransformAbsolute)(u64, int, Mat34 *); /* 32 */
|
||||
void *pad33;
|
||||
Err (*SetOverlayTransformTrackedDeviceRelative)(u64, u32, Mat34 *); /* 34 */
|
||||
void *pad35_40[6];
|
||||
Err (*ShowOverlay)(u64); /* 41 */
|
||||
Err (*HideOverlay)(u64); /* 42 */
|
||||
unsigned char (*IsOverlayVisible)(u64); /* 43 */
|
||||
void *pad44_59[16];
|
||||
Err (*SetOverlayRaw)(u64, void *, u32, u32, u32); /* 60 */
|
||||
Err (*SetOverlayFromFile)(u64, const char *); /* 61 */
|
||||
void *pad62_64[3];
|
||||
Err (*CreateDashboardOverlay)(const char *, const char *, u64 *, u64 *); /* 65 */
|
||||
unsigned char (*IsDashboardVisible)(void); /* 66 */
|
||||
} OverlayFnTable;
|
||||
|
||||
typedef unsigned (*PFN_InitInternal2)(int *, int, const char *);
|
||||
typedef void (*PFN_ShutdownInternal)(void);
|
||||
typedef void *(*PFN_GetGenericInterface)(const char *, int *);
|
||||
|
||||
static void out(const char *s) {
|
||||
DWORD n = 0, len = 0;
|
||||
while (s[len]) len++;
|
||||
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), s, len, &n, 0);
|
||||
}
|
||||
|
||||
static void out_int(long long v) {
|
||||
char buf[24], *p = buf + sizeof buf;
|
||||
int neg = v < 0;
|
||||
unsigned long long u = neg ? (unsigned long long)-v : (unsigned long long)v;
|
||||
*--p = 0;
|
||||
do *--p = (char)('0' + u % 10); while (u /= 10);
|
||||
if (neg) *--p = '-';
|
||||
out(p);
|
||||
}
|
||||
|
||||
static int split(char *p, char **argv, int max) {
|
||||
int n = 0;
|
||||
while (*p && n < max) {
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
if (!*p) break;
|
||||
if (*p == '"') {
|
||||
argv[n++] = ++p;
|
||||
while (*p && *p != '"') p++;
|
||||
} else {
|
||||
argv[n++] = p;
|
||||
while (*p && *p != ' ' && *p != '\t') p++;
|
||||
}
|
||||
if (*p) *p++ = 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static OverlayFnTable *ov;
|
||||
static int failed;
|
||||
|
||||
static Err report(const char *what, Err e) {
|
||||
out(what);
|
||||
out(" -> ");
|
||||
out_int(e);
|
||||
out(" ");
|
||||
const char *name = e ? ov->GetOverlayErrorNameFromEnum(e) : "None";
|
||||
out(name ? name : "?");
|
||||
out("\n");
|
||||
if (e) failed = 1;
|
||||
return e;
|
||||
}
|
||||
|
||||
#define W 256
|
||||
static unsigned char img_head[W * W * 4], img_world[W * W * 4];
|
||||
|
||||
static void checker(unsigned char *buf, const unsigned char *a, const unsigned char *b) {
|
||||
for (int y = 0; y < W; y++)
|
||||
for (int x = 0; x < W; x++) {
|
||||
const unsigned char white[4] = {255, 255, 255, 255};
|
||||
const unsigned char *c = ((x / 32 + y / 32) & 1) ? b : a;
|
||||
if (x < 4 || y < 4 || x >= W - 4 || y >= W - 4) c = white;
|
||||
for (int i = 0; i < 4; i++) buf[(y * W + x) * 4 + i] = c[i];
|
||||
}
|
||||
}
|
||||
|
||||
static Mat34 translation(float x, float y, float z) {
|
||||
Mat34 m = {{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}}};
|
||||
m.m[0][3] = x;
|
||||
m.m[1][3] = y;
|
||||
m.m[2][3] = z;
|
||||
return m;
|
||||
}
|
||||
|
||||
void start(void) {
|
||||
static char *argv[8];
|
||||
int argc = split(GetCommandLineA(), argv, 8);
|
||||
if (argc < 2) {
|
||||
out("usage: fp_vroverlay_probe.exe <openvr_api.dll> [seconds]\n");
|
||||
ExitProcess(2);
|
||||
}
|
||||
long long seconds = 60;
|
||||
if (argc > 2) {
|
||||
seconds = 0;
|
||||
for (const char *s = argv[2]; *s >= '0' && *s <= '9'; s++) seconds = seconds * 10 + (*s - '0');
|
||||
}
|
||||
HANDLE lib = LoadLibraryA(argv[1]);
|
||||
PFN_InitInternal2 init = lib ? (PFN_InitInternal2)GetProcAddress(lib, "VR_InitInternal2") : 0;
|
||||
PFN_ShutdownInternal shutdown = lib ? (PFN_ShutdownInternal)GetProcAddress(lib, "VR_ShutdownInternal") : 0;
|
||||
PFN_GetGenericInterface iface = lib ? (PFN_GetGenericInterface)GetProcAddress(lib, "VR_GetGenericInterface") : 0;
|
||||
if (!init || !shutdown || !iface) {
|
||||
out("error could not load openvr_api.dll\n");
|
||||
ExitProcess(2);
|
||||
}
|
||||
int err = 0;
|
||||
init(&err, VRApplication_Overlay, 0);
|
||||
out("VR_InitInternal2(Overlay) -> ");
|
||||
out_int(err);
|
||||
out("\n");
|
||||
if (err) ExitProcess(2);
|
||||
ov = (OverlayFnTable *)iface("FnTable:IVROverlay_027", &err);
|
||||
if (!ov || err) {
|
||||
out("error no FnTable:IVROverlay_027\n");
|
||||
shutdown();
|
||||
ExitProcess(2);
|
||||
}
|
||||
const unsigned char mag[4] = {255, 0, 255, 255}, grn[4] = {0, 200, 0, 255};
|
||||
const unsigned char yel[4] = {255, 220, 0, 255}, blu[4] = {0, 60, 255, 255};
|
||||
checker(img_head, mag, grn);
|
||||
checker(img_world, yel, blu);
|
||||
u64 head = 0, world = 0, dmain = 0, dthumb = 0;
|
||||
if (!report("CreateOverlay(head)", ov->CreateOverlay("frameport.probe.win.head", "FramePort probe (head, Proton)", &head))) {
|
||||
Mat34 m = translation(0, 0, -1.5f);
|
||||
report("SetOverlayRaw(head)", ov->SetOverlayRaw(head, img_head, W, W, 4));
|
||||
report("SetOverlayWidthInMeters(head)", ov->SetOverlayWidthInMeters(head, 0.6f));
|
||||
report("SetOverlayTransformTrackedDeviceRelative(head)", ov->SetOverlayTransformTrackedDeviceRelative(head, 0, &m));
|
||||
report("ShowOverlay(head)", ov->ShowOverlay(head));
|
||||
}
|
||||
if (!report("CreateOverlay(world)", ov->CreateOverlay("frameport.probe.win.world", "FramePort probe (world, Proton)", &world))) {
|
||||
Mat34 m = translation(0, 1.4f, -1.5f);
|
||||
report("SetOverlayRaw(world)", ov->SetOverlayRaw(world, img_world, W, W, 4));
|
||||
report("SetOverlayWidthInMeters(world)", ov->SetOverlayWidthInMeters(world, 1.0f));
|
||||
report("SetOverlayTransformAbsolute(world)", ov->SetOverlayTransformAbsolute(world, 1, &m));
|
||||
report("ShowOverlay(world)", ov->ShowOverlay(world));
|
||||
}
|
||||
if (!report("CreateDashboardOverlay(dash)", ov->CreateDashboardOverlay("frameport.probe.win.dash", "FramePort probe (Proton)", &dmain, &dthumb))) {
|
||||
report("SetOverlayRaw(dash main)", ov->SetOverlayRaw(dmain, img_head, W, W, 4));
|
||||
report("SetOverlayRaw(dash thumb)", ov->SetOverlayRaw(dthumb, img_world, W, W, 4));
|
||||
}
|
||||
for (long long t = 0;; t += 5) {
|
||||
out("state t=");
|
||||
out_int(t);
|
||||
out(" head visible=");
|
||||
out_int(ov->IsOverlayVisible(head));
|
||||
out(" world visible=");
|
||||
out_int(ov->IsOverlayVisible(world));
|
||||
out(" dashboard visible=");
|
||||
out_int(ov->IsDashboardVisible());
|
||||
out("\n");
|
||||
if (t >= seconds) break;
|
||||
Sleep(5000);
|
||||
}
|
||||
shutdown();
|
||||
out(failed ? "done, some calls failed\n" : "done, failed calls: none\n");
|
||||
ExitProcess(failed);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenVR overlay probe (Linux, python3 stdlib only): can an overlay application show something on the Steam Frame?
|
||||
|
||||
Runs on the Frame against SteamVR's own libopenvr_api.so through ctypes (the same pattern as the agent's
|
||||
take_screenshot). It connects as VRApplication_Overlay, creates
|
||||
- a head-locked overlay 1.5 m in front of the headset (magenta/green checker, 0.6 m wide),
|
||||
- a world overlay 1.5 m in front of the standing origin (yellow/blue),
|
||||
- a dashboard overlay "FramePort probe" (thumbnail + main image),
|
||||
logs every EVROverlayError and the visible flags, then stays alive (default 60 s), polling IsOverlayVisible /
|
||||
IsDashboardVisible every 5 s.
|
||||
|
||||
python3 vroverlay_probe.py [seconds] [--lib /path/libopenvr_api.so] [--show-dashboard]
|
||||
|
||||
Output: one line per call, "<call> -> <result>"; exit code 0 when every overlay call succeeded.
|
||||
"""
|
||||
import ctypes as C
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
LIBS = ("/opt/steamvr/bin/linuxarm64/libopenvr_api.so", "/opt/steamvr/bin/linux64/libopenvr_api.so")
|
||||
VRApplication_Overlay = 2
|
||||
OVERLAY_TABLE = b"FnTable:IVROverlay_027" # openvr_capi.h of OpenVR 2.5.1; the Frame's vrclient serves 010-028
|
||||
# indexes into VR_IVROverlay_FnTable (IVROverlay_027)
|
||||
IDX = dict(FindOverlay=0, CreateOverlay=1, DestroyOverlay=2, GetOverlayErrorNameFromEnum=7, SetOverlayFlag=10,
|
||||
SetOverlayAlpha=15, SetOverlaySortOrder=19, SetOverlayWidthInMeters=21, SetOverlayTransformAbsolute=32,
|
||||
SetOverlayTransformTrackedDeviceRelative=34, ShowOverlay=41, HideOverlay=42, IsOverlayVisible=43,
|
||||
SetOverlayRaw=60, SetOverlayFromFile=61, GetOverlayTextureSize=64, CreateDashboardOverlay=65,
|
||||
IsDashboardVisible=66, IsActiveDashboardOverlay=67, ShowDashboard=70)
|
||||
H = C.c_uint64
|
||||
ERR = C.c_int
|
||||
|
||||
|
||||
class Mat34(C.Structure):
|
||||
_fields_ = [("m", (C.c_float * 4) * 3)]
|
||||
|
||||
|
||||
def translation(x, y, z):
|
||||
m = Mat34()
|
||||
for r, row in enumerate(((1, 0, 0, x), (0, 1, 0, y), (0, 0, 1, z))):
|
||||
for c, v in enumerate(row):
|
||||
m.m[r][c] = v
|
||||
return m
|
||||
|
||||
|
||||
def checker(w, h, a, b, cell=32):
|
||||
buf = bytearray(w * h * 4)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
col = a if ((x // cell) + (y // cell)) % 2 == 0 else b
|
||||
if x < 4 or y < 4 or x >= w - 4 or y >= h - 4:
|
||||
col = (255, 255, 255, 255)
|
||||
i = (y * w + x) * 4
|
||||
buf[i:i + 4] = bytes(col)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def write_png(path, w, h, rgba):
|
||||
raw = b"".join(b"\0" + rgba[y * w * 4:(y + 1) * w * 4] for y in range(h))
|
||||
|
||||
def chunk(t, d):
|
||||
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
seconds = float(next((a for a in args if a.replace(".", "").isdigit()), 60))
|
||||
lib = args[args.index("--lib") + 1] if "--lib" in args else next((p for p in LIBS if os.path.isfile(p)), None)
|
||||
if not lib:
|
||||
log("no libopenvr_api.so")
|
||||
return 2
|
||||
vr = C.CDLL(lib)
|
||||
vr.VR_InitInternal2.restype = C.c_uint32
|
||||
vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
|
||||
vr.VR_GetGenericInterface.restype = C.c_void_p
|
||||
vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
|
||||
vr.VR_IsHmdPresent.restype = C.c_bool
|
||||
vr.VR_IsRuntimeInstalled.restype = C.c_bool
|
||||
log(f"lib {lib} hmd_present={vr.VR_IsHmdPresent()} runtime_installed={vr.VR_IsRuntimeInstalled()}")
|
||||
err = C.c_int(0)
|
||||
token = vr.VR_InitInternal2(C.byref(err), VRApplication_Overlay, None)
|
||||
log(f"VR_InitInternal2(Overlay) -> err={err.value} token={token}")
|
||||
if err.value:
|
||||
return 2
|
||||
ptr = vr.VR_GetGenericInterface(OVERLAY_TABLE, C.byref(err))
|
||||
log(f"VR_GetGenericInterface({OVERLAY_TABLE.decode()}) -> {hex(ptr or 0)} err={err.value}")
|
||||
if not ptr:
|
||||
vr.VR_ShutdownInternal()
|
||||
return 2
|
||||
table = (C.c_void_p * 83).from_address(ptr)
|
||||
|
||||
def fn(name, restype, *argtypes):
|
||||
return C.CFUNCTYPE(restype, *argtypes)(table[IDX[name]])
|
||||
|
||||
errname = fn("GetOverlayErrorNameFromEnum", C.c_char_p, ERR)
|
||||
failed = []
|
||||
|
||||
def call(label, f, *a):
|
||||
r = f(*a)
|
||||
log(f"{label} -> {r} {errname(r).decode() if r else 'None'}")
|
||||
if r:
|
||||
failed.append(label)
|
||||
return r
|
||||
|
||||
create = fn("CreateOverlay", ERR, C.c_char_p, C.c_char_p, C.POINTER(H))
|
||||
create_dash = fn("CreateDashboardOverlay", ERR, C.c_char_p, C.c_char_p, C.POINTER(H), C.POINTER(H))
|
||||
raw = fn("SetOverlayRaw", ERR, H, C.c_void_p, C.c_uint32, C.c_uint32, C.c_uint32)
|
||||
from_file = fn("SetOverlayFromFile", ERR, H, C.c_char_p)
|
||||
width = fn("SetOverlayWidthInMeters", ERR, H, C.c_float)
|
||||
rel = fn("SetOverlayTransformTrackedDeviceRelative", ERR, H, C.c_uint32, C.POINTER(Mat34))
|
||||
absolute = fn("SetOverlayTransformAbsolute", ERR, H, C.c_int, C.POINTER(Mat34))
|
||||
show = fn("ShowOverlay", ERR, H)
|
||||
visible = fn("IsOverlayVisible", C.c_bool, H)
|
||||
dash_visible = fn("IsDashboardVisible", C.c_bool)
|
||||
active_dash = fn("IsActiveDashboardOverlay", C.c_bool, H)
|
||||
tex_size = fn("GetOverlayTextureSize", ERR, H, C.POINTER(C.c_uint32), C.POINTER(C.c_uint32))
|
||||
sort = fn("SetOverlaySortOrder", ERR, H, C.c_uint32)
|
||||
show_dash = fn("ShowDashboard", None, C.c_char_p)
|
||||
|
||||
tag = f"frameport.probe.{os.getpid()}"
|
||||
head, world, dmain, dthumb = H(), H(), H(), H()
|
||||
w = h = 256
|
||||
img1 = C.create_string_buffer(checker(w, h, (255, 0, 255, 255), (0, 200, 0, 255)), w * h * 4)
|
||||
img2 = C.create_string_buffer(checker(w, h, (255, 220, 0, 255), (0, 60, 255, 255)), w * h * 4)
|
||||
png = f"/tmp/{tag}.png"
|
||||
write_png(png, w, h, checker(w, h, (0, 220, 220, 255), (40, 40, 40, 255)))
|
||||
|
||||
if not call(f"CreateOverlay({tag}.head)", create, f"{tag}.head".encode(), b"FramePort probe (head)", C.byref(head)):
|
||||
call("SetOverlayRaw(head)", raw, head, C.cast(img1, C.c_void_p), w, h, 4)
|
||||
call("SetOverlayWidthInMeters(head, 0.6)", width, head, 0.6)
|
||||
call("SetOverlayTransformTrackedDeviceRelative(head, hmd, z=-1.5)", rel, head, 0, C.byref(translation(0, 0, -1.5)))
|
||||
call("SetOverlaySortOrder(head, 10)", sort, head, 10)
|
||||
call("ShowOverlay(head)", show, head)
|
||||
if not call(f"CreateOverlay({tag}.world)", create, f"{tag}.world".encode(), b"FramePort probe (world)", C.byref(world)):
|
||||
call("SetOverlayRaw(world)", raw, world, C.cast(img2, C.c_void_p), w, h, 4)
|
||||
call("SetOverlayWidthInMeters(world, 1.0)", width, world, 1.0)
|
||||
call("SetOverlayTransformAbsolute(world, standing, y=1.4 z=-1.5)", absolute, world, 1,
|
||||
C.byref(translation(0, 1.4, -1.5)))
|
||||
call("ShowOverlay(world)", show, world)
|
||||
if not call(f"CreateDashboardOverlay({tag}.dash)", create_dash, f"{tag}.dash".encode(), b"FramePort probe",
|
||||
C.byref(dmain), C.byref(dthumb)):
|
||||
call("SetOverlayFromFile(dash main)", from_file, dmain, png.encode())
|
||||
call("SetOverlayFromFile(dash thumb)", from_file, dthumb, png.encode())
|
||||
call("SetOverlayWidthInMeters(dash, 1.5)", width, dmain, 1.5)
|
||||
if "--show-dashboard" in args:
|
||||
show_dash(f"{tag}.dash".encode())
|
||||
log("ShowDashboard(probe) called")
|
||||
|
||||
end = time.time() + seconds
|
||||
while True:
|
||||
tw, th = C.c_uint32(0), C.c_uint32(0)
|
||||
tex_size(head, C.byref(tw), C.byref(th))
|
||||
log(f"state: head visible={visible(head)} tex={tw.value}x{th.value} world visible={visible(world)} "
|
||||
f"dash main visible={visible(dmain)} active={active_dash(dmain)} dashboard visible={dash_visible()}")
|
||||
if time.time() >= end:
|
||||
break
|
||||
time.sleep(min(5, max(0.1, end - time.time())))
|
||||
vr.VR_ShutdownInternal()
|
||||
try:
|
||||
os.remove(png)
|
||||
except OSError:
|
||||
pass
|
||||
log(f"done, failed calls: {failed or 'none'}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1
-1
@@ -8,7 +8,7 @@ license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
authors = [{ name = "spoopyghosty0" }]
|
||||
keywords = ["steam-frame", "vr", "openxr", "meta-quest", "android", "lepton", "proton", "revive"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: X11 Applications",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
// Visitor statistics. Both are optional and set at build time (pages workflow → repo variables):
|
||||
// - Cloudflare Web Analytics (PUBLIC_CF_ANALYTICS_TOKEN): no cookies and no personal data, so it runs without asking.
|
||||
// - Google Analytics 4 (PUBLIC_GA_ID, G-…): sets cookies, so it loads only after the visitor clicks Allow. The
|
||||
// choice is kept in localStorage ("frameport.consent"); "Cookie settings" in the footer asks again. A browser that
|
||||
// sends Global Privacy Control counts as Decline.
|
||||
// window.fpTrack(name, params) records an event when Google Analytics is allowed (downloads use it).
|
||||
import { url } from '../data/site';
|
||||
|
||||
const cf = import.meta.env.PUBLIC_CF_ANALYTICS_TOKEN as string | undefined;
|
||||
const ga = import.meta.env.PUBLIC_GA_ID as string | undefined;
|
||||
const gaOk = !!ga && /^G-[A-Z0-9]+$/.test(ga);
|
||||
---
|
||||
{cf && <script is:inline defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon={JSON.stringify({ token: cf })}></script>}
|
||||
|
||||
{gaOk ? (
|
||||
<>
|
||||
<div class="consent" data-consent hidden role="region" aria-label="Cookie settings">
|
||||
<p><b>Allow analytics cookies?</b> Google Analytics would count visits and downloads, nothing else.
|
||||
<a href={url('privacy/')}>Privacy</a></p>
|
||||
<div class="row">
|
||||
<button type="button" class="btn btn-secondary btn-small" data-consent-no>Decline</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" data-consent-yes>Allow</button>
|
||||
</div>
|
||||
</div>
|
||||
<script is:inline define:vars={{ ga }}>
|
||||
(() => {
|
||||
const KEY = 'frameport.consent';
|
||||
const box = document.querySelector('[data-consent]');
|
||||
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
|
||||
const save = (v) => { try { localStorage.setItem(KEY, v); } catch { /* private mode: ask again next time */ } };
|
||||
let loaded = false;
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { window.dataLayer.push(arguments); }
|
||||
window.fpTrack = (name, params) => { if (loaded) gtag('event', name, params || {}); };
|
||||
const load = () => {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
gtag('js', new Date());
|
||||
gtag('config', ga);
|
||||
const s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(ga)}`;
|
||||
document.head.appendChild(s);
|
||||
};
|
||||
const drop = () => {
|
||||
// a later Decline: stop sending and remove Google's cookies for this site
|
||||
window[`ga-disable-${ga}`] = true;
|
||||
for (const c of document.cookie.split(';')) {
|
||||
const name = c.split('=')[0].trim();
|
||||
if (!/^_ga/.test(name)) continue;
|
||||
for (const domain of ['', `; domain=${location.hostname}`, `; domain=.${location.hostname.replace(/^www\./, '')}`]) {
|
||||
document.cookie = `${name}=; max-age=0; path=/${domain}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
// both answers look alike, and nothing is preselected: focus moves only when the visitor asked for the card
|
||||
const show = (asked) => { box.hidden = false; if (asked) box.querySelector('[data-consent-no]').focus({ preventScroll: true }); };
|
||||
const choose = (v) => {
|
||||
save(v);
|
||||
box.hidden = true;
|
||||
if (v === 'granted') { window[`ga-disable-${ga}`] = false; load(); } else drop();
|
||||
};
|
||||
box.querySelector('[data-consent-yes]').addEventListener('click', () => choose('granted'));
|
||||
box.querySelector('[data-consent-no]').addEventListener('click', () => choose('denied'));
|
||||
document.addEventListener('click', (e) => {
|
||||
const open = e.target.closest && e.target.closest('[data-cookie-settings]');
|
||||
if (open) { e.preventDefault(); show(true); }
|
||||
});
|
||||
const choice = read();
|
||||
if (choice === 'granted') load();
|
||||
else if (!choice) {
|
||||
if (navigator.globalPrivacyControl) save('denied');
|
||||
else show(false);
|
||||
}
|
||||
// downloads straight from GitHub's release links (the download page reports its own)
|
||||
document.addEventListener('click', (e) => {
|
||||
const a = e.target.closest && e.target.closest('a[href*="/releases/download/"]');
|
||||
if (a) window.fpTrack('file_download', { file_name: a.href.split('/').pop(), link_url: a.href });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</>
|
||||
) : <script is:inline>window.fpTrack = () => {};</script>}
|
||||
|
||||
<style>
|
||||
.consent { position: fixed; z-index: 90; left: 16px; bottom: 16px; width: min(380px, calc(100% - 32px)); display: grid; gap: 12px;
|
||||
padding: 16px 18px; border-radius: var(--radius-md); background: rgba(22, 24, 30, .96); border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, .6); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
font-size: 14px; line-height: 1.5; color: var(--text-2); animation: rise .45s var(--ease-out); }
|
||||
.consent[hidden] { display: none; }
|
||||
.consent::before { content: ""; position: absolute; inset: 0 0 auto; height: 2px; border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
background: var(--portal); opacity: .8; }
|
||||
.consent p { margin: 0; }
|
||||
.consent b { color: var(--text); }
|
||||
.consent a { color: var(--secondary); white-space: nowrap; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.row .btn { justify-content: center; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(12px); } }
|
||||
@media (prefers-reduced-motion: reduce) { .consent { animation: none; } }
|
||||
</style>
|
||||
@@ -99,7 +99,7 @@ const groups = [...new Set(DOCS.map((d) => d.group))];
|
||||
@media (max-width: 1180px) { .docs { grid-template-columns: 210px minmax(0, 1fr); } .dtoc { display: none; } }
|
||||
@media (max-width: 900px) {
|
||||
.docs { grid-template-columns: minmax(0, 1fr); gap: 16px; padding-top: 20px; }
|
||||
.dnav { position: relative; z-index: 20; max-height: none; overflow: visible; }
|
||||
.dnav { position: relative; top: auto; z-index: 20; max-height: none; overflow: visible; }
|
||||
.dnav summary { cursor: pointer; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
.dnav summary::after { content: " ▾"; }
|
||||
.keys { display: none; }
|
||||
|
||||
@@ -57,6 +57,8 @@ const joiner = (i: number, n: number) => (i === 0 ? '' : i === n - 1 ? ' and ' :
|
||||
<a href={url('docs/')}>Docs</a>
|
||||
<a href={REPORT_PROBLEM}>Report a problem</a>
|
||||
<a href={url('install/')}>Install links</a>
|
||||
<a href={url('privacy/')}>Privacy</a>
|
||||
{import.meta.env.PUBLIC_GA_ID && <a href={url('privacy/')} data-cookie-settings>Cookie settings</a>}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -28,7 +28,7 @@ const { games } = Astro.props;
|
||||
))}
|
||||
<li><a href={RELEASES}>All releases</a></li>
|
||||
</ul>
|
||||
<p class="facts">Free and open source · GPL-3.0 · Wi-Fi or USB cable · Proof of concept · Not affiliated with Valve or Meta</p>
|
||||
<p class="facts">Free and open source · GPL-3.0 · Wi-Fi or USB cable · Not affiliated with Valve or Meta</p>
|
||||
</div>
|
||||
<div class="scene">
|
||||
<Crossing games={games} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import HeadsetModel from './HeadsetModel.astro';
|
||||
import { DOWNLOADS, RELEASES, downloadPage, url } from '../data/site';
|
||||
|
||||
const FIRST = Object.fromEntries(DOWNLOADS.map((d) => [d.os, d.first]));
|
||||
const LINE = 'curl -sL frameport.app/s | bash';
|
||||
const LINE = 'curl -fsSL https://frameport.app/s | bash';
|
||||
const steps = [
|
||||
{ side: 'pc', where: 'On your PC', title: 'Download FramePort' },
|
||||
{ side: 'pc', where: 'On your PC', title: 'Start setup' },
|
||||
@@ -183,7 +183,7 @@ const steps = [
|
||||
const panes = [...wt.querySelectorAll<HTMLElement>('[data-pane]')];
|
||||
const pips = [...wt.querySelectorAll<HTMLButtonElement>('[data-go]')];
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const LINE = 'curl -sL frameport.app/s | bash';
|
||||
const LINE = 'curl -fsSL https://frameport.app/s | bash';
|
||||
let i = 0, timers: number[] = [];
|
||||
const later = (fn: () => void, ms: number) => timers.push(window.setTimeout(fn, reduce ? 0 : ms));
|
||||
const digits = String(1000 + Math.floor(Math.random() * 9000));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p><strong>Notice:</strong> FramePort doesn't download, share or unlock games. Use games you got legally, for example from
|
||||
<a href="https://sidequestvr.com/">SideQuest</a>. FramePort downloads published open-source tools (see
|
||||
<a href="#credits">Built on</a>) and adds its own patches so games run on the Frame.</p>
|
||||
<p class="poc">FramePort is a proof of concept.</p>
|
||||
<p class="poc">Not every game runs on the Frame: recipes are tested by the community, and some games need Meta's services or hardware the Frame lacks.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ROWS: Row[] = [
|
||||
y('All three; you install Proton first'),
|
||||
p('Yes; 2D Android apps need an extra file')] },
|
||||
{ label: 'PC VR games?', cells: [y('On your PC; some also on the Frame'), n('Not supported'), n('Not supported')] },
|
||||
{ label: 'Do "Install with …" buttons on websites work?', cells: [y("Its own and FrameDrop's"), y("FrameDrop's (it defined them)"), n('No')] },
|
||||
{ label: 'Do "Install with …" buttons on websites work?', cells: [y('Yes (frameport.app links)'), y('Its own'), n('No')] },
|
||||
{ label: "What if a game doesn't start?", cells: [y('A launch test reads the logs and suggests a patch'), p('A log viewer shows the log'), n('No help')] },
|
||||
{ label: 'Can I see and use the Frame from my PC?', cells: [y('Live view, Monitor, Files, Screenshots, Type on Frame'), u(), n('No')] },
|
||||
];
|
||||
@@ -61,6 +61,5 @@ export const ROWS: Row[] = [
|
||||
export const SOURCES = [
|
||||
{ label: 'FrameDrop: about', href: 'https://framedropvr.com/about' },
|
||||
{ label: 'FrameDrop: how-to', href: 'https://framedropvr.com/how-to' },
|
||||
{ label: 'FrameDrop: install buttons', href: 'https://framedropvr.com/docs' },
|
||||
{ label: 'Valve: loading games on Steam Frame', href: 'https://partner.steamgames.com/doc/steamhardware/steamframe/loadgames' },
|
||||
];
|
||||
@@ -5,10 +5,10 @@
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 380,
|
||||
"commits": 462,
|
||||
"issues": 4,
|
||||
"prs": 2,
|
||||
"total": 402
|
||||
"total": 484
|
||||
},
|
||||
{
|
||||
"login": "tkp206093",
|
||||
@@ -415,6 +415,15 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "trankillity",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/53357041?v=4",
|
||||
"url": "https://github.com/trankillity",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "TylerJaacks",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1505374?v=4",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Marked, type Tokens } from 'marked';
|
||||
import { REPO, url } from './site';
|
||||
import { fileTree } from '../scripts/filetree';
|
||||
|
||||
export interface DocInfo { file: string; slug: string; name: string; group: string; blurb: string; summary: string }
|
||||
export const DOCS: DocInfo[] = [
|
||||
@@ -89,6 +90,17 @@ export function loadDoc(info: DocInfo): Doc {
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
// ```tree blocks (folder layouts) become a real file tree; every other code block stays as it is
|
||||
code({ text, lang }) {
|
||||
return lang === 'tree' ? fileTree(text) : false;
|
||||
},
|
||||
// diagrams (SVG) keep a readable size on phones: they scroll sideways there (and still open large on a tap)
|
||||
image({ href, text }) {
|
||||
if (!/\.svg$/i.test(href)) return false;
|
||||
const alt = text.replace(/"/g, '"');
|
||||
return `<figure class="diagram"><div class="diagram-scroll"><img src="${href}" alt="${alt}" loading="lazy" /></div>`
|
||||
+ '<figcaption>Scroll sideways or tap to enlarge.</figcaption></figure>';
|
||||
},
|
||||
heading({ tokens, depth, text }) {
|
||||
const id = slug(text);
|
||||
if (depth <= 3) toc.push({ id, text: text.replace(/[`*_]/g, ''), depth });
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
// Values the pages workflow passes in from the repo's variables (both optional; see components/Analytics.astro).
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_CF_ANALYTICS_TOKEN?: string;
|
||||
readonly PUBLIC_GA_ID?: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
/** records an analytics event once the visitor allowed Google Analytics; else does nothing */
|
||||
fpTrack?: (name: string, params?: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Analytics from '../components/Analytics.astro';
|
||||
import { DOWNLOADS, downloadPage, url } from '../data/site';
|
||||
|
||||
interface Props {
|
||||
@@ -49,6 +50,7 @@ const feed = new URL(url('changelog/feed.xml'), Astro.site);
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
<Analytics />
|
||||
<script define:vars={{ downloads: DOWNLOADS.map((d) => ({ os: d.os, label: d.label, href: downloadPage(d.os) })) }}>
|
||||
// The download buttons offer the build for this computer; the other platforms stay listed next to them.
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
@@ -161,6 +161,7 @@ const builds = DOWNLOADS.map((d) => ({ os: d.os, label: d.label, detail: d.detai
|
||||
const go = () => {
|
||||
try { sessionStorage.setItem(KEY, String(Date.now())); } catch { /* no storage */ }
|
||||
sent(b, false);
|
||||
window.fpTrack?.('file_download', { file_name: b.href.split('/').pop(), os: b.os });
|
||||
// the download itself: the release asset answers with an attachment, so this page stays
|
||||
setTimeout(() => location.assign(b.href), reduce ? 0 : 520);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
// What the site (not the app) records. Kept in step with components/Analytics.astro.
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import Nav from '../../components/Nav.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import { REPORT_PROBLEM } from '../../data/site';
|
||||
|
||||
const cf = !!import.meta.env.PUBLIC_CF_ANALYTICS_TOKEN;
|
||||
const ga = !!import.meta.env.PUBLIC_GA_ID;
|
||||
---
|
||||
<Layout title="Privacy · FramePort" description="What frameport.app records about visits, and how to turn analytics cookies off.">
|
||||
<Nav home={false} />
|
||||
<main class="pv">
|
||||
<div class="wrap inner">
|
||||
<p class="label"><b>Privacy</b> frameport.app</p>
|
||||
<h1>Privacy</h1>
|
||||
<p class="lead">This page is about the website. The FramePort app itself has no analytics or tracking.</p>
|
||||
|
||||
<section>
|
||||
<h2>What the site records</h2>
|
||||
<ul>
|
||||
{cf && <li><b>Cloudflare Web Analytics</b> counts page views (page, referrer, country, browser) without
|
||||
cookies or anything that identifies you.</li>}
|
||||
{ga && <li><b>Google Analytics</b> counts visits and downloads, but only if you click <b>Allow</b>. It then
|
||||
sets cookies (<code>_ga</code>) to tell visits apart. Google doesn't store IP addresses.</li>}
|
||||
{!cf && !ga && <li>Nothing: this build of the site has no analytics.</li>}
|
||||
<li>The site is hosted on <b>GitHub Pages</b>, which keeps technical logs (including IP addresses) for
|
||||
security.</li>
|
||||
<li>The site stores a few settings in your browser only (your cookie choice, docs you've read, a
|
||||
remembered tab). They never leave your browser.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{ga && (
|
||||
<section>
|
||||
<h2>Change your choice</h2>
|
||||
<p>Open <a href="#" data-cookie-settings>Cookie settings</a> to allow or decline analytics cookies at any
|
||||
time. Declining removes Google's cookies for this site. A browser that sends Global Privacy Control is
|
||||
treated as Decline.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2>Questions</h2>
|
||||
<p><a href={REPORT_PROBLEM}>Report a problem</a> on GitHub.</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.pv { background: var(--portal-glow); padding-block: clamp(40px, 7vw, 80px) 96px; }
|
||||
.inner { max-width: 760px; display: grid; gap: 24px; }
|
||||
h1 { font-size: clamp(36px, 6vw, 56px); line-height: 1.05; font-weight: 800; letter-spacing: -0.03em; }
|
||||
h2 { font-size: 22px; margin-bottom: 10px; }
|
||||
ul { margin: 0; padding-left: 20px; display: grid; gap: 8px; color: var(--text-2); }
|
||||
p { color: var(--text-2); margin: 0; }
|
||||
b { color: var(--text); }
|
||||
code { font: 500 .9em var(--font-mono); }
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@ import Nav from '../../components/Nav.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import { downloadPage, url } from '../../data/site';
|
||||
|
||||
const line = 'curl -sL frameport.app/s | bash';
|
||||
const line = 'curl -fsSL https://frameport.app/s | bash';
|
||||
---
|
||||
<Layout title="Set up your Steam Frame · FramePort"
|
||||
description="Set up a Steam Frame for FramePort: run one line on the Frame, click Allow on your PC.">
|
||||
@@ -98,7 +98,7 @@ const line = 'curl -sL frameport.app/s | bash';
|
||||
const term = document.querySelector<HTMLElement>('[data-term]');
|
||||
const digits = String(1000 + Math.floor(Math.random() * 9000));
|
||||
const LINES: [number, string, string?][] = [
|
||||
[0, '$ curl -sL frameport.app/s | bash', 'tcmd'],
|
||||
[0, '$ curl -fsSL https://frameport.app/s | bash', 'tcmd'],
|
||||
[600, '==> FramePort setup for steamframe', 'h'],
|
||||
[300, 'Looking for FramePort on your network...'],
|
||||
[1400, 'Found FramePort on DESKTOP-PC (192.168.1.20:8765), PC words: amber-otter'],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { url } from '../data/site';
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const pages: [string, string?][] = [
|
||||
[''], ['setup/'], ['download/'], ['changelog/', LATEST?.date], ['docs/'], ...DOCS.map((d) => [`docs/${d.slug}/`] as [string]),
|
||||
[''], ['setup/'], ['download/'], ['changelog/', LATEST?.date], ['docs/'], ['privacy/'], ...DOCS.map((d) => [`docs/${d.slug}/`] as [string]),
|
||||
];
|
||||
const body = pages.map(([path, mod]) => ` <url><loc>${new URL(url(path), site)}</loc>${mod ? `<lastmod>${mod}</lastmod>` : ''}</url>`);
|
||||
return new Response(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileTree, parseTree } from './filetree.ts';
|
||||
|
||||
const FAQ = `Games/ ← scan this folder
|
||||
├── Resident Evil 4/ ← one folder per game
|
||||
│ ├── VR4.apk ← the APK
|
||||
│ └── com.Armature.VR4/ ← the data folder
|
||||
│ ├── main.203.com.Armature.VR4.obb
|
||||
│ └── patch.203.com.Armature.VR4.obb
|
||||
└── Beat Saber/
|
||||
└── com.beatgames.beatsaber.apk ← just the APK`;
|
||||
|
||||
test('a tree listing becomes nested entries with notes', () => {
|
||||
const [root] = parseTree(FAQ);
|
||||
assert.equal(root.name, 'Games/');
|
||||
assert.equal(root.note, 'scan this folder');
|
||||
assert.deepEqual(root.children.map((c) => c.name), ['Resident Evil 4/', 'Beat Saber/']);
|
||||
const re4 = root.children[0];
|
||||
assert.deepEqual(re4.children.map((c) => c.name), ['VR4.apk', 'com.Armature.VR4/']);
|
||||
assert.equal(re4.children[1].children.length, 2);
|
||||
assert.equal(root.children[1].children[0].note, 'just the APK');
|
||||
});
|
||||
|
||||
test('folders and files are marked, names escaped', () => {
|
||||
const html = fileTree(FAQ);
|
||||
assert.match(html, /<li class="ft-dir" data-kind="dir">/);
|
||||
assert.match(html, /data-kind="apk"[^]*VR4\.apk/);
|
||||
assert.match(html, /data-kind="obb"/);
|
||||
assert.match(fileTree('a<b>/'), /a<b>\//);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// A ```tree block in the docs (a `tree`-style listing, "← note" after an entry) as a real file tree on the site:
|
||||
// folders and files with icons, guide lines and notes. GitHub shows the same block as plain text.
|
||||
|
||||
interface Node { name: string; note: string; depth: number; children: Node[] }
|
||||
|
||||
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
const FOLDER = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.6l2 2h8.4A1.5 1.5 0 0 1 21 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z"/></svg>';
|
||||
const FILE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 3h7.5l4.5 4.5v12A1.5 1.5 0 0 1 17 21H6.5A1.5 1.5 0 0 1 5 19.5v-15A1.5 1.5 0 0 1 6.5 3z"/><path d="M13.5 3v5h5"/></svg>';
|
||||
|
||||
/** The listing's entries with their depth (one level = 4 characters of ├── / │ / └── / spaces). */
|
||||
export function parseTree(text: string): Node[] {
|
||||
const roots: Node[] = [];
|
||||
const stack: Node[] = [];
|
||||
for (const raw of text.replace(/\s+$/, '').split('\n')) {
|
||||
if (!raw.trim()) continue;
|
||||
const m = /^((?:[│|] | )*)(?:[├└|`][─-]{2} )?(.*)$/.exec(raw)!;
|
||||
const depth = m[1].length / 4 + (m[0].length > m[1].length && /^[├└|`]/.test(raw.slice(m[1].length)) ? 1 : 0);
|
||||
const [name, note = ''] = m[2].split(/\s*←\s*/);
|
||||
const node: Node = { name: name.trim(), note: note.trim(), depth, children: [] };
|
||||
while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();
|
||||
(stack.length ? stack[stack.length - 1].children : roots).push(node);
|
||||
stack.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function item(n: Node): string {
|
||||
const folder = n.name.endsWith('/') || n.children.length > 0;
|
||||
const kind = folder ? 'dir' : (/\.(\w+)$/.exec(n.name)?.[1] ?? 'file').toLowerCase();
|
||||
const kids = n.children.length ? `<ul>${n.children.map(item).join('')}</ul>` : '';
|
||||
return `<li class="ft-${folder ? 'dir' : 'file'}" data-kind="${esc(kind)}"><span class="ft-row">${folder ? FOLDER : FILE}`
|
||||
+ `<code class="ft-name">${esc(n.name)}</code>${n.note ? `<span class="ft-note">${esc(n.note)}</span>` : ''}</span>${kids}</li>`;
|
||||
}
|
||||
|
||||
export function fileTree(text: string): string {
|
||||
return `<div class="ftree" role="group" aria-label="Folder layout"><ul>${parseTree(text).map(item).join('')}</ul></div>\n`;
|
||||
}
|
||||
@@ -438,7 +438,7 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
if (s.link !== 'usb') { // the setup page's line: the same everywhere, the Frame finds this PC and asks
|
||||
dialog(`<h4>Set up your Steam Frame</h4>`
|
||||
+ `<p>On the Frame, open the SteamVR dashboard → <b>Launch a program → Desktop</b>, then app menu → <b>System → Konsole</b>, and run:</p>`
|
||||
+ `<div class="cmdl"><code>curl -sL frameport.app/s | bash</code></div>`
|
||||
+ `<div class="cmdl"><code>curl -fsSL https://frameport.app/s | bash</code></div>`
|
||||
+ `<p class="small">It finds this PC ("amber-otter") and asks. You allow it here.</p>`
|
||||
+ `<div data-ask></div><ul class="progress" data-prog></ul>`
|
||||
+ `<div class="row"><button class="btn-p" data-act="ran">I ran it on the Frame</button><button class="btn-s" data-act="close">Close</button></div>`);
|
||||
|
||||
@@ -361,3 +361,36 @@ dialog.zoom::backdrop { background: rgba(5, 6, 8, .86); backdrop-filter: blur(4p
|
||||
dialog.zoom img { display: block; max-width: 96vw; max-height: 88vh; border-radius: var(--radius-md); }
|
||||
dialog.zoom p { margin: 10px 0 0; text-align: center; color: var(--text-2); font-size: 14px; }
|
||||
@media (prefers-reduced-motion: reduce) { .prose :is(h2, h3).flash, .done > .tick svg { animation: none; } .prose a.playable:hover img { transform: none; } }
|
||||
|
||||
/* docs: folder layouts (```tree blocks, scripts/filetree.ts) */
|
||||
.prose .ftree { margin: 0 0 16px; padding: 16px 18px; border-radius: var(--radius-md); background: var(--surface-2);
|
||||
border: 1px solid var(--border); overflow-x: auto; }
|
||||
.prose .ftree ul { list-style: none; margin: 0; padding: 0; }
|
||||
.prose .ftree li { margin: 0; }
|
||||
.prose .ftree li ul { margin-left: 9px; padding-left: 18px; border-left: 1px solid var(--border-strong); }
|
||||
.prose .ftree .ft-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 5px 0; position: relative; }
|
||||
.prose .ftree li li > .ft-row::before { content: ""; position: absolute; left: -18px; top: 50%; width: 12px; height: 1px;
|
||||
background: var(--border-strong); }
|
||||
.prose .ftree svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6;
|
||||
stroke-linejoin: round; color: var(--text-3); }
|
||||
.prose .ftree .ft-dir > .ft-row svg { color: var(--secondary); fill: rgba(58, 168, 255, 0.14); }
|
||||
.prose .ftree [data-kind="apk"] > .ft-row svg { color: var(--accent); fill: rgba(255, 138, 31, 0.12); }
|
||||
.prose .ftree [data-kind="obb"] > .ft-row svg { color: var(--warn); }
|
||||
.prose .ftree .ft-name { padding: 0; background: none; font-size: 14px; color: var(--text); white-space: nowrap; }
|
||||
.prose .ftree .ft-dir > .ft-row .ft-name { font-weight: 600; }
|
||||
.prose .ftree .ft-note { font-size: 13.5px; color: var(--text-2); }
|
||||
.prose .ftree .ft-note::before { content: "— "; color: var(--text-3); }
|
||||
|
||||
/* docs: diagrams (SVG images) */
|
||||
.prose .diagram { margin: 0 0 20px; }
|
||||
.prose .diagram-scroll { overflow-x: auto; border-radius: 18px; }
|
||||
.prose .diagram img { display: block; width: 100%; height: auto; border-radius: 18px; }
|
||||
.prose .diagram figcaption { display: none; margin-top: 6px; font-size: 12.5px; color: var(--text-3); }
|
||||
@media (max-width: 760px) {
|
||||
.prose .diagram img { width: 760px; max-width: none; }
|
||||
.prose .diagram figcaption { display: block; }
|
||||
.prose .ftree .ft-name { font-size: 13px; }
|
||||
.prose .ftree .ft-row { display: grid; grid-template-columns: 18px auto; column-gap: 8px; row-gap: 0; }
|
||||
.prose .ftree .ft-note { grid-column: 2; font-size: 12.5px; white-space: normal; }
|
||||
.prose .ftree .ft-note::before { content: none; }
|
||||
}
|
||||
@@ -230,7 +230,8 @@ def inspect(path: Path) -> dict:
|
||||
appimage = is_appimage(path)
|
||||
return {"root": str(path.parent), "exe": path.name, "files": [path.name], "appimage": appimage,
|
||||
"machine": machine, "arch_ok": machine == EM_AARCH64, "openxr": uses_openxr([path]),
|
||||
"title": title, "candidates": [path.name]}
|
||||
"title": title, "candidates": [path.name],
|
||||
"vr_overlay": False, "vr_overlay_app": None, "vr_overlay_from": ""}
|
||||
programs = rank_programs(root)
|
||||
if not programs:
|
||||
raise ValueError(f"no Linux program found in {path.name}")
|
||||
@@ -246,4 +247,18 @@ def inspect(path: Path) -> dict:
|
||||
return {"root": str(root), "exe": exe.relative_to(root).as_posix(), "files": None,
|
||||
"appimage": is_appimage(exe), "machine": machine, "arch_ok": machine == EM_AARCH64,
|
||||
"openxr": uses_openxr([exe] + libs), "title": title,
|
||||
"candidates": [p.relative_to(root).as_posix() for p, m in programs[:20] if m == machine]}
|
||||
"candidates": [p.relative_to(root).as_posix() for p, m in programs[:20] if m == machine],
|
||||
**overlay_info(root, exe, libs)}
|
||||
|
||||
|
||||
def overlay_info(root: Path, exe: Path, libs: list[Path]) -> dict:
|
||||
"""A SteamVR overlay app (analysis/vroverlay.py): a bundled .vrmanifest, or an OpenVR program using IVROverlay."""
|
||||
from . import vroverlay
|
||||
|
||||
openvr = any(p.name.startswith(("libopenvr_api", "openvr_api")) for p in libs) or \
|
||||
any(p.name.startswith("libopenvr_api") for p in root.rglob("libopenvr_api*.so"))
|
||||
try:
|
||||
data = exe.read_bytes() if exe.stat().st_size <= MAX_SCAN else b""
|
||||
except OSError:
|
||||
data = b""
|
||||
return vroverlay.detect(root, exe, data, openvr)
|
||||
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.models import Analysis
|
||||
from . import vroverlay
|
||||
|
||||
MACHINES = {0x14C: "x86", 0x8664: "x86_64", 0xAA64: "arm64"}
|
||||
GUI_SUBSYSTEM = 2
|
||||
@@ -39,11 +40,16 @@ PLATFORM_DLLS = ("libovrplatform64_1.dll", "libovrplatform32_1.dll")
|
||||
# engine DLLs that wrap the Platform SDK (Ready At Dawn's platform services: Lone Echo, Echo Arena)
|
||||
PLATFORM_WRAPPERS = ("pnsovr.dll",)
|
||||
GRAPHICS = (("d3d12.dll", "D3D12"), ("d3d11.dll", "D3D11"), ("vulkan-1.dll", "Vulkan"), ("opengl32.dll", "OpenGL"))
|
||||
# bump when analyze() learns something new: folders analysed by an older version are analysed again at the next scan
|
||||
# (part of the fingerprint). 2: vr_overlay (SteamVR overlay apps), pyopenvr's libopenvr_api_64.dll
|
||||
RIFT_ANALYSIS = 2
|
||||
AMBIGUITY = 15 # a runner-up within this many points means "ask the user"
|
||||
# repacks start their bundled Revive through a proxy DLL next to the exe (Windows loads DLLs from the exe's folder
|
||||
# first)
|
||||
LOADER_DLLS = ("xinput1_3.dll", "xinput1_4.dll", "xinput9_1_0.dll", "dinput8.dll", "version.dll", "winmm.dll")
|
||||
REVIVE_DLLS = ("librevive64.dll", "librevive32.dll", "librevivexr64.dll", "librevivexr32.dll")
|
||||
# OpenVR's client library under its usual names (games ship openvr_api.dll; pyopenvr apps libopenvr_api_64.dll)
|
||||
OPENVR_DLLS = ("openvr_api64.dll", "openvr_api32.dll", "openvr_api.dll", "libopenvr_api_64.dll", "libopenvr_api_32.dll")
|
||||
|
||||
|
||||
class PEError(Exception):
|
||||
@@ -379,7 +385,7 @@ def fingerprint(folder: Path, exe_rel: str) -> str:
|
||||
p = Path(folder) / exe_rel
|
||||
try:
|
||||
st = p.stat()
|
||||
return f"{exe_rel}:{st.st_size}:{int(st.st_mtime)}"
|
||||
return f"{exe_rel}:{st.st_size}:{int(st.st_mtime)}:{RIFT_ANALYSIS}"
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
@@ -423,7 +429,7 @@ def analyze(folder: Path, exe: str | None = None, tree: Tree | None = None) -> A
|
||||
# whether or not it uses them, so this alone does NOT prove the game runs on SteamVR — it only matters for routing
|
||||
# when the game has NO Oculus/LibOVR code (see frame_native below). We ignore the (universal in UE) IVRSystem/
|
||||
# VR_InitInternal string markers for exactly this reason.
|
||||
openvr = any(d in info.imports or d in names for d in ("openvr_api64.dll", "openvr_api32.dll", "openvr_api.dll"))
|
||||
openvr = any(d in info.imports or d in names for d in OPENVR_DLLS)
|
||||
xr = "+".join(n for n, on in (("LibOVR", libovr), ("OpenXR", openxr), ("OpenVR", openvr)) if on) or "?"
|
||||
gfx_imports = set(info.imports)
|
||||
up = exe_path.parent / "UnityPlayer.dll"
|
||||
@@ -453,6 +459,7 @@ def analyze(folder: Path, exe: str | None = None, tree: Tree | None = None) -> A
|
||||
exe_dir = set()
|
||||
loader = sorted(n for n in exe_dir if n in LOADER_DLLS) if exe_dir & set(REVIVE_DLLS) else []
|
||||
mode, args, args_from = launch_mode(folder, chosen, engine, libovr, openxr, openvr, loader)
|
||||
overlay = vroverlay.detect(folder, exe_path, data, openvr, engine) # a SteamVR overlay app (pcvr.vr_overlay)
|
||||
canonical_hint = next((part for part in exe_path.relative_to(folder).parts[:-1]
|
||||
if re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+){2,}", part)), None) # Oculus Software dir name
|
||||
title = (manifest or {}).get("displayName") or clean_title(folder.name)
|
||||
@@ -473,6 +480,7 @@ def analyze(folder: Path, exe: str | None = None, tree: Tree | None = None) -> A
|
||||
# (SteamVR/OpenXR: run the exe directly, with launch_args) or "revive" (inject FramePort's Revive)
|
||||
"launch": mode, "loader_dlls": loader, "launch_args": args, "launch_args_from": args_from,
|
||||
"frame_native": mode == "native",
|
||||
**overlay,
|
||||
"needs_revive": mode == "revive",
|
||||
"vr_found": xr != "?", "data_bytes": tree.size, "files": tree.files,
|
||||
# a LibRevive*.dll anywhere in the folder (see "launch" for whether the game actually starts it)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SteamVR overlay apps (OpenVR VRApplication_Overlay: fpsVR, wrist watches, ...): programs that draw no scene of their
|
||||
own; SteamVR's compositor draws their overlays over whatever runs, on the Frame Lepton (Quest) and Proton (PC VR)
|
||||
games included (docs/FRAME_RUNTIME.md "Overlay apps"). FramePort registers them with SteamVR (pcvr.vr_overlay /
|
||||
a Linux app's "vr_overlay" field; agent register_vr_overlay).
|
||||
|
||||
Detection, for Windows programs (analysis/rift.py) and Linux apps (analysis/linux.py):
|
||||
- proof: a SteamVR application manifest (*.vrmanifest) next to the program or at the folder's top that declares a
|
||||
dashboard overlay (`is_dashboard_overlay`). Its entry (app key, name, image, arguments) is kept so FramePort
|
||||
registers the app under its own key, with paths made absolute on the Frame;
|
||||
- heuristic: an OpenVR program without a game engine that uses IVROverlay and registers itself with SteamVR
|
||||
("vrmanifest" / "VRApplication_Overlay" in its code; C# apps keep strings as UTF-16).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
OVERLAY_MARKERS = (b"IVROverlay_",)
|
||||
SELF_REGISTER_MARKERS = (b"vrmanifest", b"VRApplication_Overlay")
|
||||
APP_FIELDS = ("app_key", "image_path", "strings", "arguments")
|
||||
|
||||
|
||||
def has_markers(blob: bytes, markers) -> bool:
|
||||
return any(m in blob or m.decode().encode("utf-16-le") in blob for m in markers)
|
||||
|
||||
|
||||
def manifests(dirs) -> list[Path]:
|
||||
"""*.vrmanifest files directly in these folders (no duplicates)."""
|
||||
found = []
|
||||
for d in dict.fromkeys(Path(x) for x in dirs):
|
||||
try:
|
||||
found += sorted(p for p in d.iterdir() if p.suffix.lower() == ".vrmanifest" and p.is_file())
|
||||
except OSError:
|
||||
pass
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def bundled_overlay(root: Path, dirs) -> dict | None:
|
||||
"""The first bundled manifest entry that declares a dashboard overlay:
|
||||
{"app": {app_key, image_path, strings, arguments}, "manifest": <path relative to root>, "manifest_dir": <its
|
||||
folder relative to root>}, or None."""
|
||||
root = Path(root)
|
||||
for m in manifests(dirs):
|
||||
try:
|
||||
data = json.loads(m.read_text(encoding="utf-8-sig", errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
apps = data.get("applications") if isinstance(data, dict) else None
|
||||
for app in apps if isinstance(apps, list) else []:
|
||||
if isinstance(app, dict) and app.get("is_dashboard_overlay"):
|
||||
try:
|
||||
rel = m.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
return {"app": {k: app[k] for k in APP_FIELDS if k in app}, "manifest": rel.as_posix(),
|
||||
"manifest_dir": rel.parent.as_posix() or "."}
|
||||
return None
|
||||
|
||||
|
||||
def detect(root: Path, program: Path, program_data: bytes, openvr: bool, engine: str = "Other") -> dict:
|
||||
"""{"vr_overlay": bool, "vr_overlay_from": why, "vr_overlay_app": bundled_overlay() or None} for a program."""
|
||||
bundled = bundled_overlay(root, (program.parent, root))
|
||||
if bundled:
|
||||
return {"vr_overlay": True, "vr_overlay_app": bundled,
|
||||
"vr_overlay_from": f"{Path(bundled['manifest']).name} declares a SteamVR dashboard overlay"}
|
||||
if openvr and engine == "Other" and has_markers(program_data, OVERLAY_MARKERS) and \
|
||||
has_markers(program_data, SELF_REGISTER_MARKERS):
|
||||
return {"vr_overlay": True, "vr_overlay_app": None,
|
||||
"vr_overlay_from": f"{program.name} is an OpenVR overlay program (IVROverlay, registers itself with "
|
||||
"SteamVR)"}
|
||||
return {"vr_overlay": False, "vr_overlay_app": None, "vr_overlay_from": ""}
|
||||
|
||||
|
||||
def install_args(extra: dict, autostart: bool) -> dict:
|
||||
"""The agent's `overlay` argument (finalize_pcvr / finalize_linux) for an analysis' extra."""
|
||||
bundled = extra.get("vr_overlay_app") or {}
|
||||
return {"autostart": bool(autostart), "app": bundled.get("app"), "manifest_dir": bundled.get("manifest_dir") or "."}
|
||||
@@ -532,7 +532,8 @@ def frame_pair(timeout: float = typer.Option(600, help="seconds to wait for the
|
||||
from .frame.connection import FrameTarget, save_target
|
||||
from .frame.pairing import PairingServer
|
||||
|
||||
srv = PairingServer().start()
|
||||
# no mDNS announcement: the setup URL's requests need someone to click Allow, which only the GUI offers
|
||||
srv = PairingServer(announce=False).start()
|
||||
typer.echo("On the Frame, open the SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run:\n\n " + srv.one_liner + "\n")
|
||||
end = time.time() + timeout
|
||||
@@ -855,7 +856,9 @@ def diag_report(package: Optional[str] = typer.Argument(None, help="the game (om
|
||||
typer.echo(f"wrote {path} — attach it to the issue")
|
||||
if browser:
|
||||
winhost.open_folder(path, select=True)
|
||||
_open(pipeline.problem_report(pkgs[0] if pkgs else None, description, path, info), browser)
|
||||
links = pipeline.problem_report_links(pkgs[0] if pkgs else None, description, path, info)
|
||||
_open(links.form, browser)
|
||||
typer.echo(f"If the form's fields are empty, open a plain issue instead:\n{links.plain}")
|
||||
|
||||
|
||||
@app.command("share-recipe")
|
||||
@@ -870,7 +873,9 @@ def share_recipe(package: str, status: str = typer.Option("works", help="works o
|
||||
info = None
|
||||
if frame:
|
||||
info = _target(frame).describe()
|
||||
_open(pipeline.share_working_config(pkg, status, notes, info), browser)
|
||||
links = pipeline.share_working_config_links(pkg, status, notes, info)
|
||||
_open(links.form, browser)
|
||||
typer.echo(f"If the form's fields are empty, open a plain issue instead:\n{links.plain}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -4,7 +4,8 @@ FrameDrop (framedropvr.com) defined it: a page links to `https://framedropvr.com
|
||||
`?url=<direct file URL>`), which opens `framedrop://install?manifest=…|url=…`. The manifest is JSON:
|
||||
`{"schema": "framedrop.install/v1", "name": "<Steam title>", "files": [{"url": "https://…/x.apk", "sha256": "…"}]}`
|
||||
with an arm64 APK or a zipped Linux ARM64 build. FramePort accepts the same links (`framedrop://` when it is the
|
||||
system's handler, its own `frameport://`, or the https link pasted into "Install from link…"), follows the same
|
||||
system's handler, its own `frameport://`, FramePort's `https://frameport.app/install?…` button page, or an https link
|
||||
pasted into "Install from link…"), follows the same
|
||||
rules (https only, http only on this PC, no credentials, no LAN addresses, a URL that ends in a file name) and
|
||||
always asks before it downloads anything a web page sent. `urlhandler.py` registers the schemes.
|
||||
"""
|
||||
@@ -22,7 +23,8 @@ from urllib.parse import parse_qs, unquote, urlsplit
|
||||
from .core.events import Reporter
|
||||
|
||||
SCHEMES = ("framedrop", "frameport")
|
||||
WEB_HOSTS = ("framedropvr.com", "www.framedropvr.com")
|
||||
# the https pages an install button links to: FramePort's own landing page, and FrameDrop's (older buttons)
|
||||
WEB_HOSTS = ("frameport.app", "www.frameport.app", "framedropvr.com", "www.framedropvr.com")
|
||||
SCHEMA = "framedrop.install/v1"
|
||||
MAX_MANIFEST = 256 << 10
|
||||
MAX_FILES = 16
|
||||
|
||||
@@ -44,7 +44,8 @@ Created by FramePort {version} on {created}. Personal data was replaced by place
|
||||
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game"""
|
||||
""" logs, file list |
|
||||
| games/<pkg>/target/shaders/<dir>/ | shader dumps (vk_shader_dump: fp_vk_shaders, zink_shader_dump: fp_spirv): the"""
|
||||
""" newest SPIR-V modules (binary, not redacted: compiled game shaders) and index.txt (creation order and time) |
|
||||
""" SPIR-V modules of the newest run, else the newest written (binary, not redacted: compiled game shaders) and"""
|
||||
""" index.txt (creation order and time) |
|
||||
|
||||
## Debugging from this bundle
|
||||
1. `frameport diag inspect <this zip>` re-runs triage with the current signatures (catalog/triage.yaml).
|
||||
@@ -104,7 +105,17 @@ class _Writer:
|
||||
logs = sorted((n for n in self.files if n.endswith((".log", ".txt")) and len(self.files[n]) > 64 << 10),
|
||||
key=lambda n: -len(self.files[n]))
|
||||
if not logs:
|
||||
break
|
||||
# then shader modules (agent >= 74 sends up to 30 MB): the least recently used first (the agent
|
||||
# lists them last used first)
|
||||
spv = [n for n in self.files if n.endswith(".spv")]
|
||||
if not spv:
|
||||
break
|
||||
drop = spv[-max(1, len(spv) // 8):]
|
||||
for n in drop:
|
||||
del self.files[n]
|
||||
self.warnings.append(f"{len(drop)} shader module(s) left out to fit the size limit")
|
||||
size = zipped()
|
||||
continue
|
||||
n = logs[0]
|
||||
tail = self.files[n][-(len(self.files[n]) // 4):]
|
||||
self.files[n] = b"[... cut to fit the 25 MB attachment limit ...]\n" + tail
|
||||
@@ -130,6 +141,11 @@ def add_shader_dumps(w: _Writer, prefix: str, shaders: dict) -> None:
|
||||
w.files[base + name] = base64.b64decode(b64, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
w.warnings.append(f"{base}{name}: not base64")
|
||||
if "session" in res: # agent >= 74: the newest session's modules (index.txt), not the newest written
|
||||
left = int(res.get("session") or 0) - len(modules)
|
||||
if left > 0:
|
||||
w.warnings.append(f"{base}: {left} module(s) of the newest session stayed on the Frame (size limit)")
|
||||
continue
|
||||
left = int(res.get("total") or 0) - len(modules)
|
||||
if left > 0:
|
||||
w.warnings.append(f"{base}: {left} older module(s) stayed on the Frame (size limit)")
|
||||
|
||||
+110
-13
@@ -3,9 +3,16 @@
|
||||
No token: the browser opens the form, the user reviews it, drags the diagnostics zip in (GitHub has no API for issue
|
||||
attachments) and submits. Everything is redacted; URLs stay under ~7.5k characters (longer ones get cut by
|
||||
browsers/GitHub), trimming free text first and never the recipe.
|
||||
|
||||
Some browsers open the form with the title but every field empty (GitHub #167: Flatpak browsers on Linux), so each
|
||||
link also comes as the Markdown body GitHub's issue forms write (`### <label>` sections, the recipe as a ```yaml
|
||||
block): the GUI copies it to the clipboard and offers a plain issue (`?title=…&body=…`) with that body, which
|
||||
scripts/catalog_from_issue.py reads the same way.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .. import REPO_URL as REPO
|
||||
@@ -13,6 +20,21 @@ from . import redact
|
||||
|
||||
MAX_URL = 7500
|
||||
|
||||
# field id → (label, render) as in .github/ISSUE_TEMPLATE/*.yml (tests check they match)
|
||||
WORKING_CONFIG_FIELDS = {"game": ("Game", None), "result": ("Result", None), "recipe": ("Recipe", "yaml"),
|
||||
"environment": ("Environment", None), "notes": ("Notes", None)}
|
||||
WORKING_CONFIG_CONFIRM = ("Confirmation", "I played the game in the headset with this recipe.")
|
||||
PROBLEM_FIELDS = {"game": ("Game", None), "description": ("What happens?", None), "findings": ("Launch test", None),
|
||||
"recipe": ("Recipe", "yaml"), "environment": ("Environment", None), "logs": ("Diagnostics", None)}
|
||||
NO_RESPONSE = "_No response_" # what GitHub writes for an empty field
|
||||
|
||||
|
||||
class IssueLinks(NamedTuple):
|
||||
"""The issue form link, a plain issue link with the same content, and that content as Markdown."""
|
||||
form: str
|
||||
plain: str
|
||||
body: str
|
||||
|
||||
|
||||
def _env_text(env: dict) -> str:
|
||||
lines = [f"- FramePort {env.get('app')} (agent {env.get('agent_bundled')}), {env.get('os')} {env.get('machine')}"
|
||||
@@ -31,11 +53,11 @@ def _env_text(env: dict) -> str:
|
||||
CUT = "\n[... cut; the full text is in the diagnostics zip]"
|
||||
|
||||
|
||||
def _url(template: str, fields: dict[str, str], trim: list[str]) -> str:
|
||||
"""fields in order; the keys in `trim` are shortened (in that order) until the URL fits."""
|
||||
def _fit(fields: dict[str, str], trim: list[str], build: Callable[[dict[str, str]], str]) -> str:
|
||||
"""build(fields) with the keys in `trim` shortened (in that order) until the URL fits."""
|
||||
fields = {k: v for k, v in fields.items() if v}
|
||||
while True:
|
||||
url = f"{REPO}/issues/new?" + urlencode({"template": template, **fields})
|
||||
url = build(fields)
|
||||
over = len(url) - MAX_URL
|
||||
if over <= 0:
|
||||
return url
|
||||
@@ -51,8 +73,46 @@ def _url(template: str, fields: dict[str, str], trim: list[str]) -> str:
|
||||
fields[key] = text[:keep].rstrip() + CUT
|
||||
|
||||
|
||||
def working_config_url(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None = None) -> str:
|
||||
def _url(template: str, fields: dict[str, str], trim: list[str]) -> str:
|
||||
"""The issue form link: fields in order (query keys = the form's field ids)."""
|
||||
return _fit(fields, trim, lambda f: f"{REPO}/issues/new?" + urlencode({"template": template, **f}))
|
||||
|
||||
|
||||
def markdown_body(fields: dict[str, str], layout: dict[str, tuple[str, str | None]],
|
||||
confirm: tuple[str, str] | None = None) -> str:
|
||||
"""The body GitHub writes for a submitted issue form: one `### <label>` section per field (empty ones say
|
||||
"_No response_", `render` fields in a code block), a checked confirmation box last."""
|
||||
parts = []
|
||||
for key, (label, render) in layout.items():
|
||||
value = (fields.get(key) or "").strip("\n")
|
||||
if not value.strip():
|
||||
value = NO_RESPONSE
|
||||
elif render:
|
||||
value = f"```{render}\n{value}\n```"
|
||||
parts.append(f"### {label}\n\n{value}")
|
||||
if confirm:
|
||||
parts.append(f"### {confirm[0]}\n\n- [X] {confirm[1]}")
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def plain_url(title: str, body: str, labels: str = "") -> str:
|
||||
"""A plain "new issue" link (no form) with title and body; labels only apply for users who may set them."""
|
||||
q = {"title": title, "body": body}
|
||||
if labels:
|
||||
q["labels"] = labels
|
||||
return f"{REPO}/issues/new?" + urlencode(q)
|
||||
|
||||
|
||||
def _links(template: str, fields: dict[str, str], trim: list[str], layout: dict, confirm=None) -> IssueLinks:
|
||||
"""The form link, the plain link (trimmed the same way to fit MAX_URL) and the untrimmed Markdown body."""
|
||||
title, labels = fields.get("title", ""), fields.get("labels", "")
|
||||
content = {k: v for k, v in fields.items() if k not in ("title", "labels")}
|
||||
plain = _fit(content, trim, lambda f: plain_url(title, markdown_body(f, layout, confirm), labels))
|
||||
return IssueLinks(_url(template, fields, trim), plain, markdown_body(content, layout, confirm))
|
||||
|
||||
|
||||
def _working_config_fields(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None) -> dict[str, str]:
|
||||
red = red or redact.Redactor()
|
||||
title = g.get("title") or g["package"]
|
||||
kind = {"rift": "Oculus Rift (PC VR)", "linux": "Linux app (arm64)"}.get(g.get("kind"), "Meta Quest")
|
||||
@@ -62,7 +122,7 @@ def working_config_url(g: dict, recipe_yaml: str, status: str, notes: str, env:
|
||||
last = g.get("last_test") or {}
|
||||
result = f"{status}" + (f"; last launch test: {last.get('state')} ({last.get('verdict')}), furthest: "
|
||||
f"{last.get('milestone') or '-'}" if last else "")
|
||||
return _url("working-config.yml", {
|
||||
return {
|
||||
"title": red.text(f"[Working recipe] {title}"),
|
||||
"labels": "working-config",
|
||||
"game": red.text(game),
|
||||
@@ -70,7 +130,29 @@ def working_config_url(g: dict, recipe_yaml: str, status: str, notes: str, env:
|
||||
"recipe": red.text(recipe_yaml),
|
||||
"environment": red.text(_env_text(env)),
|
||||
"notes": red.text(notes or ""),
|
||||
}, trim=["notes", "environment", "result"])
|
||||
}
|
||||
|
||||
|
||||
WORKING_CONFIG_TRIM = ["notes", "environment", "result"]
|
||||
|
||||
|
||||
def working_config_links(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None = None) -> IssueLinks:
|
||||
return _links("working-config.yml", _working_config_fields(g, recipe_yaml, status, notes, env, red),
|
||||
WORKING_CONFIG_TRIM, WORKING_CONFIG_FIELDS, WORKING_CONFIG_CONFIRM)
|
||||
|
||||
|
||||
def working_config_url(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None = None) -> str:
|
||||
return _url("working-config.yml", _working_config_fields(g, recipe_yaml, status, notes, env, red),
|
||||
WORKING_CONFIG_TRIM)
|
||||
|
||||
|
||||
def working_config_body(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None = None) -> str:
|
||||
"""The Markdown body of a "Working recipe" issue, as the issue form writes it."""
|
||||
return markdown_body(_working_config_fields(g, recipe_yaml, status, notes, env, red), WORKING_CONFIG_FIELDS,
|
||||
WORKING_CONFIG_CONFIRM)
|
||||
|
||||
|
||||
def findings_text(summary: dict | None, limit: int = 8) -> str:
|
||||
@@ -87,8 +169,8 @@ def findings_text(summary: dict | None, limit: int = 8) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def problem_url(g: dict | None, description: str, env: dict, bundle_name: str | None,
|
||||
recipe_yaml: str = "", red: redact.Redactor | None = None) -> str:
|
||||
def _problem_fields(g: dict | None, description: str, env: dict, bundle_name: str | None, recipe_yaml: str,
|
||||
red: redact.Redactor | None) -> dict[str, str]:
|
||||
red = red or redact.Redactor()
|
||||
title = (g.get("title") or g["package"]) if g else "FramePort"
|
||||
game = ""
|
||||
@@ -98,13 +180,28 @@ def problem_url(g: dict | None, description: str, env: dict, bundle_name: str |
|
||||
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 ""
|
||||
return _url("bug-report.yml", {
|
||||
"title": red.text(f"[Problem] {title}"),
|
||||
"labels": "bug",
|
||||
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"),
|
||||
"labels": "game-compatibility" if g else "bug",
|
||||
"game": red.text(game),
|
||||
"description": red.text(description or ""),
|
||||
"findings": red.text(findings_text(g.get("last_test") if g else None)),
|
||||
"recipe": red.text(recipe_yaml),
|
||||
"environment": red.text(_env_text(env)),
|
||||
"logs": logs,
|
||||
}, trim=["findings", "description", "recipe", "environment"])
|
||||
}
|
||||
|
||||
|
||||
PROBLEM_TRIM = ["findings", "description", "recipe", "environment"]
|
||||
|
||||
|
||||
def problem_links(g: dict | None, description: str, env: dict, bundle_name: str | None,
|
||||
recipe_yaml: str = "", red: redact.Redactor | None = None) -> IssueLinks:
|
||||
return _links("bug-report.yml", _problem_fields(g, description, env, bundle_name, recipe_yaml, red),
|
||||
PROBLEM_TRIM, PROBLEM_FIELDS)
|
||||
|
||||
|
||||
def problem_url(g: dict | None, description: str, env: dict, bundle_name: str | None,
|
||||
recipe_yaml: str = "", red: redact.Redactor | None = None) -> str:
|
||||
return _url("bug-report.yml", _problem_fields(g, description, env, bundle_name, recipe_yaml, red), PROBLEM_TRIM)
|
||||
@@ -109,8 +109,44 @@ class AutoPairer:
|
||||
self._stop.set()
|
||||
|
||||
|
||||
SCAN_EVERY = 30.0 # seconds between network scans for Valve's pairing port (mDNS is checked on every look)
|
||||
_scan_cache: tuple[float, list[FrameTarget]] = (-1e9, [])
|
||||
SCAN_EVERY = 30.0 # seconds between network scans for Valve's pairing port (mDNS is checked on every look) ...
|
||||
SCAN_MAX = 300.0 # ... doubling after each scan that found nothing, up to this
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanSchedule:
|
||||
"""When the slow network scan runs: every SCAN_EVERY seconds at first; each scan that finds nothing doubles the
|
||||
wait up to SCAN_MAX. A changed set of networks (another Wi-Fi, a cable) or reset() (the Frame page was opened)
|
||||
starts over with a scan right away."""
|
||||
clock: Callable[[], float] = time.monotonic
|
||||
empty: int = 0 # scans in a row that found nothing
|
||||
last: float | None = None # when the last scan ran (None: never)
|
||||
nets: tuple = ()
|
||||
found: list = field(default_factory=list)
|
||||
|
||||
def interval(self) -> float:
|
||||
return min(SCAN_EVERY * 2 ** max(self.empty - 1, 0), SCAN_MAX)
|
||||
|
||||
def due(self, nets: tuple) -> bool:
|
||||
if nets != self.nets:
|
||||
self.nets = nets
|
||||
self.reset()
|
||||
return self.last is None or self.clock() - self.last >= self.interval()
|
||||
|
||||
def done(self, found: list) -> None:
|
||||
self.last, self.found = self.clock(), found
|
||||
self.empty = 0 if found else self.empty + 1
|
||||
|
||||
def reset(self) -> None:
|
||||
self.empty, self.last = 0, None
|
||||
|
||||
|
||||
_schedule = ScanSchedule()
|
||||
|
||||
|
||||
def rescan_soon() -> None:
|
||||
"""Scan again at the next look and start the back-off over (the user opened the Frame page)."""
|
||||
_schedule.reset()
|
||||
|
||||
|
||||
def devkit_login(host: str, timeout: float = 0.6) -> str | None:
|
||||
@@ -125,18 +161,19 @@ def devkit_login(host: str, timeout: float = 0.6) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _scan_devkits() -> list[FrameTarget]:
|
||||
def _scan_devkits(schedule: ScanSchedule | None = None) -> list[FrameTarget]:
|
||||
"""Every host on the PC's /24 networks answering on Valve's devkit port: for PCs that don't hear the Frame's mDNS
|
||||
(a firewall in front of WSL, some routers). At most every SCAN_EVERY seconds."""
|
||||
global _scan_cache
|
||||
(a firewall in front of WSL, some routers). Only when `schedule` says so (else its last result)."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .discovery import local_addresses, local_subnets
|
||||
|
||||
if time.monotonic() - _scan_cache[0] < SCAN_EVERY:
|
||||
return _scan_cache[1]
|
||||
schedule = schedule or _schedule
|
||||
nets = local_subnets()
|
||||
if not schedule.due(tuple(str(n) for n in nets)):
|
||||
return schedule.found
|
||||
own = local_addresses()
|
||||
hosts = [str(h) for n in local_subnets() for h in n.hosts() if str(h) not in own]
|
||||
hosts = [str(h) for n in nets for h in n.hosts() if str(h) not in own]
|
||||
|
||||
def probe(h):
|
||||
try:
|
||||
@@ -148,9 +185,11 @@ def _scan_devkits() -> list[FrameTarget]:
|
||||
return None
|
||||
login = devkit_login(h)
|
||||
return FrameTarget(h, login, 22, "") if login else None
|
||||
with ThreadPoolExecutor(128) as pool:
|
||||
found = [t for t in pool.map(probe, hosts) if t]
|
||||
_scan_cache = (time.monotonic(), found)
|
||||
found = []
|
||||
if hosts:
|
||||
with ThreadPoolExecutor(min(128, len(hosts))) as pool:
|
||||
found = [t for t in pool.map(probe, hosts) if t]
|
||||
schedule.done(found)
|
||||
return found
|
||||
|
||||
|
||||
|
||||
@@ -102,18 +102,43 @@ def local_addresses() -> set[str]:
|
||||
return {a.address for addrs in psutil.net_if_addrs().values() for a in addrs if a.family == socket.AF_INET}
|
||||
|
||||
|
||||
# Interfaces that never lead to a Frame: container bridges, VM switches, VPN tunnels and overlay networks. Scanning
|
||||
# their /24 costs 254 connection attempts each and finds nothing (or someone else's machines).
|
||||
_VIRTUAL_PREFIXES = ("docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "tun", "tap", "wg", "tailscale", "zt",
|
||||
"utun", "lxc", "lxd", "cni", "flannel", "podman", "ham", "ipsec", "ppp")
|
||||
_VIRTUAL_PARTS = ("vethernet", "wsl", "hyper-v", "virtualbox", "vmware", "tailscale", "zerotier", "wireguard",
|
||||
"openvpn", "vpn", "tap-windows", "wintun", "docker", "nordlynx", "loopback")
|
||||
_CGNAT = ipaddress.ip_network("100.64.0.0/10") # carrier-grade NAT: Tailscale and other overlay VPNs live here
|
||||
|
||||
|
||||
def is_virtual_interface(name: str) -> bool:
|
||||
"""A container, VM, VPN or overlay interface by its name (Linux, macOS and Windows' friendly names)."""
|
||||
n = name.lower()
|
||||
return n.startswith(_VIRTUAL_PREFIXES) or any(p in n for p in _VIRTUAL_PARTS)
|
||||
|
||||
|
||||
def scannable_address(address: str) -> bool:
|
||||
"""An IPv4 address whose /24 may hold a Frame: not loopback, link-local, CGNAT/VPN or 172.* (Docker/WSL)."""
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(address)
|
||||
except ValueError:
|
||||
return False
|
||||
return not (ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified or ip in _CGNAT
|
||||
or address.startswith("172."))
|
||||
|
||||
|
||||
def local_subnets() -> list[ipaddress.IPv4Network]:
|
||||
"""/24 networks of the PC's non-loopback, non-link-local IPv4 interfaces (virtual switches skipped)."""
|
||||
"""/24 networks of the PC's interfaces that may lead to a Frame (up; container, VM, VPN, loopback, link-local and
|
||||
CGNAT skipped, see is_virtual_interface / scannable_address)."""
|
||||
import psutil
|
||||
|
||||
nets = set()
|
||||
stats = psutil.net_if_stats()
|
||||
for nic, addrs in psutil.net_if_addrs().items():
|
||||
virtual = any(v in nic.lower() for v in ("vethernet", "docker", "virbr", "vmnet", "wsl"))
|
||||
if not stats.get(nic) or not stats[nic].isup or virtual:
|
||||
if not stats.get(nic) or not stats[nic].isup or is_virtual_interface(nic):
|
||||
continue
|
||||
for a in addrs:
|
||||
if a.family == socket.AF_INET and not a.address.startswith(("127.", "169.254.", "172.")):
|
||||
if a.family == socket.AF_INET and scannable_address(a.address):
|
||||
nets.add(ipaddress.ip_network(f"{a.address}/24", strict=False))
|
||||
return sorted(nets, key=str)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from collections.abc import Callable
|
||||
from ..core import applog
|
||||
|
||||
MIN_AGENT = 62 # first agent with `_monitor`
|
||||
MIN_AGENT_MODULES = 65 # first agent that takes {"modules": [...]} (collects only what's asked for)
|
||||
MIN_AGENT_MODULES = 73 # first agent that takes {"modules": [...]} (collects only what's asked for)
|
||||
MODULES = ("games", "procs", "cpu", "gpu", "mem", "temps", "power", "battery", "net", "disk") # agent MON_MODULES
|
||||
HISTORY = 120 # points per sparkline (2 min at 1 s)
|
||||
REPLY_TIMEOUT = 15.0
|
||||
|
||||
@@ -34,11 +34,15 @@ HINT_AFTER = 45 # seconds without any request from the Frame before the UI sugg
|
||||
|
||||
# the static setup: one fixed address on the project page serves bootstrap/setup.sh (copied there by the site build)
|
||||
SETUP_PAGE = "https://frameport.app/setup/"
|
||||
SETUP_URL = "frameport.app/s"
|
||||
SETUP_LINE = f"curl -sL {SETUP_URL} | bash"
|
||||
SETUP_URL = "https://frameport.app/s"
|
||||
SETUP_LINE = f"curl -fsSL {SETUP_URL} | bash"
|
||||
SERVICE = "_frameport-pair._tcp.local."
|
||||
MAX_ASKS = 3 # open requests at a time (more get 429)
|
||||
WAIT = 100 # seconds one /wait may hold before the Frame asks again
|
||||
# An open request whose Frame stopped polling /wait for more than 2 x WAIT is gone (setup.sh ended, Ctrl+C, the Frame
|
||||
# slept): it no longer counts against MAX_ASKS and its Allow card goes. Decided ones are kept a while for the record.
|
||||
DECIDED_KEEP = 10 * 60
|
||||
EXPIRE_EVERY = 5 # seconds between expiry checks while the server runs
|
||||
|
||||
# two words that name this PC's app key (its public key: same on every start), shown on both sides
|
||||
_ADJ = ("amber", "brisk", "calm", "dusky", "eager", "fable", "gentle", "hazel", "ivory", "jolly", "keen", "lunar",
|
||||
@@ -65,6 +69,13 @@ def announce_properties(name: str) -> dict[str, str]:
|
||||
return {"v": "1", "pc": name, "words": pc_words()}
|
||||
|
||||
|
||||
def _default_frame_name() -> str:
|
||||
"""The name shown for a Frame that didn't send its host name (shown in the Allow card)."""
|
||||
from ..i18n import tr
|
||||
|
||||
return tr("Steam Frame")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ask:
|
||||
"""A Frame asking to be set up (from /hello), waiting for the user to allow it."""
|
||||
@@ -72,9 +83,28 @@ class Ask:
|
||||
frame: str # the Frame's host name
|
||||
address: str
|
||||
digits: str
|
||||
state: str = "open" # open | allowed | denied
|
||||
state: str = "open" # open | allowed | denied | expired
|
||||
created: float = field(default_factory=time.time)
|
||||
event: threading.Event = field(default_factory=threading.Event, repr=False)
|
||||
seen: float = 0.0 # when the Frame last asked or polled (/hello, /wait start or end)
|
||||
waiting: int = 0 # /wait requests held right now
|
||||
decided: float = 0.0 # when the user allowed or denied it
|
||||
|
||||
def __post_init__(self):
|
||||
self.seen = self.seen or self.created
|
||||
|
||||
def expired(self, now: float | None = None) -> bool:
|
||||
"""An open request nobody polls any more (no /wait for 2 x WAIT), or a decided one older than DECIDED_KEEP."""
|
||||
now = time.time() if now is None else now
|
||||
if self.state == "open":
|
||||
return self.waiting == 0 and now - self.seen > 2 * WAIT
|
||||
return now - (self.decided or self.seen) > DECIDED_KEEP
|
||||
|
||||
|
||||
def asking(server: PairingServer | None) -> bool:
|
||||
"""A running setup page has a Frame waiting for Allow / Deny right now (automatic pairing holds off meanwhile;
|
||||
allowed, denied and expired requests don't count)."""
|
||||
return bool(server is not None and server.running and server.open_asks())
|
||||
|
||||
|
||||
def ensure_reachable(server: PairingServer) -> str:
|
||||
@@ -155,10 +185,13 @@ class PairingServer:
|
||||
# setup uses the cable's fixed PC address (10.86.200.234), so no Wi-Fi, router or discovery is involved
|
||||
asks: list[Ask] = field(default_factory=list)
|
||||
on_ask: object = None # called with each new Ask (the UI shows Allow / Deny)
|
||||
on_expire: object = None # called with the list of Asks that just expired (the UI drops their cards)
|
||||
announce: bool = True # announce over mDNS so the setup URL finds this PC
|
||||
_httpd: http.server.ThreadingHTTPServer | None = None
|
||||
_zc: object = None
|
||||
_info: object = None
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
_stopped: threading.Event = field(default_factory=threading.Event, repr=False)
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
@@ -194,20 +227,31 @@ class PairingServer:
|
||||
nonce = q.get("nonce", "")
|
||||
if not (8 <= len(nonce) <= 64) or not nonce.isalnum():
|
||||
return self._send(b"bad request\n", status=400)
|
||||
if sum(a.state == "open" for a in server.asks) >= MAX_ASKS:
|
||||
return self._send(b"too many open requests\n", status=429)
|
||||
ask = Ask(secrets.token_hex(8), q.get("host", "")[:64] or "Steam Frame", self.client_address[0],
|
||||
ask_digits(nonce))
|
||||
server.asks.append(ask)
|
||||
server.prune()
|
||||
with server._lock:
|
||||
if len(server.open_asks()) >= MAX_ASKS:
|
||||
return self._send(b"too many open requests\n", status=429)
|
||||
ask = Ask(secrets.token_hex(8), q.get("host", "")[:64] or _default_frame_name(),
|
||||
self.client_address[0], ask_digits(nonce))
|
||||
server.asks.append(ask)
|
||||
if callable(server.on_ask):
|
||||
server.on_ask(ask)
|
||||
return self._send(json.dumps({"id": ask.id, "pc": socket.gethostname(), "words": pc_words(),
|
||||
"digits": ask.digits}).encode(), "application/json")
|
||||
if path == "/wait": # held until the user decides (or WAIT seconds: the Frame asks again)
|
||||
ask = next((a for a in server.asks if secrets.compare_digest(a.id, q.get("id", ""))), None)
|
||||
with server._lock:
|
||||
ask = next((a for a in server.asks if secrets.compare_digest(a.id, q.get("id", ""))), None)
|
||||
if ask is not None:
|
||||
ask.waiting += 1
|
||||
ask.seen = time.time()
|
||||
if ask is None:
|
||||
return self._send(b"unknown request\n", status=404)
|
||||
ask.event.wait(WAIT)
|
||||
try:
|
||||
ask.event.wait(WAIT)
|
||||
finally:
|
||||
with server._lock:
|
||||
ask.waiting -= 1
|
||||
ask.seen = time.time()
|
||||
if ask.state == "allowed":
|
||||
return self._send(json.dumps({"code": server.code}).encode(), "application/json")
|
||||
if ask.state == "denied":
|
||||
@@ -249,14 +293,47 @@ class PairingServer:
|
||||
self._timer.start()
|
||||
if self.announce:
|
||||
threading.Thread(target=self._announce, daemon=True).start()
|
||||
threading.Thread(target=self._expire_loop, daemon=True).start()
|
||||
return self
|
||||
|
||||
def decide(self, ask_id: str, allow: bool) -> None:
|
||||
"""The user allowed (or denied) a Frame that asked via /hello."""
|
||||
for a in self.asks:
|
||||
if a.id == ask_id and a.state == "open":
|
||||
a.state = "allowed" if allow else "denied"
|
||||
with self._lock:
|
||||
for a in self.asks:
|
||||
if a.id == ask_id and a.state == "open":
|
||||
a.state = "allowed" if allow else "denied"
|
||||
a.decided = time.time()
|
||||
a.event.set()
|
||||
|
||||
def open_asks(self) -> list[Ask]:
|
||||
"""The requests still waiting for Allow / Deny (expired ones not included)."""
|
||||
now = time.time()
|
||||
return [a for a in self.asks if a.state == "open" and not a.expired(now)]
|
||||
|
||||
def prune(self, now: float | None = None) -> list[Ask]:
|
||||
"""Drop expired requests (see Ask.expired); calls on_expire with them. Returns them."""
|
||||
now = time.time() if now is None else now
|
||||
with self._lock:
|
||||
gone = [a for a in self.asks if a.expired(now)]
|
||||
if not gone:
|
||||
return []
|
||||
self.asks = [a for a in self.asks if a not in gone]
|
||||
for a in gone:
|
||||
if a.state == "open":
|
||||
a.state = "expired"
|
||||
a.event.set()
|
||||
if callable(self.on_expire):
|
||||
try:
|
||||
self.on_expire(gone)
|
||||
except Exception: # noqa: BLE001 - a UI callback must not stop the expiry checks
|
||||
pass
|
||||
return gone
|
||||
|
||||
def _expire_loop(self) -> None:
|
||||
while not self._stopped.wait(EXPIRE_EVERY):
|
||||
if self._httpd is None:
|
||||
return
|
||||
self.prune()
|
||||
|
||||
def _announce(self) -> None:
|
||||
"""mDNS `_frameport-pair._tcp` while the server runs: the setup URL's script finds this PC with it. Never the
|
||||
@@ -296,6 +373,7 @@ class PairingServer:
|
||||
threading.Thread(target=self.stop, daemon=True).start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
httpd, self._httpd = self._httpd, None
|
||||
if httpd:
|
||||
httpd.shutdown()
|
||||
|
||||
@@ -141,6 +141,7 @@ class PcvrPlan:
|
||||
revive_version: str | None = None
|
||||
art_lookup: str | None = None # package whose store art to use
|
||||
dest: str | None = None
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
@@ -211,12 +212,27 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source},
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
return result
|
||||
|
||||
|
||||
def log_overlay(overlay: dict | None, reporter: Reporter) -> None:
|
||||
"""Outcome of registering a SteamVR overlay app on the Frame (agent register_vr_overlay)."""
|
||||
if not overlay:
|
||||
return
|
||||
if overlay.get("registered"):
|
||||
reporter.check("SteamVR overlay app", True, f"registered with SteamVR as {overlay.get('key')}"
|
||||
+ (", starts with SteamVR" if overlay.get("autostart") else ""))
|
||||
elif overlay.get("error") == "steamvr":
|
||||
reporter.check("SteamVR overlay app", None, "SteamVR isn't running on the Frame: registered at the next "
|
||||
"connection")
|
||||
else:
|
||||
reporter.check("SteamVR overlay app", False, f"SteamVR didn't take it: {overlay.get('error')}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinuxPlan:
|
||||
package: str # "linux.<slug>"
|
||||
@@ -229,6 +245,7 @@ class LinuxPlan:
|
||||
x86_64: bool = False # runs through FEX (installed on the Frame first, like Proton)
|
||||
dest: str | None = None # a drive's install dir for a new install (GitHub #90); None = internal storage
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -261,8 +278,9 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
result = frame.agent("finalize_linux", package=plan.package, title=plan.title, exe=plan.exe,
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, timeout=900)
|
||||
desktop_entry=plan.desktop_entry, overlay=plan.overlay, timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
|
||||
if result.get("desktop_entry"):
|
||||
reporter.check("Desktop Mode entry", True, "in the application menu"
|
||||
|
||||
@@ -152,7 +152,7 @@ def delete(frame, shots: list[dict]) -> int:
|
||||
|
||||
|
||||
def take(frame, wait: float = 8) -> dict:
|
||||
"""Take a headset screenshot on the Frame (agent v66 `take_screenshot`: OpenVR's RequestScreenshot +
|
||||
"""Take a headset screenshot on the Frame (agent v73 `take_screenshot`: OpenVR's RequestScreenshot +
|
||||
SubmitScreenshot, saved by Steam under SteamVR like one taken in the headset): {taken, path, reason, hmd}.
|
||||
reason "steamvr" = SteamVR isn't running, "capture" = nothing captured (the headset sleeps)."""
|
||||
return frame.agent("take_screenshot", wait=wait)
|
||||
@@ -72,6 +72,7 @@
|
||||
"A second build with different OVRPort patches, for example for Unreal games that close themselves right after starting. Turn on to install that build.": "",
|
||||
"A small helper FramePort runs on the Frame for installs, launch tests and Steam entries. FramePort updates it by itself.": "",
|
||||
"a source checkout (git pull + uv sync)": "",
|
||||
"A SteamVR overlay app: SteamVR on the Frame draws it over whatever you play, Quest and PC VR games alike. With 'Start with SteamVR' on, SteamVR starts it by itself; otherwise start it from the library before the game.": "",
|
||||
"A VR app made for another Android headset. OpenXR apps work like Quest games; apps built for Pico or HTC Wave can't run on the Frame.": "",
|
||||
"A web page asked FramePort to install {name}.": "",
|
||||
"A Windows game without VR. On the Frame, Proton runs it in a window from the Steam library.": "",
|
||||
@@ -358,8 +359,8 @@
|
||||
"Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). A video's frame rate that divides the refresh rate plays smoothest (for example 30 fps at 90 Hz, 24 fps at 72 Hz).": "",
|
||||
"Documents": "",
|
||||
"Don't pause on short interruptions": "",
|
||||
"done": "",
|
||||
"Done": "",
|
||||
"done": "",
|
||||
"Done — start the game again to see whether it helped.": "",
|
||||
"Done. It's used the next time you start {title}.": "",
|
||||
"Download all ({n})…": "",
|
||||
@@ -390,6 +391,7 @@
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Download…": "",
|
||||
"Drag {name} into the GitHub issue that opened, then submit it.": "",
|
||||
"Draw each eye separately (OpenGL ES)": "",
|
||||
"drawing frames": "",
|
||||
"Draws each eye separately. Try it when one eye shows a gray or broken picture.": "",
|
||||
@@ -491,6 +493,7 @@
|
||||
"For older Unity games that freeze at the first scene change (for example Sniper Elite VR). Can make loading screens stutter.": "",
|
||||
"For older Unity games whose hands trail the controllers (for example Sniper Elite VR). In others the hands lag.": "",
|
||||
"For one eye looking wrong (for example a jittering right eye): logs each eye's submitted pose against a fresh one, the eye-to-eye relation and when each eye's image was released, also into framebridge.log in the game's storage (some games stop Android's log early). No effect on the game.": "",
|
||||
"For overlay apps (fpsVR, wrist watches and other SteamVR add-ons that draw over games instead of being a game): FramePort registers the app with the Frame's SteamVR, which draws its overlays over whatever you play there, Quest and PC VR games alike. With 'start with SteamVR' on, SteamVR starts it by itself (also after a restart); otherwise start it from the library. It isn't launch-tested (it shows nothing without a game). Ignored on this PC.": "",
|
||||
"For troubleshooting: records the game's requests to Meta's online services.": "",
|
||||
"For Unity games that crash when they turn on anti-aliasing (for example The Room VR).": "",
|
||||
"For Unreal Engine 5 games that crash on the Frame (for example Metro Awakening).": "",
|
||||
@@ -504,6 +507,7 @@
|
||||
"Force quit all {n}…": "",
|
||||
"Force quit {name}?": "",
|
||||
"Force quit…": "",
|
||||
"Form empty? Open a plain issue": "",
|
||||
"Found": "",
|
||||
"Found on your network": "",
|
||||
"fps": "",
|
||||
@@ -587,6 +591,7 @@
|
||||
"Get FramePort ready": "",
|
||||
"Getting FramePort ready": "",
|
||||
"GiB": "",
|
||||
"GitHub issue opened": "",
|
||||
"Gives older Quest games a newer version of Meta's VR plugin that can be converted.": "",
|
||||
"GL shim (Mesa GLSL compatibility)": "",
|
||||
"GL shim for Unity (no multisampled render-to-texture)": "",
|
||||
@@ -767,6 +772,7 @@
|
||||
"Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR).": "",
|
||||
"Lets OpenGL ES games start when the Frame refuses their picture format. Leave it on.": "",
|
||||
"Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).": "",
|
||||
"Lets SteamVR on the Frame show this overlay app over your games (and start it with SteamVR).": "",
|
||||
"Lets Team Beef ports (for example Lambda1VR) start on the Frame.": "",
|
||||
"Lets the game find extra content it keeps next to its data (for example Star Wars Tales' seasons).": "",
|
||||
"Lets the game leave its loading screen: it waited for a Quest-only step that never starts on the Frame (for example Vader Immortal).": "",
|
||||
@@ -992,7 +998,7 @@
|
||||
"Package": "",
|
||||
"passed": "",
|
||||
"Password (first time only)": "",
|
||||
"Paste an install link: a FrameDrop button's address, a manifest (.json) or a direct link to an APK, .zip or .exe.": "",
|
||||
"Paste an install link: an install button's address, a manifest (.json) or a direct link to an APK, .zip or .exe.": "",
|
||||
"Paste long text": "",
|
||||
"Patch": "",
|
||||
"Patch Meta XR Audio": "",
|
||||
@@ -1210,7 +1216,7 @@
|
||||
"Save the screenshots to which folder on this PC?": "",
|
||||
"Save them to this PC": "",
|
||||
"Saved as a known-good recipe": "",
|
||||
"Saved as known-good — check the GitHub issue and submit it": "",
|
||||
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
|
||||
"Saved {name} (in {parent})": "",
|
||||
"Saved {name} — drag it into the GitHub issue that opened": "",
|
||||
"Saved. Restart the game to use the new settings.": "",
|
||||
@@ -1307,7 +1313,7 @@
|
||||
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
"Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi (for example Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt was called before vrapi_Initialize()\"). Replaces it with a library that has the same functions, each doing nothing. Only for games whose VR runs through OVRPlugin on OpenXR.": "",
|
||||
"Some websites have \"Install with FrameDrop\" buttons. FramePort opens these links, shows what they offer and installs it when you agree. On macOS, use Add games → Add from a link… instead.": "",
|
||||
"Some websites have \"Install with FramePort\" buttons (frameport.app links). FramePort opens these links, shows what they offer and installs it when you agree. On macOS, use Add games → Add from a link… instead.": "",
|
||||
"Something not working? Attach a diagnostics zip to a GitHub issue; personal data is removed. For one game, use its menu instead.": "",
|
||||
"Something went wrong": "",
|
||||
"Sort": "",
|
||||
@@ -1318,12 +1324,14 @@
|
||||
"square, 256 × 256": "",
|
||||
"Stand in for cube layers": "",
|
||||
"Standard for every game": "",
|
||||
"Start it from the library": "",
|
||||
"Start live view": "",
|
||||
"Start setup": "",
|
||||
"Start straight in VR (skip the 2D launcher)": "",
|
||||
"Start the live view to watch the headset here.": "",
|
||||
"Start the live view to watch the headset in an mpv window.": "",
|
||||
"Start the live view to watch the headset in your browser.": "",
|
||||
"Start with SteamVR": "",
|
||||
"Starting on Frame…": "",
|
||||
"Starting on this PC…": "",
|
||||
"Starting SteamVR and {title} — put the headset on": "",
|
||||
@@ -1339,6 +1347,7 @@
|
||||
"Starts the game on the Frame — put the headset on.": "",
|
||||
"Starts the game with options, for example to choose SteamVR or OpenXR.": "",
|
||||
"Starts the game with the launcher that came with your copy.": "",
|
||||
"Starts with SteamVR": "",
|
||||
"Status": "",
|
||||
"Steadier hands (older Unity)": "",
|
||||
"Steadier head tracking": "",
|
||||
@@ -1355,6 +1364,8 @@
|
||||
"steamos@frame.local or an IP address": "",
|
||||
"SteamVR": "",
|
||||
"SteamVR isn't running on the Frame: put the headset on, then take the screenshot.": "",
|
||||
"SteamVR overlay": "",
|
||||
"SteamVR overlay app (Frame)": "",
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
"Stop": "",
|
||||
"Stop objects popping in and out": "",
|
||||
@@ -1452,6 +1463,7 @@
|
||||
"The headset's view is open in your browser. Closed the tab? Open it again.": "",
|
||||
"The headset's view on this PC, for watching along or recording.": "",
|
||||
"the installed game (APK and its data)": "",
|
||||
"The issue's text is also on your clipboard. If the form's fields are empty, open a plain issue instead: it comes filled in.": "",
|
||||
"The launch settings changed since the install — update the Steam shortcut.": "",
|
||||
"The live view couldn't play {first}, so it opened {where}.": "",
|
||||
"The live view is streaming.": "",
|
||||
@@ -1742,6 +1754,7 @@
|
||||
"{get} is installed on this PC — start it with Play or from your Steam library": "",
|
||||
"{get} is installed on {where}, but the launch test needs a look": "",
|
||||
"{get} is installed on {where}: put the headset on and launch it from your Steam library": "",
|
||||
"{get} is installed on {where}: SteamVR shows it over your games": "",
|
||||
"{get} is ready on {where} — put the headset on and start it from your Steam library": "",
|
||||
"{get} starts with {value}": "",
|
||||
"{human} · {files} files": "",
|
||||
|
||||
@@ -237,8 +237,29 @@ class SteamvrTuning(_PcvrPatch):
|
||||
return None
|
||||
|
||||
|
||||
for _cls in (RepackLauncher, LaunchArgs, Revive, ReviveOpenVR, LibovrRedirect, NoCrashReporter, OculusUnreal, XrTimefix,
|
||||
ProtonLog, ProtonTool, ProtonEnv, SteamvrTuning):
|
||||
class VrOverlay(_PcvrPatch):
|
||||
id = "pcvr.vr_overlay"
|
||||
title = "SteamVR overlay app (Frame)"
|
||||
description = ("For overlay apps (fpsVR, wrist watches and other SteamVR add-ons that draw over games instead of "
|
||||
"being a game): FramePort registers the app with the Frame's SteamVR, which draws its overlays "
|
||||
"over whatever you play there, Quest and PC VR games alike. With 'start with SteamVR' on, SteamVR "
|
||||
"starts it by itself (also after a restart); otherwise start it from the library. It isn't "
|
||||
"launch-tested (it shows nothing without a game). Ignored on this PC.")
|
||||
order = 5
|
||||
params = [Param("autostart", "bool", True, "start with SteamVR")]
|
||||
|
||||
def detect(self, analysis):
|
||||
x = analysis.extra or {}
|
||||
if _rift(analysis) and x.get("vr_overlay"):
|
||||
return Suggestion(True, x.get("vr_overlay_from") or "A SteamVR overlay app.", {"autostart": True})
|
||||
return None
|
||||
|
||||
def applies(self, analysis) -> bool:
|
||||
return _rift(analysis) and "OpenVR" in (analysis.xr or "")
|
||||
|
||||
|
||||
for _cls in (VrOverlay, RepackLauncher, LaunchArgs, Revive, ReviveOpenVR, LibovrRedirect, NoCrashReporter, OculusUnreal,
|
||||
XrTimefix, ProtonLog, ProtonTool, ProtonEnv, SteamvrTuning):
|
||||
register(_cls)
|
||||
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ SUMMARIES = {
|
||||
"pcvr.oculus_unreal": "Lets Unreal games accept a headset that isn't an Oculus Rift.",
|
||||
"pcvr.revive_openvr": "On this PC, runs Revive through SteamVR (works with more headsets).",
|
||||
"pcvr.xr_timefix": "Lets games written for newer OpenXR versions run on the Frame.",
|
||||
"pcvr.vr_overlay": "Lets SteamVR on the Frame show this overlay app over your games (and start it with SteamVR).",
|
||||
"pcvr.proton_log": "Writes a detailed Proton log for troubleshooting.",
|
||||
"pcvr.proton_tool": "Which Proton runs the game on the Frame. Stable is smoother; try Experimental if the game "
|
||||
"doesn't start or runs badly.",
|
||||
|
||||
@@ -450,13 +450,44 @@ def add_from_link(manifest, path: Path, reporter: Reporter | None = None, icon:
|
||||
return library.game(pkg)
|
||||
|
||||
|
||||
def is_overlay_app(entry: dict) -> bool:
|
||||
"""A SteamVR overlay app (fpsVR & co.): installed for SteamVR to draw over games; no launch test."""
|
||||
if is_linux(entry):
|
||||
return linux_overlay(entry) is not None
|
||||
if not is_rift(entry) or not entry.get("recipe"):
|
||||
return False
|
||||
return "pcvr.vr_overlay" in library.recipe_from_dict(entry["recipe"]).patches
|
||||
|
||||
|
||||
def linux_overlay(entry: dict) -> dict | None:
|
||||
"""A Linux app that is a SteamVR overlay app (analysis "vr_overlay"; the entry's "vr_overlay" field turns it off,
|
||||
"vr_overlay_autostart" = start with SteamVR, default on): the agent's overlay argument, else None."""
|
||||
from .analysis import vroverlay
|
||||
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
if not extra.get("vr_overlay") or entry.get("vr_overlay", True) is False:
|
||||
return None
|
||||
return vroverlay.install_args(extra, entry.get("vr_overlay_autostart", True) is not False)
|
||||
|
||||
|
||||
def rift_overlay(entry: dict, recipe) -> dict | None:
|
||||
"""pcvr.vr_overlay → the agent's overlay argument (None: not an overlay app)."""
|
||||
from .analysis import vroverlay
|
||||
|
||||
if "pcvr.vr_overlay" not in recipe.patches:
|
||||
return None
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
return vroverlay.install_args(extra, recipe.params("pcvr.vr_overlay").get("autostart", True) is not False)
|
||||
|
||||
|
||||
def install_linux(package: str, target: Target, reporter: Reporter, add_to_library: bool = True) -> dict:
|
||||
entry = library.game(package)
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
result = target.install_linux(package, steam_title(entry), Path(entry["game_dir"]), entry["exe"],
|
||||
extra.get("files"), bool(extra.get("appimage")), bool(extra.get("openxr")),
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False)
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False,
|
||||
overlay=linux_overlay(entry))
|
||||
if extra.get("x86_64"):
|
||||
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
|
||||
"there; not checked ahead)")
|
||||
@@ -914,7 +945,8 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
rdir = revive.revive_dir() if "pcvr.revive" in recipe.patches else None
|
||||
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
|
||||
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"))
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
|
||||
overlay=rift_overlay(entry, recipe))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
@@ -1116,6 +1148,13 @@ def share_working_config(package: str, status: str = "works", notes: str = "",
|
||||
frame_info: dict | None = None) -> str:
|
||||
"""Save the game's recipe as a known-good user recipe and return a prefilled GitHub issue link that submits it
|
||||
to the catalog (a maintainer turns accepted ones into a PR)."""
|
||||
return share_working_config_links(package, status, notes, frame_info).form
|
||||
|
||||
|
||||
def share_working_config_links(package: str, status: str = "works", notes: str = "",
|
||||
frame_info: dict | None = None):
|
||||
"""share_working_config's issue.IssueLinks: the form link, a plain issue link and the Markdown body (for
|
||||
browsers that open GitHub's form with empty fields, GitHub #167)."""
|
||||
from .diag import bundle, issue, redact
|
||||
from .recommend import catalog
|
||||
|
||||
@@ -1128,12 +1167,18 @@ def share_working_config(package: str, status: str = "works", notes: str = "",
|
||||
"agent": (env.get("frame") or {}).get("agent_version")}
|
||||
entry = catalog.entry_from_library(g, status=status, notes=notes or None, verified=verified)
|
||||
catalog.save_user_entry(entry)
|
||||
return issue.working_config_url(g, catalog.to_yaml(entry), status, notes, env, redact.default(frame_info))
|
||||
return issue.working_config_links(g, catalog.to_yaml(entry), status, notes, env, redact.default(frame_info))
|
||||
|
||||
|
||||
def problem_report(package: str | None, description: str, bundle_path: Path | None,
|
||||
frame_info: dict | None = None) -> str:
|
||||
"""A prefilled GitHub bug-report link (the user attaches the diagnostics zip)."""
|
||||
return problem_report_links(package, description, bundle_path, frame_info).form
|
||||
|
||||
|
||||
def problem_report_links(package: str | None, description: str, bundle_path: Path | None,
|
||||
frame_info: dict | None = None):
|
||||
"""problem_report's issue.IssueLinks (form link, plain issue link, Markdown body)."""
|
||||
from .diag import bundle, issue, redact
|
||||
from .recommend import catalog
|
||||
|
||||
@@ -1144,5 +1189,5 @@ def problem_report(package: str | None, description: str, bundle_path: Path | No
|
||||
recipe = catalog.to_yaml(catalog.entry_from_library(g, status=g.get("status") or "unknown"))
|
||||
except Exception: # noqa: BLE001
|
||||
recipe = ""
|
||||
return issue.problem_url(g, description, bundle.env_info(frame_info),
|
||||
bundle_path.name if bundle_path else None, recipe, redact.default(frame_info))
|
||||
return issue.problem_links(g, description, bundle.env_info(frame_info),
|
||||
bundle_path.name if bundle_path else None, recipe, redact.default(frame_info))
|
||||
@@ -34,7 +34,7 @@ class Target(ABC):
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True) -> dict:
|
||||
desktop_entry=True, overlay=None) -> dict:
|
||||
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
|
||||
raise NotImplementedError(f"{self.label} can't install Linux apps")
|
||||
|
||||
|
||||
@@ -75,13 +75,13 @@ class FrameLeptonTarget(Target):
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest())
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"))
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True):
|
||||
desktop_entry=True, overlay=None):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry)
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
@@ -95,6 +95,10 @@ class FrameLeptonTarget(Target):
|
||||
"""A Linux app's Desktop Mode entry on or off (GitHub #84)."""
|
||||
return self.connect().frame.agent("desktop_entry", package=package, enabled=enabled)
|
||||
|
||||
def set_vr_overlay(self, package: str, autostart: bool) -> dict:
|
||||
"""An installed SteamVR overlay app's auto-start with SteamVR (agent >= 75)."""
|
||||
return self.connect().frame.agent("register_vr_overlay", package=package, autostart=autostart)
|
||||
|
||||
def proton_status(self, tool: str | None = None) -> dict:
|
||||
return self.connect().frame.agent("proton_status", tool=tool)
|
||||
|
||||
|
||||
+64
-23
@@ -586,6 +586,10 @@ class FramePortApp:
|
||||
self.stop_keyboard() # leaving the tab removes the virtual keyboard from the Frame
|
||||
if self.route[0] == "monitor" and route != "monitor":
|
||||
self.stop_monitor() # leaving the tab ends the Frame's monitor stream
|
||||
if route == "frame" and self.route[0] != "frame":
|
||||
from ..frame import autopair
|
||||
|
||||
autopair.rescan_soon() # the user looks for a Frame: the automatic pairing's scan stops backing off
|
||||
self.route = (route, *args)
|
||||
self.render()
|
||||
|
||||
@@ -1479,6 +1483,10 @@ class FramePortApp:
|
||||
return tr("{get} is installed on this PC — start it with Play or from your Steam library").format(
|
||||
get=g.get('title'))
|
||||
rep.check_cancel()
|
||||
if pipeline.is_overlay_app(library.game(pkg) or {}): # nothing to see without a game: no launch test
|
||||
rep.stage("Installed")
|
||||
return tr("{get} is installed on {where}: SteamVR shows it over your games").format(
|
||||
get=g.get('title'), where=where)
|
||||
if not library.setting("install.launch_test", True): # Settings → Installing
|
||||
rep.stage("Installed")
|
||||
return tr("{get} is installed on {where}: put the headset on and launch it from your Steam "
|
||||
@@ -1743,6 +1751,28 @@ class FramePortApp:
|
||||
except Exception: # noqa: BLE001
|
||||
winhost.open_url(url)
|
||||
|
||||
def open_issue(self, links, message: str) -> None:
|
||||
"""Open a prefilled GitHub issue form (diag.issue.IssueLinks), copy its text to the clipboard and offer a
|
||||
plain issue with the same text: some browsers open the form with every field empty (GitHub #167)."""
|
||||
try:
|
||||
self.page.run_task(ft.Clipboard().set, links.body)
|
||||
except Exception: # noqa: BLE001 - the plain issue still works
|
||||
pass
|
||||
self.open_url(links.form)
|
||||
|
||||
def plain(e):
|
||||
self.page.pop_dialog()
|
||||
self.open_url(links.plain)
|
||||
self.page.show_dialog(C.dialog(
|
||||
tr("GitHub issue opened"),
|
||||
ft.Column([
|
||||
C.body(message, T.TEXT_2),
|
||||
C.body(tr("The issue's text is also on your clipboard. If the form's fields are empty, open a plain "
|
||||
"issue instead: it comes filled in."), T.TEXT_3),
|
||||
], tight=True, spacing=T.S3),
|
||||
actions=[C.secondary(tr("Form empty? Open a plain issue"), ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=plain),
|
||||
C.ghost(tr("Done"), on_click=lambda e: self.page.pop_dialog())]))
|
||||
|
||||
def _diag_target(self, pkg: str | None):
|
||||
g = library.game(pkg) if pkg else None
|
||||
if g and g.get("kind") == "rift" and pkg in self.pc_installs() and \
|
||||
@@ -1761,7 +1791,10 @@ class FramePortApp:
|
||||
path = pipeline.collect_diagnostics([pkg] if pkg else None, target, job.reporter)
|
||||
winhost.open_folder(path, select=True)
|
||||
if report is not None:
|
||||
self.open_url(pipeline.problem_report(pkg, report, path, info if target is self.target else None))
|
||||
self.open_issue(pipeline.problem_report_links(pkg, report, path,
|
||||
info if target is self.target else None),
|
||||
tr("Drag {name} into the GitHub issue that opened, then submit it.").format(
|
||||
name=path.name))
|
||||
return tr("Saved {name} — drag it into the GitHub issue that opened").format(name=path.name)
|
||||
return tr("Saved {name} (in {parent})").format(name=path.name, parent=path.parent)
|
||||
job_title = tr("Collect logs: {title}").format(title=self._title(pkg)) if pkg else tr("Collect FramePort logs")
|
||||
@@ -1808,10 +1841,9 @@ class FramePortApp:
|
||||
info = self.frame_info if self.frame_state == "connected" else None
|
||||
|
||||
def work():
|
||||
url = pipeline.share_working_config(pkg, status.value or "works", notes.value or "", info)
|
||||
library.upsert_game(pkg, shared_config=time.time()) # the game page stops asking
|
||||
self.open_url(url)
|
||||
self.toast(tr("Saved as known-good — check the GitHub issue and submit it"))
|
||||
links = pipeline.share_working_config_links(pkg, status.value or "works", notes.value or "", info)
|
||||
self.open_issue(links, tr("Saved as known-good. Check the GitHub issue that opened and submit it."))
|
||||
library.upsert_game(pkg, shared_config=time.time()) # opened: the game page stops asking
|
||||
self.run_bg(work)
|
||||
send.on_click = go
|
||||
self.page.show_dialog(C.dialog(
|
||||
@@ -2192,7 +2224,7 @@ class FramePortApp:
|
||||
FramePort in is connected, one that doesn't is offered FramePort's key through Valve's pairing (approve in
|
||||
the headset after opening Settings → Developer → Pair new host). Never in an isolated data dir (tests,
|
||||
screenshots: they'd connect to a real Frame on the network) or with FRAMEPORT_NO_AUTO_PAIR."""
|
||||
from ..frame import autopair, devkit
|
||||
from ..frame import autopair, devkit, pairing
|
||||
|
||||
self.auto_pair_status = ""
|
||||
if os.environ.get("FRAMEPORT_HOME") or os.environ.get("FRAMEPORT_NO_AUTO_PAIR"):
|
||||
@@ -2200,7 +2232,7 @@ class FramePortApp:
|
||||
|
||||
def active() -> bool:
|
||||
return (self.frame_state in ("none", "offline") and bool(library.setting("frame.auto_pair", True))
|
||||
and not (self.pairing and self.pairing.running and self.pairing.asks))
|
||||
and not pairing.asking(self.pairing))
|
||||
|
||||
def ready(target) -> None:
|
||||
self.auto_pair_status = ""
|
||||
@@ -2736,24 +2768,33 @@ def serialize_flet_updates() -> None:
|
||||
dict mid-iteration ("dictionary changed size during iteration"): the handler that was updating (a sidebar
|
||||
click while the live Frame card took a sample) died and the page didn't change.
|
||||
Both take one re-entrant lock: re-entrant because did_mount() may update again and the loop's own handlers set
|
||||
properties during a patch."""
|
||||
from flet.controls.value_types import Prop
|
||||
from flet.messaging.session import Session
|
||||
properties during a patch.
|
||||
Both hook Flet internals: if a Flet version moves or renames one, that part is skipped (logged) and the rest
|
||||
still applies."""
|
||||
try:
|
||||
from flet.messaging.session import Session
|
||||
|
||||
if getattr(Session.patch_control, "_serialized", False):
|
||||
return
|
||||
original_patch, original_set = Session.patch_control, Prop.__set__
|
||||
original_patch = Session.patch_control
|
||||
if not getattr(original_patch, "_serialized", False):
|
||||
def patch_control(self, *args, **kwargs):
|
||||
with FLET_LOCK:
|
||||
return original_patch(self, *args, **kwargs)
|
||||
patch_control._serialized = True
|
||||
Session.patch_control = patch_control
|
||||
except (ImportError, AttributeError) as exc:
|
||||
applog.log.warning("Flet's patch sending couldn't be serialized (another Flet version?): %s", exc)
|
||||
try:
|
||||
from flet.controls.value_types import Prop
|
||||
|
||||
def patch_control(self, *args, **kwargs):
|
||||
with FLET_LOCK:
|
||||
return original_patch(self, *args, **kwargs)
|
||||
|
||||
def set_prop(self, obj, value):
|
||||
with FLET_LOCK:
|
||||
original_set(self, obj, value)
|
||||
patch_control._serialized = True
|
||||
Session.patch_control = patch_control
|
||||
Prop.__set__ = set_prop
|
||||
original_set = Prop.__set__
|
||||
if not getattr(original_set, "_serialized", False):
|
||||
def set_prop(self, obj, value):
|
||||
with FLET_LOCK:
|
||||
original_set(self, obj, value)
|
||||
set_prop._serialized = True
|
||||
Prop.__set__ = set_prop
|
||||
except (ImportError, AttributeError) as exc:
|
||||
applog.log.warning("Flet's property writes couldn't be serialized (another Flet version?): %s", exc)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
|
||||
@@ -65,8 +65,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
|
||||
"moments the game lost focus. Game settings apply at the next start; other patches reinstall the "
|
||||
"game. Your saves stay.",
|
||||
"install_links": "Some websites have \"Install with FrameDrop\" buttons. FramePort opens these links, shows what "
|
||||
"they offer and installs it when you agree. On macOS, use Add games → Add from a link… instead.",
|
||||
"install_links": "Some websites have \"Install with FramePort\" buttons (frameport.app links). FramePort opens "
|
||||
"these links, shows what they offer and installs it when you agree. On macOS, use Add games → "
|
||||
"Add from a link… instead.",
|
||||
"linux_x86": "The Frame has an arm64 processor. Programs built for x86_64 PCs run through Valve's translator, "
|
||||
"like Steam's x86 Linux games, but slower. Use an arm64 build when there is one.",
|
||||
"flatscreen": "How the app shows in the headset: in VR, or as a flat window like a phone or tablet app. "
|
||||
@@ -118,6 +119,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.",
|
||||
"move_game": "Copies the game and its saves to the other drive, checks the copy, then removes the old one. The "
|
||||
"game must be closed. A game on a microSD card only starts while the card is in.",
|
||||
"vr_overlay": "A SteamVR overlay app: SteamVR on the Frame draws it over whatever you play, Quest and PC VR "
|
||||
"games alike. With 'Start with SteamVR' on, SteamVR starts it by itself; otherwise start it from "
|
||||
"the library before the game.",
|
||||
"desktop_entry": "Adds the app to Desktop Mode's app menu and desktop. Some apps work better there with a mouse "
|
||||
"and keyboard.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes take effect the "
|
||||
|
||||
@@ -298,7 +298,12 @@ class FrameView:
|
||||
frame=ask.frame, digits=ask.digits), action=tr("Allow"),
|
||||
on_action=lambda e: server.decide(ask.id, True))
|
||||
app.page.run_thread(show)
|
||||
server = app.pairing = PairingServer(on_paired=on_paired, host=host, on_ask=on_ask).start()
|
||||
|
||||
def on_expire(asks): # a Frame stopped asking (setup.sh ended): its Allow card goes
|
||||
if app.route[0] == "frame" and app.pairing is server:
|
||||
app.page.run_thread(show_command)
|
||||
server = app.pairing = PairingServer(on_paired=on_paired, host=host, on_ask=on_ask,
|
||||
on_expire=on_expire).start()
|
||||
app.pairing_mode = mode
|
||||
show_command()
|
||||
|
||||
@@ -425,7 +430,7 @@ class FrameView:
|
||||
*([C.meta(tr("No keyboard? Open {page} in Chromium on the Frame, copy the line and paste it into "
|
||||
"Konsole. This PC shows there as \"{words}\".").format(page=SETUP_PAGE, words=pc_words()))]
|
||||
if by_url else []),
|
||||
*[ask_card(a) for a in server.asks if a.state == "open"],
|
||||
*[ask_card(a) for a in server.open_asks()],
|
||||
ft.Row([C.spinner(),
|
||||
C.meta(tr("Waiting for your Frame to ask…") if by_url else
|
||||
tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
|
||||
|
||||
@@ -579,6 +579,10 @@ class GameView:
|
||||
value=g.get("desktop_entry", True) is not False,
|
||||
on_change=self.set_desktop_entry), "desktop_entry"),
|
||||
]
|
||||
if extra.get("vr_overlay"): # a SteamVR overlay app (wrist watch, fps counter, ...)
|
||||
rows.append(C.kv(tr("SteamVR overlay"), C.switch(
|
||||
tr("Start with SteamVR"), value=g.get("vr_overlay_autostart", True) is not False,
|
||||
on_change=self.set_overlay_autostart), "vr_overlay"))
|
||||
return C.section(tr("What FramePort will do"), C.card(ft.Column([
|
||||
ft.Row([ft.Icon(ft.Icons.TERMINAL_ROUNDED, color=T.PC, size=T.px(18)),
|
||||
C.body(lead, T.TEXT, weight=ft.FontWeight.W_500, expand=True)], spacing=T.S2),
|
||||
@@ -597,6 +601,19 @@ class GameView:
|
||||
app.toast(tr("Added to Desktop Mode") if on else tr("Removed from Desktop Mode"))
|
||||
app.run_bg(work)
|
||||
|
||||
def set_overlay_autostart(self, e) -> None:
|
||||
"""A Linux overlay app started by SteamVR itself (SteamVR's auto-launch): kept with the game, applied on the
|
||||
Frame at once when it's installed there."""
|
||||
app, pkg, on = self.app, self.package, bool(e.control.value)
|
||||
|
||||
def work():
|
||||
library.upsert_game(pkg, vr_overlay_autostart=on)
|
||||
if app.frame_state == "connected" and app.target and \
|
||||
C.install_state(self.g, app.frame_info) in ("installed", "outdated"):
|
||||
app.target.set_vr_overlay(pkg, on)
|
||||
app.toast(tr("Starts with SteamVR") if on else tr("Start it from the library"))
|
||||
app.run_bg(work)
|
||||
|
||||
def recipe_summary(self) -> ft.Control:
|
||||
if self.linux:
|
||||
return self.linux_summary()
|
||||
|
||||
@@ -25,7 +25,7 @@ def kind_label(kind: str | None) -> str:
|
||||
|
||||
def show_paste_dialog(app: FramePortApp) -> None:
|
||||
"""Add games → "Install from link…": paste a FrameDrop/FramePort button link, a manifest or a file URL."""
|
||||
field = C.field(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
|
||||
field = C.field(label=tr("Link"), hint_text="https://frameport.app/install?manifest=…", autofocus=True,
|
||||
expand=True)
|
||||
|
||||
def go(e=None):
|
||||
@@ -38,7 +38,7 @@ def show_paste_dialog(app: FramePortApp) -> None:
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Install from a link"),
|
||||
ft.Column([
|
||||
C.body(tr("Paste an install link: a FrameDrop button's address, a manifest (.json) or a direct link to "
|
||||
C.body(tr("Paste an install link: an install button's address, a manifest (.json) or a direct link to "
|
||||
"an APK, .zip or .exe.")),
|
||||
ft.Row([field, C.help_icon("install_links")]),
|
||||
], tight=True, spacing=T.S3),
|
||||
|
||||
@@ -11,6 +11,9 @@ def launch_test(frame: Frame, package: str, reporter: Reporter, seconds: int = 4
|
||||
reporter.log(f"starting {package} for {seconds}s; without the headset worn the game can't reach FOCUSED, "
|
||||
"so this checks startup, not the picture")
|
||||
res = frame.agent("launch_test", timeout=seconds + 120, package=package, seconds=seconds)
|
||||
if res.get("skipped"): # agent >= 75: SteamVR overlay apps show nothing without a game
|
||||
reporter.check("Launch test", None, f"skipped ({res['skipped']})")
|
||||
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:])
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""The automatic pairing's network scan: only real LAN interfaces, and it backs off while it finds nothing."""
|
||||
import socket
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
from frameport.frame import autopair, discovery
|
||||
from frameport.frame.connection import FrameTarget
|
||||
from frameport.frame.discovery import is_virtual_interface, scannable_address
|
||||
|
||||
|
||||
class Clock:
|
||||
t = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def test_virtual_and_vpn_interfaces_are_not_scanned():
|
||||
for name in ("docker0", "br-3f2a", "veth12ab", "virbr0", "tun0", "tap0", "wg0", "tailscale0", "zt5u4y",
|
||||
"utun3", "vEthernet (WSL (Hyper-V firewall))", "vEthernet (Default Switch)", "Tailscale",
|
||||
"ZeroTier One [8056c2e21c]", "OpenVPN TAP-Windows6", "VirtualBox Host-Only Network", "vmnet8"):
|
||||
assert is_virtual_interface(name), name
|
||||
for name in ("eth0", "wlan0", "enp3s0", "wlp2s0", "en0", "Wi-Fi", "Ethernet 2", "usb0", "enx0a1b2c3d4e5f"):
|
||||
assert not is_virtual_interface(name), name
|
||||
for addr in ("192.168.1.20", "10.0.0.7", "10.86.200.234"):
|
||||
assert scannable_address(addr), addr
|
||||
for addr in ("127.0.0.1", "169.254.3.4", "100.64.0.1", "100.101.102.103", "100.127.255.254", "172.17.0.1",
|
||||
"0.0.0.0", "nonsense"):
|
||||
assert not scannable_address(addr), addr
|
||||
assert scannable_address("100.128.0.1") # just outside CGNAT
|
||||
|
||||
|
||||
def test_local_subnets_skip_virtual(monkeypatch):
|
||||
import psutil
|
||||
|
||||
addrs = {"wlan0": [NS(family=socket.AF_INET, address="192.168.1.20")],
|
||||
"docker0": [NS(family=socket.AF_INET, address="10.9.0.1")],
|
||||
"tailscale0": [NS(family=socket.AF_INET, address="100.70.1.2")],
|
||||
"eth1": [NS(family=socket.AF_INET, address="100.70.1.3")], # CGNAT under a plain name
|
||||
"eth2": [NS(family=socket.AF_INET, address="10.1.2.3")]} # down
|
||||
stats = {n: NS(isup=n != "eth2") for n in addrs}
|
||||
monkeypatch.setattr(psutil, "net_if_addrs", lambda: addrs)
|
||||
monkeypatch.setattr(psutil, "net_if_stats", lambda: stats)
|
||||
assert [str(n) for n in discovery.local_subnets()] == ["192.168.1.0/24"]
|
||||
|
||||
|
||||
def test_scan_backs_off_after_empty_scans():
|
||||
clock = Clock()
|
||||
s = autopair.ScanSchedule(clock=clock)
|
||||
nets = ("192.168.1.0/24",)
|
||||
waits = []
|
||||
assert s.due(nets) # never scanned
|
||||
for _ in range(7):
|
||||
s.done([])
|
||||
start = clock.t
|
||||
while not s.due(nets):
|
||||
clock.t += 1
|
||||
waits.append(clock.t - start)
|
||||
assert waits == [30, 60, 120, 240, 300, 300, 300]
|
||||
s.done([FrameTarget("192.168.1.9")]) # found something: back to every 30 s
|
||||
clock.t += 29
|
||||
assert not s.due(nets)
|
||||
clock.t += 1
|
||||
assert s.due(nets)
|
||||
|
||||
|
||||
def test_scan_backoff_resets():
|
||||
clock = Clock()
|
||||
s = autopair.ScanSchedule(clock=clock)
|
||||
nets = ("192.168.1.0/24",)
|
||||
s.due(nets)
|
||||
for _ in range(4):
|
||||
s.done([])
|
||||
assert s.interval() == 240 and not s.due(nets)
|
||||
assert s.due(("10.0.0.0/24",)) and s.interval() == 30 # another network: scan now, start over
|
||||
s.done([])
|
||||
s.done([])
|
||||
assert not s.due(("10.0.0.0/24",))
|
||||
s.reset() # the Frame page was opened
|
||||
assert s.due(("10.0.0.0/24",)) and s.interval() == 30
|
||||
|
||||
|
||||
def test_rescan_soon_resets_the_shared_schedule(monkeypatch):
|
||||
s = autopair.ScanSchedule(empty=4, last=0.0)
|
||||
monkeypatch.setattr(autopair, "_schedule", s)
|
||||
autopair.rescan_soon()
|
||||
assert s.empty == 0 and s.last is None
|
||||
|
||||
|
||||
def test_scan_follows_the_schedule(monkeypatch):
|
||||
monkeypatch.setattr(discovery, "local_subnets", lambda: [])
|
||||
monkeypatch.setattr(discovery, "local_addresses", lambda: set())
|
||||
clock = Clock()
|
||||
s = autopair.ScanSchedule(clock=clock)
|
||||
assert autopair._scan_devkits(s) == [] and s.empty == 1 and s.last == 0
|
||||
clock.t += 5
|
||||
autopair._scan_devkits(s)
|
||||
assert s.empty == 1 # not due yet: no scan
|
||||
@@ -23,6 +23,8 @@ MANIFEST = "https://cdn.example.com/game.framedrop.json"
|
||||
@pytest.mark.parametrize("link", [
|
||||
f"framedrop://install?manifest={quote(MANIFEST, safe='')}",
|
||||
f"frameport://install?manifest={quote(MANIFEST, safe='')}",
|
||||
f"https://frameport.app/install?manifest={quote(MANIFEST, safe='')}",
|
||||
f"https://frameport.app/install/?manifest={MANIFEST}",
|
||||
f"https://framedropvr.com/install?manifest={quote(MANIFEST, safe='')}",
|
||||
f"https://framedropvr.com/install/?manifest={MANIFEST}",
|
||||
MANIFEST,
|
||||
|
||||
@@ -165,6 +165,54 @@ def test_problem_url(tmp_path):
|
||||
f = _fields(url)
|
||||
assert f["template"] == "bug-report.yml" and "FramePort-diag-a.zip" in f["logs"]
|
||||
assert "missing-ovr-symbol" in f["findings"] and "alice" not in url
|
||||
assert f["title"].startswith("[Game report] ") and f["labels"] == "game-compatibility"
|
||||
f = _fields(issue.problem_url(None, "the window stays white", {"app": "x"}, None))
|
||||
assert f["title"] == "[App] FramePort" and f["labels"] == "bug"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template,layout", [("working-config.yml", issue.WORKING_CONFIG_FIELDS),
|
||||
("bug-report.yml", issue.PROBLEM_FIELDS)])
|
||||
def test_issue_body_labels_match_the_forms(template, layout):
|
||||
form = yaml.safe_load((ROOT / ".github/ISSUE_TEMPLATE" / template).read_text(encoding="utf-8"))
|
||||
fields = {f["id"]: (f["attributes"]["label"], f["attributes"].get("render"))
|
||||
for f in form["body"] if f.get("id") and f["type"] in ("input", "textarea")}
|
||||
assert fields == layout
|
||||
if template == "working-config.yml":
|
||||
confirm = next(f for f in form["body"] if f.get("id") == "confirm")["attributes"]
|
||||
assert issue.WORKING_CONFIG_CONFIRM == (confirm["label"], confirm["options"][0]["label"])
|
||||
|
||||
|
||||
def test_working_config_body_round_trips_through_the_catalog_script(tmp_path):
|
||||
"""GitHub #167: the plain-issue body (and the clipboard copy) is what the issue form would have written."""
|
||||
g = _game(tmp_path)
|
||||
e = catalog.entry_from_library(g, status="works", notes="n", verified={"app": "0.2.0"})
|
||||
env = {"app": "0.2.0", "os": "Linux"}
|
||||
body = issue.working_config_body(g, catalog.to_yaml(e), "works", "", env, redact.default())
|
||||
assert body.startswith("### Game\n\n") and "### Notes\n\n_No response_" in body
|
||||
assert "```yaml\npackage: com.example.game" in body and "- [X] I played the game" in body
|
||||
s = _script()
|
||||
assert s.main([str(_write(tmp_path, body)), "--issue", "9", "--out", str(tmp_path)]) == 0
|
||||
d = yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())
|
||||
assert d["frame"] == ["frame.nodebug"] and d["verified"]["issue"] == 9
|
||||
links = issue.working_config_links(g, catalog.to_yaml(e), "works", "played /home/alice fine" + "x" * 20000, env,
|
||||
redact.default())
|
||||
assert links.form == issue.working_config_url(g, catalog.to_yaml(e), "works",
|
||||
"played /home/alice fine" + "x" * 20000, env, redact.default())
|
||||
f = _fields(links.plain)
|
||||
assert "template" not in f and f["title"] == "[Working recipe] Example" and f["labels"] == "working-config"
|
||||
assert len(links.plain) <= issue.MAX_URL + 200 and "alice" not in links.plain
|
||||
assert "[... cut" in s.section(f["body"], "Notes") # notes trimmed first, the recipe never
|
||||
assert s.validate(s.recipe_from_body(f["body"])).package == "com.example.game"
|
||||
assert "x" * 20000 in links.body # the clipboard copy isn't trimmed
|
||||
|
||||
|
||||
def test_problem_links(tmp_path):
|
||||
g = _game(tmp_path)
|
||||
links = issue.problem_links(g, "crashes", {"app": "x"}, "FramePort-diag-a.zip", "package: com.example.game\n")
|
||||
f = _fields(links.plain)
|
||||
assert f["labels"] == "game-compatibility" and f["body"] == links.body
|
||||
assert "### What happens?\n\ncrashes" in links.body and "FramePort-diag-a.zip" in links.body
|
||||
assert "### Recipe\n\n```yaml\npackage: com.example.game\n```" in links.body
|
||||
|
||||
|
||||
def test_entry_from_library_matches_save_known_good_shape(tmp_path):
|
||||
|
||||
@@ -383,10 +383,10 @@ def test_linux_launcher_from_desktop_mode(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
prep, _ = linux_install(a, tmp_path)
|
||||
text = (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
assert '[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1' in text
|
||||
assert '[[ -n "${FRAMEPORT_DESKTOP:-}${FRAMEPORT_OVERLAY:-}" ]] && parent=1' in text
|
||||
assert 'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then' in text
|
||||
# older launchers are upgraded in place
|
||||
old = text.replace('[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1\n', "").replace(
|
||||
old = text.replace('[[ -n "${FRAMEPORT_DESKTOP:-}${FRAMEPORT_OVERLAY:-}" ]] && parent=1\n', "").replace(
|
||||
'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then', 'if [[ -z "${DISPLAY:-}" ]]; then')
|
||||
old = old.replace("# Started from Desktop Mode's menu (FRAMEPORT_DESKTOP=1, GitHub #84) the desktop's own session "
|
||||
"is used.\n", "")
|
||||
@@ -394,7 +394,8 @@ def test_linux_launcher_from_desktop_mode(monkeypatch, tmp_path):
|
||||
(Path(prep["anchor"]) / "launch.sh").write_text(old)
|
||||
assert "linux.true" in a.upgrade_launchers()
|
||||
new = (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
assert '[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1' in new and "FRAMEPORT_DESKTOP:-}\" && -z" in new
|
||||
assert '[[ -n "${FRAMEPORT_DESKTOP:-}${FRAMEPORT_OVERLAY:-}" ]] && parent=1' in new
|
||||
assert "FRAMEPORT_DESKTOP:-}\" && -z" in new
|
||||
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
|
||||
# the watchdog: a parent that exits ends the app, unless started from the desktop
|
||||
script = tmp_path / "app.sh"
|
||||
|
||||
@@ -237,3 +237,20 @@ def test_fps_target():
|
||||
assert M.fps_target([]) is None and M.fps_target([None]) is None
|
||||
assert M.fps_target([50.0, 71.9, 72.4]) == 72 and M.fps_target([88.0, 90.1]) == 90
|
||||
assert M.fps_target([200]) == 144
|
||||
|
||||
|
||||
def test_modules_need_the_agent_that_has_them():
|
||||
"""Per-module collection came to main with agent 73 (main's 72 had `_monitor` without it): an older agent would
|
||||
ignore {"modules": …} and keep sending everything, so the client must not count on it."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py").read_text()
|
||||
version = next(n.value.value for n in ast.parse(src).body if isinstance(n, ast.Assign)
|
||||
and getattr(n.targets[0], "id", "") == "AGENT_VERSION")
|
||||
assert "def set_modules" in src and M.MIN_AGENT_MODULES <= version
|
||||
assert M.MIN_AGENT_MODULES == 73
|
||||
s = SimpleNamespace(static={"agent": 72})
|
||||
assert not M.MonitorSession.supports_modules.fget(s)
|
||||
s.static["agent"] = 73
|
||||
assert M.MonitorSession.supports_modules.fget(s)
|
||||
@@ -179,3 +179,112 @@ def test_first_key_is_made_once_under_concurrency():
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert not errors and len(set(keys)) == 1
|
||||
|
||||
|
||||
def test_cli_pair_does_not_announce(monkeypatch):
|
||||
"""`frameport frame pair` can't answer the setup URL's requests (no Allow button), so it must not announce
|
||||
itself: a Frame running the setup URL would find it and wait for an answer that never comes."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
made = []
|
||||
|
||||
class Fake:
|
||||
def __init__(self, **kw):
|
||||
made.append(kw)
|
||||
self.paired = []
|
||||
self.one_liner = "curl -fsS 127.0.0.1:8765/code | bash"
|
||||
|
||||
def start(self):
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(pairing, "PairingServer", Fake)
|
||||
result = CliRunner().invoke(cli.app, ["frame", "pair", "--timeout", "0"])
|
||||
assert result.exit_code == 1 and "timed out" in result.output
|
||||
assert made and made[0].get("announce") is False
|
||||
|
||||
|
||||
def test_open_requests_expire_when_polling_stops(server):
|
||||
"""A Frame that stopped polling /wait (setup.sh ended) no longer blocks new requests, and its card goes."""
|
||||
gone = []
|
||||
server.on_expire = gone.extend
|
||||
for i in range(pairing.MAX_ASKS):
|
||||
hello(server, nonce=f"nonce{i:011d}")
|
||||
assert get(server, "/hello?host=x&nonce=0123456789abcdef")[0] == 429
|
||||
stale = server.asks[0]
|
||||
stale.seen -= 2 * pairing.WAIT + 1
|
||||
assert stale not in server.open_asks() # the UI drops it at once
|
||||
status, _ = get(server, "/hello?host=x&nonce=0123456789abcdef")
|
||||
assert status == 200 and stale not in server.asks and stale.state == "expired" and gone == [stale]
|
||||
server.decide(stale.id, True) # Allow on a stale toast: nothing
|
||||
assert stale.state == "expired"
|
||||
assert get(server, f"/wait?id={stale.id}")[0] == 404
|
||||
|
||||
|
||||
def test_polled_and_decided_requests(server):
|
||||
now = time.time()
|
||||
a = pairing.Ask("a", "f", "1.2.3.4", "0000", created=now - 10 * pairing.WAIT)
|
||||
a.waiting = 1 # a /wait is held right now: still alive however old
|
||||
b = pairing.Ask("b", "f", "1.2.3.4", "0000", state="allowed", created=now - 60)
|
||||
b.decided = now - 60
|
||||
c = pairing.Ask("c", "f", "1.2.3.4", "0000", state="denied", created=now - pairing.DECIDED_KEEP - 100)
|
||||
c.decided = now - pairing.DECIDED_KEEP - 1
|
||||
server.asks.extend([a, b, c])
|
||||
assert server.prune(now) == [c] and server.asks == [a, b]
|
||||
assert server.open_asks() == [a]
|
||||
a.waiting = 0
|
||||
assert server.prune(now) == [a]
|
||||
|
||||
|
||||
def test_polling_keeps_a_request_alive(server, monkeypatch):
|
||||
monkeypatch.setattr(pairing, "WAIT", 0.2)
|
||||
reply = hello(server)
|
||||
ask = server.asks[0]
|
||||
ask.seen -= 100
|
||||
assert get(server, f"/wait?id={reply['id']}")[0] == 202
|
||||
assert time.time() - ask.seen < 1 and ask.waiting == 0 and server.open_asks() == [ask]
|
||||
|
||||
|
||||
def test_expiry_loop_runs(monkeypatch):
|
||||
monkeypatch.setattr(pairing, "EXPIRE_EVERY", 0.05)
|
||||
gone = []
|
||||
srv = PairingServer(announce=False, host="127.0.0.1", on_expire=gone.extend).start()
|
||||
try:
|
||||
srv.asks.append(pairing.Ask("x", "f", "1.2.3.4", "0000", created=time.time() - 3 * pairing.WAIT))
|
||||
for _ in range(100):
|
||||
if gone:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert [a.id for a in gone] == ["x"] and not srv.asks
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_only_open_requests_hold_off_auto_pairing(server):
|
||||
"""Automatic pairing waits only while a Frame waits for Allow / Deny, not for decided or stale requests."""
|
||||
assert not pairing.asking(None) and not pairing.asking(server)
|
||||
hello(server)
|
||||
assert pairing.asking(server)
|
||||
server.decide(server.asks[0].id, False)
|
||||
assert not pairing.asking(server) # denied: no longer waiting
|
||||
hello(server, nonce="fedcba9876543210")
|
||||
server.asks[-1].seen -= 3 * pairing.WAIT
|
||||
assert not pairing.asking(server) # its Frame stopped asking
|
||||
hello(server, nonce="00112233445566778899")
|
||||
server.stop()
|
||||
assert not pairing.asking(server) # setup page closed
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32" or not shutil.which("bash") or not shutil.which("curl"),
|
||||
reason="needs bash and curl")
|
||||
def test_setup_sh_says_why_when_full(server):
|
||||
for i in range(pairing.MAX_ASKS):
|
||||
hello(server, nonce=f"nonce{i:011d}")
|
||||
r = subprocess.run(["bash", str(ROOT / "bootstrap" / "setup.sh"), "--pc", f"127.0.0.1:{server.port}"],
|
||||
capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL)
|
||||
assert r.returncode == 1 and "already has several setup requests" in r.stdout
|
||||
assert "didn't answer" not in r.stdout
|
||||
@@ -523,6 +523,32 @@ def test_flet_updates_are_serialized():
|
||||
Session.patch_control, Prop.__set__ = original, original_set
|
||||
|
||||
|
||||
def test_serialize_survives_moved_flet_internals(monkeypatch, caplog):
|
||||
"""A Flet version without value_types.Prop (or Session.patch_control) must not stop the GUI from starting: that
|
||||
part is skipped and logged, the other still applies."""
|
||||
import logging
|
||||
|
||||
import flet.controls.value_types as value_types
|
||||
from flet.messaging.session import Session
|
||||
|
||||
from frameport.ui import app
|
||||
|
||||
original, original_set = Session.patch_control, value_types.Prop.__set__
|
||||
try:
|
||||
Session.patch_control = lambda self, *a, **k: None
|
||||
monkeypatch.delattr(value_types, "Prop")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
app.serialize_flet_updates()
|
||||
assert getattr(Session.patch_control, "_serialized", False)
|
||||
assert "property writes couldn't be serialized" in caplog.text
|
||||
monkeypatch.undo()
|
||||
monkeypatch.delattr(Session, "patch_control")
|
||||
app.serialize_flet_updates() # nothing to wrap: no exception
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
Session.patch_control, value_types.Prop.__set__ = original, original_set
|
||||
|
||||
|
||||
def test_property_writes_wait_for_a_patch_being_packed():
|
||||
"""A property set on a background thread (the live Frame card, job progress) while the event loop packs a patch
|
||||
changed the control's _values mid-iteration: "dictionary changed size during iteration" killed the update (a
|
||||
|
||||
@@ -171,6 +171,64 @@ def test_agent_collects_the_newest_modules(monkeypatch, tmp_path):
|
||||
assert agent.AGENT_VERSION >= 72
|
||||
|
||||
|
||||
def _module(d, i, size=1000):
|
||||
data = bytes([i]) * size
|
||||
n = f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv"
|
||||
(d / n).write_bytes(data)
|
||||
return n
|
||||
|
||||
|
||||
def test_agent_collects_the_newest_session(monkeypatch, tmp_path):
|
||||
"""GitHub #140: every module of the newest session (index.txt), last used first, not the newest written."""
|
||||
agent = _load_agent(monkeypatch, tmp_path)
|
||||
files = tmp_path / "files"
|
||||
d = files / "fp_vk_shaders"
|
||||
d.mkdir(parents=True)
|
||||
old = [_module(d, i) for i in range(3)] # only in the first session
|
||||
a, b, c = (_module(d, i) for i in (10, 11, 12))
|
||||
big = _module(d, 13, size=3000)
|
||||
now = time.time()
|
||||
for i, n in enumerate(old):
|
||||
os.utime(d / n, (now + i, now + i)) # the newest written: would have won before
|
||||
(d / "index.txt").write_text(
|
||||
"# start 100 pid 1\n" + "".join(f"{i} 0 1000 {n} new\n" for i, n in enumerate(old + [a]))
|
||||
+ "# start 200 pid 2\n"
|
||||
f"1 0 2000 {a} known\n2 5 2005 {b} new\n3 9 2009 {big} new\n4 10 2010 {c} new\n5 20 2020 {b} again\n"
|
||||
f"6 21 2021 {'1_' + 'f' * 64}.spv failed\n")
|
||||
res = agent.shader_dumps(str(files))["fp_vk_shaders"]
|
||||
assert res["session"] == 5 and res["total"] == 7
|
||||
assert list(res["modules"]) == [b, c, big, a] # last use in the session, newest first; missing file skipped
|
||||
assert not set(old) & set(res["modules"])
|
||||
assert res["index"].startswith("[... earlier sessions left out ...]\n# start 200") and "pid 1" not in res["index"]
|
||||
assert res["skipped"] == 1
|
||||
# the total cap: 1000 + 1000 fit, the 3000-byte module doesn't, the next smaller one still does
|
||||
capped = agent.shader_dumps(str(files), session_bytes=3000)["fp_vk_shaders"]
|
||||
assert list(capped["modules"]) == [b, c, a] and capped["skipped"] == 2
|
||||
monkeypatch.setattr(agent, "SHADER_MODULE_BYTES", 2000) # each module's cap
|
||||
assert big not in agent.shader_dumps(str(files))["fp_vk_shaders"]["modules"]
|
||||
# an index whose newest session names no module: the newest written, as before
|
||||
(d / "index.txt").write_text("# start 300 pid 3\n")
|
||||
fallback = agent.shader_dumps(str(files), max_bytes=2500)["fp_vk_shaders"]
|
||||
assert "session" not in fallback and list(fallback["modules"]) == [old[2], old[1]]
|
||||
assert agent.AGENT_VERSION >= 74
|
||||
|
||||
|
||||
def test_bundle_drops_old_modules_to_fit(tmp_path):
|
||||
w = bundle._Writer(redact.Redactor())
|
||||
names = []
|
||||
for _ in range(16):
|
||||
data = os.urandom(20000) # incompressible
|
||||
names.append(f"{len(data)}_{hashlib.sha256(data).hexdigest()}.spv")
|
||||
w.files["s/" + names[-1]] = data
|
||||
w.fit(limit=200000)
|
||||
kept = [n for n in names if "s/" + n in w.files]
|
||||
assert kept and kept == names[:len(kept)] and len(kept) < 16 # the newest (first) stay
|
||||
assert any("shader module" in m for m in w.warnings)
|
||||
w2 = bundle._Writer(redact.Redactor())
|
||||
bundle.add_shader_dumps(w2, "g/", {"fp_vk_shaders": {"total": 50, "session": 3, "modules": {}}})
|
||||
assert any("newest session" in m for m in w2.warnings)
|
||||
|
||||
|
||||
def test_bundle_writes_the_dumps(tmp_path):
|
||||
w = bundle._Writer(redact.Redactor())
|
||||
data = b"\x03\x02\x23\x07" * 5
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""SteamVR overlay apps (fpsVR, wrist watches): detection, the pcvr.vr_overlay patch, the agent's registration with
|
||||
SteamVR (agent v75; IVRApplications faked) and what overlay apps skip."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from test_drives import linux_install, load
|
||||
|
||||
from frameport.analysis import linux, vroverlay
|
||||
from frameport.core.models import Analysis, Recipe
|
||||
|
||||
MANIFEST = {"source": "builtin", "applications": [{
|
||||
"app_key": "temporalreality.overlay", "launch_type": "binary", "binary_path_linux": "TemporalReality",
|
||||
"is_dashboard_overlay": True, "image_path": "assets/icon.png", "arguments": "--quiet",
|
||||
"strings": {"en_us": {"name": "Temporal Reality", "description": "A watch."}}}]}
|
||||
|
||||
|
||||
class FakeApps:
|
||||
"""SteamVRApps stand-in: records manifests, auto-launch flags and launches."""
|
||||
|
||||
def __init__(self):
|
||||
self.paths, self.auto, self.launched = [], {}, []
|
||||
|
||||
def _keys(self):
|
||||
keys = set()
|
||||
for p in self.paths:
|
||||
keys |= {app["app_key"] for app in json.load(open(p))["applications"]}
|
||||
return keys
|
||||
|
||||
def add(self, path):
|
||||
self.paths.append(path)
|
||||
|
||||
def remove(self, path):
|
||||
self.paths = [p for p in self.paths if p != path]
|
||||
|
||||
def installed(self, key):
|
||||
return key in self._keys()
|
||||
|
||||
def set_autolaunch(self, key, on):
|
||||
self.auto[key] = on
|
||||
|
||||
def autolaunch(self, key):
|
||||
return self.auto.get(key, False)
|
||||
|
||||
def launch(self, key):
|
||||
self.launched.append(key)
|
||||
|
||||
def pid(self, key):
|
||||
return 4242 if key in self.launched else 0
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def fake_steamvr(monkeypatch, a, running=True):
|
||||
apps = FakeApps()
|
||||
monkeypatch.setattr(a.SteamVRApps, "open", classmethod(lambda cls: apps if running else None))
|
||||
return apps
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ detection
|
||||
def test_bundled_manifest_marks_an_overlay_app(tmp_path):
|
||||
(tmp_path / "app").mkdir()
|
||||
(tmp_path / "app" / "watch.vrmanifest").write_text(json.dumps(MANIFEST))
|
||||
found = vroverlay.detect(tmp_path, tmp_path / "app" / "Watch", b"", openvr=False)
|
||||
assert found["vr_overlay"] and "watch.vrmanifest" in found["vr_overlay_from"]
|
||||
assert found["vr_overlay_app"] == {"app": {k: MANIFEST["applications"][0][k] for k in vroverlay.APP_FIELDS},
|
||||
"manifest": "app/watch.vrmanifest", "manifest_dir": "app"}
|
||||
# a scene app's manifest isn't an overlay
|
||||
scene = {"applications": [{"app_key": "game", "launch_type": "binary"}]}
|
||||
(tmp_path / "app" / "watch.vrmanifest").write_text(json.dumps(scene))
|
||||
assert not vroverlay.detect(tmp_path, tmp_path / "app" / "Watch", b"", openvr=False)["vr_overlay"]
|
||||
|
||||
|
||||
def test_overlay_heuristic_needs_openvr_overlay_and_self_registration(tmp_path):
|
||||
exe = tmp_path / "fpsVR.exe"
|
||||
code = "IVROverlay_027".encode("utf-16-le") + b"\0" + "vrmanifest".encode("utf-16-le") # a C# app's strings
|
||||
assert vroverlay.detect(tmp_path, exe, code, openvr=True)["vr_overlay"]
|
||||
assert not vroverlay.detect(tmp_path, exe, code, openvr=False)["vr_overlay"]
|
||||
assert not vroverlay.detect(tmp_path, exe, code, openvr=True, engine="Unity")["vr_overlay"] # games use overlays
|
||||
assert not vroverlay.detect(tmp_path, exe, b"IVROverlay_027 IVRCompositor_028", openvr=True)["vr_overlay"]
|
||||
|
||||
|
||||
def test_linux_app_with_overlay_manifest(tmp_path):
|
||||
root = tmp_path / "Watch-1.0-linux-arm64"
|
||||
root.mkdir()
|
||||
shutil.copy("/bin/true", root / "Watch")
|
||||
(root / "watch.vrmanifest").write_text(json.dumps(MANIFEST))
|
||||
info = linux.inspect(root)
|
||||
assert info["vr_overlay"] and info["vr_overlay_app"]["app"]["app_key"] == "temporalreality.overlay"
|
||||
|
||||
|
||||
def test_pcvr_patch_and_install_arguments():
|
||||
from frameport import pipeline
|
||||
from frameport.patches import base
|
||||
|
||||
patch = base.get("pcvr.vr_overlay")
|
||||
extra = {"kind": "rift", "vr_overlay": True, "vr_overlay_from": "x declares a SteamVR dashboard overlay",
|
||||
"vr_overlay_app": {"app": {"app_key": "k.overlay"}, "manifest": "m.vrmanifest", "manifest_dir": "."}}
|
||||
a = Analysis("rift.watch", "", "Watch", ["x86_64"], "Other", "OpenVR", "D3D11", False, [], None, False, [],
|
||||
False, 0, False, False, False, extra=extra)
|
||||
s = patch.detect(a)
|
||||
assert s.recommended and s.params == {"autostart": True} and patch.applies(a)
|
||||
recipe = Recipe(package="rift.watch", title="Watch", patches={"pcvr.vr_overlay": {"autostart": False}})
|
||||
entry = {"kind": "rift", "analysis": {"extra": extra}}
|
||||
assert pipeline.rift_overlay(entry, recipe) == {"autostart": False, "app": {"app_key": "k.overlay"},
|
||||
"manifest_dir": "."}
|
||||
assert pipeline.rift_overlay(entry, Recipe(package="rift.watch", title="Watch")) is None
|
||||
linux_entry = {"kind": "linux", "analysis": {"extra": extra}}
|
||||
assert pipeline.linux_overlay(linux_entry)["autostart"] is True
|
||||
assert pipeline.linux_overlay({**linux_entry, "vr_overlay_autostart": False})["autostart"] is False
|
||||
assert pipeline.linux_overlay({**linux_entry, "vr_overlay": False}) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ agent
|
||||
def overlay_arg(autostart=True):
|
||||
return {"autostart": autostart, "app": MANIFEST["applications"][0], "manifest_dir": "bin"}
|
||||
|
||||
|
||||
def test_linux_overlay_install_registers_with_steamvr(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
apps = fake_steamvr(monkeypatch, a)
|
||||
prep, res = linux_install(a, tmp_path, overlay=overlay_arg())
|
||||
anchor = Path(prep["anchor"])
|
||||
assert res["overlay"]["registered"] and res["overlay"]["autostart"]
|
||||
assert res["overlay"]["key"] == "temporalreality.overlay" and apps.auto == {"temporalreality.overlay": True}
|
||||
man = json.loads((anchor / a.OVERLAY_MANIFEST).read_text())["applications"][0]
|
||||
# SteamVR on the Frame reads binary_path_linux_arm only; the binary is FramePort's launcher
|
||||
assert man["binary_path_linux_arm"] == man["binary_path_linux"] == str(anchor / "launch.sh")
|
||||
assert man["is_dashboard_overlay"] and man["arguments"] == "--quiet"
|
||||
assert man["image_path"] == str(anchor / "artwork" / "icon.png") # the app's own image isn't there
|
||||
assert man["strings"]["en_us"]["name"] == "Temporal Reality"
|
||||
dep = a.deployment("linux.true")
|
||||
assert dep["overlay"]["key"] == "temporalreality.overlay" and dep["overlay"]["registered"]
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert "export FRAMEPORT_OVERLAY=1" in text # started by SteamVR: no Steam-parent watchdog
|
||||
assert subprocess.run(["bash", "-n", str(anchor / "launch.sh")]).returncode == 0
|
||||
# no launch test, and it doesn't count as a game being played
|
||||
assert a.cmd_launch_test({"package": "linux.true"})["state"] == "SKIPPED"
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: ["123"])
|
||||
monkeypatch.setattr(a, "run", lambda *x, **k: subprocess.CompletedProcess(x, 0, "", ""))
|
||||
assert a.game_running() is False
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
# started through SteamVR
|
||||
assert a.cmd_launch_vr_overlay({"package": "linux.true"}) == {"started": True, "pid": 4242, "error": None}
|
||||
# uninstall: SteamVR forgets it
|
||||
a.cmd_uninstall({"package": "linux.true", "keep_data": False})
|
||||
assert apps.paths == [] and apps.auto["temporalreality.overlay"] is False
|
||||
|
||||
|
||||
def test_overlay_app_image_from_its_files(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
fake_steamvr(monkeypatch, a)
|
||||
prep, _ = linux_install(a, tmp_path)
|
||||
img = Path(prep["base"]) / "app" / "bin" / "assets" / "icon.png"
|
||||
img.parent.mkdir(parents=True)
|
||||
img.write_bytes(b"png")
|
||||
out = a.cmd_register_vr_overlay({"package": "linux.true", "autostart": False, "app": MANIFEST["applications"][0],
|
||||
"manifest_dir": "bin"})
|
||||
man = json.load(open(out["manifest"]))["applications"][0]
|
||||
assert man["image_path"] == str(img) and not out["autostart"]
|
||||
# turning it off (a reinstall without overlay) unregisters it
|
||||
assert a.finalize_overlay("linux.true", None) is None
|
||||
assert "overlay" not in a.deployment("linux.true")
|
||||
assert not os.path.exists(out["manifest"])
|
||||
|
||||
|
||||
def test_overlay_registered_later_when_steamvr_was_off(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
fake_steamvr(monkeypatch, a, running=False)
|
||||
_, res = linux_install(a, tmp_path, overlay=overlay_arg())
|
||||
assert res["overlay"] == {"key": "temporalreality.overlay", "manifest": res["overlay"]["manifest"],
|
||||
"registered": False, "autostart": False, "error": "steamvr"}
|
||||
apps = fake_steamvr(monkeypatch, a)
|
||||
assert a.overlay_registrations() == ["linux.true"]
|
||||
assert apps.installed("temporalreality.overlay") and a.deployment("linux.true")["overlay"]["registered"]
|
||||
assert a.overlay_registrations() == [] # nothing to do the next time
|
||||
|
||||
|
||||
def test_pcvr_overlay_install(monkeypatch, tmp_path):
|
||||
from test_agent import fake_steam_tools
|
||||
|
||||
a = load(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, Path(a.HOME))
|
||||
apps = fake_steamvr(monkeypatch, a)
|
||||
prep = a.cmd_prepare_pcvr({"package": "rift.fpsvr", "title": "fpsVR"})
|
||||
(Path(prep["incoming"]) / "game/fpsVR.exe").write_bytes(b"MZexe")
|
||||
res = a.cmd_finalize_pcvr({"package": "rift.fpsvr", "title": "fpsVR", "exe": "fpsVR.exe", "revive": False,
|
||||
"manifests": {"game": {"fpsVR.exe": 5}}, "overlay": {"autostart": False}})
|
||||
assert res["overlay"]["registered"] and res["overlay"]["key"] == "frameport.rift.fpsvr"
|
||||
assert apps.auto == {"frameport.rift.fpsvr": False}
|
||||
man = json.loads((Path(prep["anchor"]) / a.OVERLAY_MANIFEST).read_text())["applications"][0]
|
||||
assert man["binary_path_linux_arm"].endswith("/rift.fpsvr/launch.sh")
|
||||
assert man["strings"]["en_us"]["name"] == "fpsVR"
|
||||
Reference in new issue
Block a user