Share working configs and diagnostics bundles via GitHub issues

- "Share working config…" (game menu, `frameport share-recipe`): saves the recipe as known-good and opens a
  prefilled working-config issue form; labelling it `catalog-accepted` runs catalog-from-issue.yml, which
  validates the YAML (scripts/catalog_from_issue.py) and opens a PR adding catalog/games/<pkg>.yaml
- "Collect logs" / "Report a problem…" (game menu, Settings, failure pop-up, `frameport diag collect|report`):
  redacted diagnostics zip (app + job logs, library entry, recipe, launch logs + re-triage, APK/ELF/PE listings
  instead of game files, Frame launcher/settings/Lepton/logcat/XRService logs via agent v21 `collect_diag`) and
  a prefilled bug-report issue to attach it to; `frameport diag inspect <zip>` re-triages a bundle offline
- diag/redact.py scrubs IPs, MACs, host/user names, home dirs, Steam ids, data and dump folders
- App log (<data>/logs/app.log) and saved GUI job logs (<data>/logs/jobs/)
- catalog.entry_from_library shared by "Save as known-good" and sharing; docs/DIAGNOSTICS.md
This commit is contained in:
spoopyghosty0 committed 2026-09-30 14:14:00 -04:00
1 parent fc4aa9c8e5
commit dfb4fc4ffd
34 files changed
+1849 -44

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
name: Problem report
description: A game or FramePort doesn't work (FramePort → "Report a problem…" fills this in and saves a diagnostics zip).
title: "[Problem] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Please attach the **diagnostics zip** FramePort saved (drag it into the "Diagnostics" box below). It contains
logs, the recipe and device details with personal data removed — no game files. Without the app, it can be
read with `frameport diag inspect <zip>`.
- type: input
id: game
attributes:
label: Game
description: Title — package id (version, engine / XR API); empty for app problems
- type: textarea
id: description
attributes:
label: What happens?
description: What you did, what you expected, what you saw (in the headset, if it started)
validations:
required: true
- type: textarea
id: findings
attributes:
label: Launch test / triage
description: Filled in by FramePort from the last launch test
- type: textarea
id: recipe
attributes:
label: Recipe
render: yaml
- type: textarea
id: environment
attributes:
label: Environment
- type: textarea
id: logs
attributes:
label: Diagnostics
description: Drag the FramePort-diag-*.zip here (max 25 MB)
validations:
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+49
View File
@@ -0,0 +1,49 @@
name: Working configuration
description: Submit a recipe that works on the Steam Frame (FramePort → game menu → "Share working config…" fills this in).
title: "[Working config] "
labels: ["working-config"]
body:
- type: markdown
attributes:
value: |
Thanks! A maintainer reviews the recipe; once it's labelled `catalog-accepted`, a pull request adding it to
`catalog/games/` is opened automatically. Please don't paste game files or links to them.
- type: input
id: game
attributes:
label: Game
description: Title — package id (platform, version, engine / XR API)
validations:
required: true
- type: input
id: result
attributes:
label: Result
description: works or issues (+ the last headless launch test)
validations:
required: true
- type: textarea
id: recipe
attributes:
label: Recipe
description: The catalog entry (catalog/games/<package>.yaml). Don't edit the package line.
render: yaml
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: FramePort, tool and SteamOS versions
- type: textarea
id: notes
attributes:
label: Notes
description: What you checked in the headset, known issues, anything unusual
- type: checkboxes
id: confirm
attributes:
label: Confirmation
options:
- label: I played the game in the headset with this recipe (headless launch tests can't check the picture).
required: true
+52
View File
@@ -0,0 +1,52 @@
name: catalog-from-issue
# A maintainer adds the label `catalog-accepted` to a "Working configuration" issue → this opens a PR adding the
# recipe to catalog/games/. Never runs for unlabelled issues; the issue text is only read by the validating script
# (never interpolated into shell commands). Needs Settings → Actions → "Allow GitHub Actions to create pull requests".
on:
issues:
types: [labeled]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
recipe-pr:
if: github.event.label.name == 'catalog-accepted' && contains(github.event.issue.labels.*.name, 'working-config')
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ github.event.issue.number }}
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
- run: uv sync
- name: Write the recipe
id: recipe
run: |
gh issue view "$ISSUE" --json body -q .body > "$RUNNER_TEMP/body.md"
uv run python scripts/catalog_from_issue.py "$RUNNER_TEMP/body.md" --issue "$ISSUE" | tee "$RUNNER_TEMP/out.txt"
grep '^package=' "$RUNNER_TEMP/out.txt" >> "$GITHUB_OUTPUT"
- name: Check it loads
env:
PKG: ${{ steps.recipe.outputs.package }}
run: uv run python -c "import os, sys; from frameport.recommend import catalog; sys.exit(os.environ['PKG'] not in catalog.load(refresh=True))"
- name: Open the pull request
env:
PKG: ${{ steps.recipe.outputs.package }}
run: |
branch="catalog/issue-$ISSUE"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add "catalog/games/$PKG.yaml"
git commit -m "catalog: $PKG (from #$ISSUE)"
git push --force origin "$branch"
if ! gh pr view "$branch" >/dev/null 2>&1; then
gh pr create --head "$branch" --title "catalog: $PKG" \
--body "Recipe submitted in #$ISSUE (working configuration). Closes #$ISSUE."
fi
- name: Report a failure on the issue
if: failure()
run: gh issue comment "$ISSUE" --body "Couldn't turn this into a catalog recipe — see the workflow run ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}."
+6
View File
@@ -105,6 +105,12 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
release matching the DLL build date, since Revive's version resources are stale) > portable copy unpacked from
ReviveInstaller.exe in pure Python; never replaces the user's install). Library ids `rift.<slug>`, entries have `kind: rift`.
- `parity.py` — rebuild catalog games from dumps and classify every APK entry difference vs known-good builds.
- `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:
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
- `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 (sshd, app key, avahi service, Lepton).
+85 -1
View File
@@ -33,7 +33,7 @@ import sys
import time
import zlib
AGENT_VERSION = 20
AGENT_VERSION = 21
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1849,6 +1849,90 @@ def launch_test_pcvr(dep, anchor, log, seconds):
"kind": "pcvr", "game_process": bool(game_seen)}
def _tail(path, max_bytes):
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
if size > max_bytes:
f.seek(size - max_bytes)
data = f.read()
text = data.decode("utf-8", "replace")
return (f"[... first {size - max_bytes} bytes cut ...]\n" if size > max_bytes else "") + text
except OSError:
return None
def cmd_collect_diag(args):
"""Everything useful for debugging without the game or the PC app: host runtime facts and, with a package, the
game's launcher, settings, deployment, logs (launch, Lepton logcat, Proton/Revive/Unreal) and its file listing.
Returns {"host": {...}, "files": {name: text}, "listing": ...}; the PC redacts and zips it."""
max_bytes = int(args.get("max_bytes", 2 << 20))
files, host = {}, {}
host["openxr_runtime"] = openxr_runtime()
layers = os.path.join(HOME, ".local/share/openxr/1/api_layers/explicit.d")
host["openxr_layers"] = sorted(os.listdir(layers)) if os.path.isdir(layers) else []
host["kernel_keys"] = key_usage()
host["containers_conf"] = _tail(CONTAINERS_CONF, 20000)
host["podman"] = run(["podman", "ps", "-a", "--filter", "name=lepton", "--format",
"{{.Names}} {{.Status}}"]).stdout[-20000:]
host["steam_running"] = run(["pgrep", "-x", "steam"]).returncode == 0
host["uptime"] = _tail("/proc/uptime", 200)
host["installed"] = [{k: g.get(k) for k in ("package", "title", "kind", "appid", "version", "agent_version")}
for g in cmd_list_installed({})["games"]]
out = {"agent_version": AGENT_VERSION, "host": host, "files": files}
pkg = args.get("package")
if not pkg:
# the OpenXR runtime's own log (XRService-<date>_<time>.log): the newest one
xr = [p for p in glob.glob(os.path.join(STEAM, "logs/XRService-*.log")) +
glob.glob(os.path.join(STEAM, "logs/XRService-*/XRService-*.log")) if os.path.isfile(p)]
for p in sorted(xr, key=os.path.getmtime, reverse=True)[:1]:
files[os.path.basename(p)] = _tail(p, max_bytes)
return out
pkg = check_pkg(pkg)
dep = deployment(pkg)
anchor = os.path.join(ANCHORS, pkg)
if not dep:
out["installed"] = False
return out
out["installed"] = True
base, appid = dep["base"], dep.get("appid")
dep = dict(dep)
dep.pop("files", None) # the install manifest is large; `listing` below has the real files
files["deployment.json"] = json.dumps(dep, indent=1)
for name, path in (("launch.sh", os.path.join(anchor, "launch.sh")),
("settings.conf", os.path.join(base, "settings.conf")),
("launch.log", os.path.join(base, "launch.log")),
("launch-test.log", os.path.join(base, "launch-test.log"))):
text = _tail(path, max_bytes)
if text is not None:
files[name] = text
for rel in PCVR_LOGS + (f"steam-{appid}.log",):
text = _tail(os.path.join(base, rel), max_bytes)
if text is not None:
files[os.path.basename(rel)] = text
if dep.get("kind") == "pcvr":
for i, part in enumerate(game_logs(base)):
files[f"game-log-{i}.txt"] = part[-max_bytes:]
logs = os.path.join(STEAM, "logs")
text = _tail(os.path.join(logs, f"lepton-steamlaunch-{appid}.log"), max_bytes) # Lepton's launcher log
if text is not None:
files[f"lepton-steamlaunch-{appid}.log"] = text
logcats = os.path.join(logs, "lepton-logcats")
# the container's logcat buffers: lepton-logcats/steamlaunch-<appid>/logcat-{main,crash,system,kernel,radio}.log
order = ("main", "crash", "system", "kernel", "radio")
cands = [p for p in glob.glob(os.path.join(logcats, f"steamlaunch-{appid}", "*")) if os.path.isfile(p)]
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)
try:
listing = cmd_list_files({"package": pkg, "limit": 20000})
out["listing"] = {"missing": listing["missing"], "truncated": listing["truncated"],
"roots": [{"name": r["name"], "files": r["files"]} for r in listing["roots"]]}
except AgentError:
pass
return out
AGENT_HOME = os.path.join(HOME, ".local/share/frameport")
+63
View File
@@ -0,0 +1,63 @@
# Diagnostics bundles and shared recipes
Two ways users feed results back, both without a GitHub token:
- **Share working config** (game menu, `frameport share-recipe <pkg>`): saves the recipe as a user catalog entry and
opens a prefilled issue from `.github/ISSUE_TEMPLATE/working-config.yml`. A maintainer checks it and adds the label
`catalog-accepted`. Then `.github/workflows/catalog-from-issue.yml` runs `scripts/catalog_from_issue.py`, which reads
only the YAML block, validates it, and writes `catalog/games/<pkg>.yaml`, and the workflow opens a PR.
- The PR step needs Settings → Actions → "Allow GitHub Actions to create and approve pull requests".
- Create the labels `working-config`, `catalog-accepted` and `bug` once.
- **Report a problem / Collect logs** (game menu, Settings, the failure pop-up, `frameport diag report|collect`): writes
a redacted zip and opens a prefilled `bug-report.yml` issue.
- GitHub has no API for issue attachments, so the user drags the zip in (25 MB max; the bundle stays under 24 MB).
- Prefilled links are capped at about 7.5k characters. Free text is trimmed first; the recipe is never cut.
Code: `src/frameport/diag/` (`redact.py`, `bundle.py`, `issue.py`), agent command `collect_diag` (agent ≥ 21),
`Target.collect_diag`, `core/applog.py` (app log + saved job logs).
## Bundle layout (schema 1)
```
README.md what's where (for whoever opens it)
manifest.json versions (app, agent, tools, SteamOS build, Proton), games, warnings, redaction counts
app/app.log the app's log (<data>/logs/app.log, rotated 2 MB × 3)
app/jobs/*.log saved GUI job logs (<data>/logs/jobs, last 30): build, install, test, ...
app/settings.json library settings, catalog sources
frame/info.json agent `info` (SteamOS build, Lepton, Proton, installed games, kernel keys)
frame/host.json OpenXR runtime + explicit layers, podman containers, containers.conf, uptime
frame/XRService-*.log the Frame's OpenXR runtime (SteamVR XRService) log, newest
games/<pkg>/entry.json library entry: analysis, recipe, build checks, installs, last_test (store text dropped)
games/<pkg>/recipe.yaml the recipe in catalog form; catalog-<origin>.yaml = the catalog entry it came from
games/<pkg>/triage.json newest local launch log re-triaged with the bundling app's signatures
games/<pkg>/logs/*.log launch-test logs kept on the PC (last 5)
games/<pkg>/package/ stand-in for the game files (no content):
apk_files.json every APK entry + size
AndroidManifest.xml decoded manifest
elf.json per lib/*.so: DT_NEEDED, ovr*/xr*/vrapi_/gl*/JNI exports + imports
files.json, pe.json Rift: file list; PE imports / delay imports of the exe and VR DLLs
games/<pkg>/target/ from the Frame (or the PC): launch.sh, settings.conf, deployment.json, launch.log,
launch-test.log (PC VR), lepton-steamlaunch-<appid>.log, logcat-{main,crash,system,...}.log,
ReviveInjector.txt / steam-<appid>.log / game-log-N.txt (PC VR), files.json (+ missing)
```
Each log keeps at most its last 4 MB (2 MB per file from the Frame).
## Redaction
`diag/redact.py` runs on every file and on the issue text.
- **Exact values:** the data dir → `<data>`, home dirs → `<home>`, user names → `<user>`, PC and Frame host names and
saved Frame addresses → `<host>`, Steam account ids from the Frame's `info` and the PC's Steam → `<steam-id>`, the
folders holding the user's game dumps → `<source>`.
- **Patterns:** IPv4 addresses (first octet ≥ 10, so version numbers survive), IPv6, MAC, SteamID64, `userdata/<id>`,
e-mail addresses (file names like `x@123.txt` excluded), and any `/home/<u>`, `/Users/<u>`, `C:\Users\<u>` or
`/mnt/c/Users/<u>`.
- **Kept:** generic accounts that identify no one: `steamos`, `steamuser` (Proton), `root`, `deck`.
## Debugging from a bundle (no game, no Frame, no GUI)
1. `frameport diag inspect <zip>` (`--json` for everything). It prints the versions and warnings, the last launch
test, and a fresh triage of the newest launch log with the current `catalog/triage.yaml`.
2. Match the findings and log lines against `docs/PLAYBOOK.md` (symptom → fix) and `docs/FRAME_RUNTIME.md`.
3. Check the analysis in `entry.json` (engine, XR API, `extra`: missing ovr symbols, features, Unreal version) and
`package/elf.json` against the heuristics in CLAUDE.md. Compare `recipe.yaml` with catalog games that use the same
engine and API.
4. Compare `manifest.json → env.frame.build_id` with the build the docs describe: SteamOS updates change the runtime.
5. A new failure signature means adding a patch module, a `triage.yaml` signature, a PLAYBOOK row and a unit test (see
Conventions). Then `diag inspect` on the same zip should report it.
+10
View File
@@ -41,6 +41,16 @@ nothing is installed system-wide).
- Games that use the Oculus Platform SDK (FramePort shows a note) check your Oculus license: they need the Oculus app
installed on the PC with a license you own, so they can't run on the headset.
## Sharing a working game, reporting a problem
- **A game works?** Game menu (right-click in the Library, or "…" on its page) → **Share working config…**. FramePort
opens a prefilled GitHub issue with the game's recipe (patches and settings); check it and submit. Once accepted it
becomes a built-in recipe for everyone. No GitHub token and no game files are involved.
- **Something doesn't work?** Game menu → **Report a problem…** (or Settings → Problems & feedback for app issues).
FramePort saves a diagnostics zip to your Documents folder (logs, recipe, device details; IP addresses, user and
host names, home folders and Steam ids replaced by placeholders; no game files), shows it in a folder window and
opens a prefilled GitHub issue: drag the zip into the "Diagnostics" box and submit. **Collect logs** only saves the
zip. Command line: `frameport diag report <game>`, `frameport diag collect <game>`, `frameport share-recipe <game>`.
## Uninstalling
Settings → **Uninstall FramePort…** (or `frameport uninstall-app`) removes everything FramePort created: its data
folder (tools, library, artwork, cache, builds), the Steam shortcuts it added on this PC and, optionally, its games and
+2
View File
@@ -55,6 +55,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Eye distortion while moving (Arcsmith, Time Stall) | unknown (not eye swap, tracking, Valve layers, depth or pacing) | unresolved |
## Debugging techniques that worked
- A user's problem report: `frameport diag inspect <FramePort-diag-*.zip>` re-triages its launch log with the current
signatures; the zip has the recipe, analysis, ELF/PE imports, Frame logs and versions (docs/DIAGNOSTICS.md).
- Read `<base>/launch.log` (logcat mirror). Filter the game's pid: `Start proc <pid>:<package>`.
- The adapter logs as `FrameBridge` (settings, xrCreateInstance result, swapchain retries, pacing fps).
- For GLES/GLAD engines, wrap `eglGetProcAddress` to see shader compile errors (build the shim with `-DGLSHIM_TRACE`
+101
View File
@@ -0,0 +1,101 @@
"""Turn a "Working configuration" issue (.github/ISSUE_TEMPLATE/working-config.yml) into catalog/games/<pkg>.yaml.
Used by .github/workflows/catalog-from-issue.yml after a maintainer labels the issue `catalog-accepted`; can also be
run by hand: gh issue view 12 --json body -q .body | python scripts/catalog_from_issue.py --issue 12
The issue text is untrusted: only the YAML block of the "Recipe" section is read, parsed with yaml.safe_load, reduced
to known catalog fields and checked (package id, status, types) before anything is written.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from frameport.recommend.catalog import CatalogEntry, to_yaml # noqa: E402
PKG_RE = re.compile(r"^(rift\.[a-z0-9_]{1,80}|[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+){1,8})$")
STATUSES = {"works", "issues", "unsupported", "unknown"}
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove"}
DICT_FIELDS = {"adapter", "device_files", "lepton_env", "proton_env", "verified"}
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,80}$")
class Invalid(ValueError):
pass
def section(body: str, heading: str) -> str:
m = re.search(r"^###\s+" + re.escape(heading) + r"\s*$(.*?)(?=^###\s|\Z)", body, re.M | re.S)
if not m:
raise Invalid(f"no '{heading}' section")
return m.group(1).strip()
def recipe_from_body(body: str) -> dict:
text = section(body, "Recipe")
m = re.search(r"```(?:ya?ml)?\s*\n(.*?)```", text, re.S)
raw = m.group(1) if m else text
try:
d = yaml.safe_load(raw)
except yaml.YAMLError as exc:
raise Invalid(f"recipe isn't valid YAML: {exc}") from None
if not isinstance(d, dict):
raise Invalid("recipe must be a YAML mapping")
return d
def validate(d: dict) -> CatalogEntry:
pkg = d.get("package")
if not isinstance(pkg, str) or not PKG_RE.match(pkg):
raise Invalid(f"bad package id {pkg!r}")
if not isinstance(d.get("title"), str) or not d["title"].strip():
raise Invalid("missing title")
if d.get("status", "unknown") not in STATUSES:
raise Invalid(f"bad status {d.get('status')!r}")
unknown = sorted(set(d) - set(CatalogEntry.__dataclass_fields__) - {"origin"})
if unknown:
raise Invalid(f"unknown fields: {', '.join(unknown)}")
for k in LIST_FIELDS & set(d):
if not isinstance(d[k], list) or not all(isinstance(x, str) and ID_RE.match(x) for x in d[k]):
raise Invalid(f"{k} must be a list of patch ids")
for k in DICT_FIELDS & set(d):
if not isinstance(d[k], dict):
raise Invalid(f"{k} must be a mapping")
for k in ("device_files",):
for path in (d.get(k) or {}):
if not isinstance(path, str) or path.startswith("/") or ".." in Path(path).parts:
raise Invalid(f"{k}: bad path {path!r}")
d = {k: v for k, v in d.items() if k != "origin"}
return CatalogEntry.from_dict(d, "bundled")
def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("body", nargs="?", help="file with the issue body (default: stdin)")
ap.add_argument("--issue", type=int, help="issue number (recorded in verified.issue)")
ap.add_argument("--out", type=Path, default=ROOT / "catalog" / "games")
a = ap.parse_args(argv)
body = Path(a.body).read_text(encoding="utf-8") if a.body else sys.stdin.read()
try:
entry = validate(recipe_from_body(body))
except Invalid as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if a.issue:
entry.verified = {**entry.verified, "issue": a.issue}
path = a.out / f"{entry.package}.yaml"
existed = path.exists()
path.write_text(to_yaml(entry), encoding="utf-8")
print(f"{'updated' if existed else 'added'} {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}")
print(f"package={entry.package}")
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -1
View File
@@ -49,8 +49,11 @@ def main() -> int:
if game:
steps.insert(1, ("game", lambda a: a.open_game(game)))
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game)))]
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.navigate(0), time.sleep(3), a.library_view.open_menu(game))))
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
if args.frame:
from frameport.frame.connection import parse_target
+111
View File
@@ -12,6 +12,10 @@
frameport install rift.<game> --to pc|frame # Rift games: this PC (Revive + Steam) or the Frame (Proton)
frameport pc info # Windows Steam / SteamVR / Revive on this PC
frameport parity --known-good <PATCHED dir> # rebuild everything and diff against known-good APKs
frameport diag collect <pkg>|--all [--no-frame] # redacted diagnostics zip (attach it to a GitHub issue)
frameport diag inspect <zip> # re-triage a diagnostics zip (no game files or Frame needed)
frameport diag report <pkg> # zip + prefilled GitHub problem report
frameport share-recipe <pkg> --status works # prefilled GitHub issue submitting a working recipe
"""
from __future__ import annotations
@@ -31,9 +35,18 @@ app = typer.Typer(add_completion=False, no_args_is_help=True, help="Port Meta Qu
tools_app = typer.Typer(help="Manage the portable toolchain.")
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
pc_app = typer.Typer(help="This PC as a target for Oculus Rift (PC VR) games via Revive.")
diag_app = typer.Typer(help="Diagnostics bundles and problem reports.")
app.add_typer(tools_app, name="tools")
app.add_typer(frame_app, name="frame")
app.add_typer(pc_app, name="pc")
app.add_typer(diag_app, name="diag")
@app.callback()
def _setup():
from .core import applog
applog.setup("cli")
def _target(frame: Optional[str], password: Optional[str] = None, to: str = "frame"):
@@ -428,6 +441,104 @@ def uninstall_app(frame: bool = typer.Option(False, help="also remove FramePort'
" Delete the FramePort program folder to finish (or `uv tool uninstall frameport`).")
# ------------------------------------------------------------------------------------------ diagnostics / sharing
def _diag_target(no_frame: bool, frame: Optional[str], to: str):
if no_frame:
return None
try:
return _target(frame, to=to)
except Exception as exc: # noqa: BLE001 (offline Frame: PC-side data only)
typer.echo(f"({to} not reachable: {exc}; collecting the PC side only)")
return None
def _open(url: str, browser: bool) -> None:
from .core import winhost
typer.echo(url)
if browser and not winhost.open_url(url):
typer.echo("(couldn't open a browser: open the link above)")
@diag_app.command("collect")
def diag_collect(package: Optional[str] = typer.Argument(None, help="a game (omit for app-wide logs only)"),
all_: bool = typer.Option(False, "--all"), frame: Optional[str] = None,
no_frame: bool = typer.Option(False, "--no-frame", help="don't contact the Frame"),
to: str = typer.Option("frame", help="where the game is installed: frame or pc"),
out: Optional[Path] = typer.Option(None, help="folder or .zip path (default: Documents)")):
"""Write a redacted diagnostics zip: logs, recipe, analysis, device info (no game files, no personal data)."""
pkgs = _pkgs(package, all_) if (package or all_) else []
path = pipeline.collect_diagnostics(pkgs, _diag_target(no_frame, frame, to), printing_reporter(False), out)
typer.echo(f"wrote {path}")
@diag_app.command("inspect")
def diag_inspect(bundle: Path, as_json: bool = typer.Option(False, "--json")):
"""Summarize a diagnostics zip and re-triage its launch logs with this version's signatures."""
from .diag import bundle as b
res = b.read(bundle)
if as_json:
typer.echo(json.dumps(res, indent=1, default=str))
return
m = res["manifest"]
env = m.get("env", {})
typer.echo(f"created {m.get('created')} by FramePort {env.get('app')}; {env.get('os')}; "
f"Frame: {(env.get('frame') or {}).get('build_id') or '-'}")
for w in m.get("warnings", []):
typer.echo(f" warning: {w}")
for pkg, g in res["games"].items():
typer.echo(f"\n{pkg} — {g.get('title')} [{g.get('status')}]")
last = g.get("last_test") or {}
if last:
typer.echo(f" last test: {last.get('state')} ({last.get('verdict')}) furthest: {last.get('milestone')}")
t = g.get("triage")
if t:
typer.echo(f" re-triage of {g['log']}: {t['verdict']}, furthest: {t['milestone']}")
for f in t["findings"]:
typer.echo(f" {f['severity']:7} {f['id']}: {f['diagnosis']}\n {f['evidence'][:200]}")
if t["suggestions"]:
typer.echo(f" suggested patches: {', '.join(t['suggestions'])}")
@diag_app.command("report")
def diag_report(package: Optional[str] = typer.Argument(None), description: str = typer.Option("", "--text"),
frame: Optional[str] = None, no_frame: bool = typer.Option(False, "--no-frame"),
to: str = typer.Option("frame"), out: Optional[Path] = None,
browser: bool = typer.Option(True, "--browser/--no-browser")):
"""Collect a diagnostics zip and open a prefilled GitHub problem report (attach the zip there)."""
from .core import winhost
pkgs = _pkgs(package, False) if package else []
target = _diag_target(no_frame, frame, to)
info = None
if target is not None:
try:
info = target.describe()
except Exception: # noqa: BLE001
target = None
path = pipeline.collect_diagnostics(pkgs, target, printing_reporter(False), out)
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)
@app.command("share-recipe")
def share_recipe(package: str, status: str = typer.Option("works", help="works or issues"),
notes: str = typer.Option("", help="what you checked in the headset, known issues"),
frame: Optional[str] = typer.Option(None, help="include the Frame's SteamOS build"),
browser: bool = typer.Option(True, "--browser/--no-browser")):
"""Submit a working configuration: saves it as a known-good recipe and opens a prefilled GitHub issue."""
if status not in ("works", "issues"):
raise typer.BadParameter("--status must be works or issues")
pkg = _pkgs(package, False)[0]
info = None
if frame:
info = _target(frame).describe()
_open(pipeline.share_working_config(pkg, status, notes, info), browser)
def main(): # pragma: no cover
app()
+75
View File
@@ -0,0 +1,75 @@
"""The app's own log (<data>/logs/app.log, rotated) and saved job logs (<data>/logs/jobs/), for diagnostics bundles."""
from __future__ import annotations
import logging
import logging.handlers
import re
import sys
import time
from pathlib import Path
from .paths import user_data_dir
log = logging.getLogger("frameport")
KEEP_JOB_LOGS = 30
def logs_dir() -> Path:
path = user_data_dir() / "logs"
path.mkdir(parents=True, exist_ok=True)
return path
def jobs_dir() -> Path:
path = logs_dir() / "jobs"
path.mkdir(parents=True, exist_ok=True)
return path
def app_log_path() -> Path:
return logs_dir() / "app.log"
def setup(component: str = "app") -> None:
"""Log to <data>/logs/app.log (2 MB × 3). Safe to call more than once; also records uncaught exceptions."""
if any(getattr(h, "_frameport", False) for h in log.handlers):
return
try:
handler = logging.handlers.RotatingFileHandler(app_log_path(), maxBytes=2 << 20, backupCount=3,
encoding="utf-8")
except OSError:
return
handler._frameport = True
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
log.addHandler(handler)
log.setLevel(logging.INFO)
log.info("start %s: %s (python %s, %s)", component, _version(), sys.version.split()[0], sys.platform)
previous = sys.excepthook
def hook(kind, exc, tb):
log.error("uncaught exception", exc_info=(kind, exc, tb))
previous(kind, exc, tb)
sys.excepthook = hook
def _version() -> str:
try:
from importlib.metadata import version
return version("frameport")
except Exception: # noqa: BLE001
return "dev"
def save_job_log(kind: str, package: str | None, state: str, text: str) -> Path | None:
"""Keep a finished job's log (the GUI only holds it in memory); the last KEEP_JOB_LOGS are kept."""
try:
d = jobs_dir()
name = re.sub(r"[^A-Za-z0-9._-]+", "_", f"{kind}-{package or 'app'}-{state}")[:120]
path = d / f"{time.strftime('%Y%m%d-%H%M%S')}-{name}.log"
path.write_text(text, encoding="utf-8", errors="replace")
for old in sorted(d.glob("*.log"))[:-KEEP_JOB_LOGS]:
old.unlink(missing_ok=True)
return path
except OSError:
return None
+33
View File
@@ -223,3 +223,36 @@ def start_steamvr(root: Path, wait: float = 25) -> bool:
time.sleep(2) # let vrserver finish coming up before the game connects
return True
return False
def open_url(url: str) -> bool:
"""Open a link in the user's browser (the Windows browser under WSL)."""
try:
if is_wsl():
# rundll32 passes the URL through untouched (cmd.exe /c start would split it at every '&')
run_win([system32("rundll32.exe"), "url.dll,FileProtocolHandler", url], timeout=15)
return True
import webbrowser
return webbrowser.open(url)
except (OSError, subprocess.SubprocessError):
return False
def open_folder(path: Path | str, select: bool = False) -> bool:
"""Show a folder (or, with select, a file inside its folder) in the file manager."""
path = Path(path)
try:
if is_windows() or is_wsl():
target = to_windows(path)
exe = next((p for p in ("/mnt/c/Windows/explorer.exe",) if is_wsl() and Path(p).exists()), "explorer.exe")
args = [exe] + ([f"/select,{target}"] if select else [target])
subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **_no_window())
return True
folder = path.parent if select or path.is_file() else path
cmd = ["open", "-R", str(path)] if sys.platform == "darwin" and select else \
["open" if sys.platform == "darwin" else "xdg-open", str(folder)]
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except (OSError, ValueError, subprocess.SubprocessError):
return False
+1
View File
@@ -0,0 +1 @@
"""Diagnostics: redacted log bundles and GitHub issue links (working configs, problem reports)."""
+404
View File
@@ -0,0 +1,404 @@
"""Diagnostics bundle: one redacted zip with everything needed to debug a game later, without the game files, the
Frame or this app (see docs/DIAGNOSTICS.md for the layout). Users attach it to a GitHub issue."""
from __future__ import annotations
import contextlib
import io
import json
import platform
import sys
import time
import zipfile
from pathlib import Path
from ..core import applog, library
from ..core.events import Reporter
from ..core.paths import user_data_dir
from . import redact
SCHEMA = 1
MAX_LOG = 4 << 20 # per log file (tail kept)
MAX_TOTAL = 24 << 20 # GitHub's attachment limit is 25 MB
ENTRY_DROP = ("details", "art_source", "artwork", "art") # store metadata: public, big, no debug value
ELF_SYMBOL_PREFIXES = ("ovr", "xr", "vrapi_", "OVR", "Java_", "JNI_", "eglGetProcAddress", "gl")
README = """# FramePort diagnostics bundle
Created by FramePort {version} on {created}. Personal data was replaced by placeholders such as `<ip>`, `<home>`,
`<user>`, `<data>` (FramePort's data folder), `<frame>` and `<steam-id>` (counts in manifest.json → redactions).
| Path | What |
|---|---|
| manifest.json | app / agent / tool versions, OS, which games are included, warnings |
| app/app.log, app/jobs/*.log | the app's log and recent GUI job logs (build, install, launch test, ...) |
| app/settings.json | library settings and catalog sources |
| frame/info.json, frame/host.json | the Frame: SteamOS build, Lepton/Proton, OpenXR runtime + layers, podman, kernel keys |
| games/<pkg>/entry.json | the library entry: analysis, recipe, build checks, installs, last launch test |
| games/<pkg>/recipe.yaml | the recipe in catalog form (catalog/games/<pkg>.yaml) |
| games/<pkg>/triage.json | the newest launch log re-triaged with the triage signatures of this app version |
| games/<pkg>/package/ | stand-in for the game files: file list, AndroidManifest.xml, ELF imports/exports / PE imports |
| games/<pkg>/logs/ | launch-test logs saved on the PC |
| games/<pkg>/target/ | from the Frame (or PC): launch.sh, settings.conf, deployment.json, launch/logcat/Proton/game logs, file list |
## Debugging from this bundle
1. `frameport diag inspect <this zip>` re-runs triage with the current signatures (catalog/triage.yaml).
2. Look up symptoms in docs/PLAYBOOK.md; runtime facts are in docs/FRAME_RUNTIME.md.
3. Compare `recipe.yaml` with `catalog/games/` recipes of games using the same engine / XR API (entry.json → analysis).
4. Headless launch tests can't show the picture: "RUNNING" only proves startup (see FRAME_RUNTIME.md).
"""
def _tail(text: str, limit: int = MAX_LOG) -> str:
if len(text) <= limit:
return text
return f"[... first {len(text) - limit} characters cut ...]\n" + text[-limit:]
def _read_tail(path: Path, limit: int = MAX_LOG) -> str:
try:
size = path.stat().st_size
with path.open("rb") as f:
if size > limit:
f.seek(size - limit)
data = f.read().decode("utf-8", "replace")
return (f"[... first {size - limit} bytes cut ...]\n" if size > limit else "") + data
except OSError as exc:
return f"(unreadable: {exc})"
def _version() -> str:
try:
from importlib.metadata import version
return version("frameport")
except Exception: # noqa: BLE001
return "dev"
class _Writer:
"""Collects redacted files; enforces the total size cap (drops the biggest logs first)."""
def __init__(self, red: redact.Redactor):
self.red = red
self.files: dict[str, bytes] = {}
self.warnings: list[str] = []
def text(self, name: str, text: str, limit: int = MAX_LOG) -> None:
self.files[name] = self.red.text(_tail(text or "", limit)).encode("utf-8", "replace")
def json(self, name: str, obj) -> None:
self.files[name] = self.red.json(obj).encode("utf-8")
def fit(self, limit: int = MAX_TOTAL) -> None:
def zipped():
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for n, d in self.files.items():
z.writestr(n, d)
return buf.tell()
size = zipped()
while size > limit:
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
n = logs[0]
self.files[n] = b"[... cut to fit the 25 MB attachment limit ...]\n" + self.files[n][-(len(self.files[n]) // 4):]
self.warnings.append(f"{n} was cut to fit the size limit")
size = zipped()
# ------------------------------------------------------------------------------------------ pieces
def env_info(target_info: dict | None = None) -> dict:
"""Versions and platform facts (manifest.json and the "environment" of GitHub issues)."""
from ..frame.connection import bundled_agent_version
out = {"app": _version(), "agent_bundled": bundled_agent_version(), "python": sys.version.split()[0],
"os": f"{platform.system()} {platform.release()}", "machine": platform.machine()}
try:
from ..core import winhost
out["wsl"] = winhost.is_wsl()
except Exception: # noqa: BLE001
pass
try:
from ..tools import toolchain
out["tools"] = {s.name: s.version for s in toolchain.status(False) if s.installed}
except Exception: # noqa: BLE001
pass
try:
from ..tools import revive
out["revive"] = revive.installed_version()
except Exception: # noqa: BLE001
pass
if target_info:
out["frame"] = {k: target_info.get(k) for k in ("os", "os_version", "build_id", "agent_version", "arch")}
proton = target_info.get("proton") or {}
if isinstance(proton, dict):
ready = proton.get("ready")
out["frame"]["proton"] = ready.get("display_name") or ready.get("name") if isinstance(ready, dict) else ready
return out
def package_listing(g: dict) -> dict[str, object]:
"""A stand-in for the game files: what's in the APK (or Rift folder) and what its binaries link against."""
out: dict[str, object] = {}
if g.get("kind") == "rift":
root = Path(g.get("game_dir") or "")
if root.is_dir():
files = []
for p in sorted(root.rglob("*")):
if p.is_file():
files.append([p.relative_to(root).as_posix(), p.stat().st_size])
if len(files) >= 20000:
break
out["files.json"] = files
from ..analysis import rift
pe = {}
exes = [g.get("exe")] + [p for p, _ in files if p.lower().endswith((".exe", ".dll"))
and any(k in p.lower() for k in ("ovr", "openxr", "openvr", "revive", "oculus"))]
for rel in [e for e in exes if e][:40]:
try:
info, _ = rift.read_pe(root / rel, limit=64 << 20)
pe[rel] = {"machine": info.machine, "subsystem": info.subsystem, "imports": info.imports,
"delay_imports": info.delay_imports}
except Exception as exc: # noqa: BLE001
pe[rel] = {"error": str(exc)}
out["pe.json"] = pe
return out
apk = Path(g.get("apk") or "")
if not apk.is_file():
return out
with zipfile.ZipFile(apk) as z:
infos = z.infolist()
out["apk_files.json"] = [[i.filename, i.file_size] for i in infos[:20000]]
try:
from pyaxmlparser.axmlprinter import AXMLPrinter
out["AndroidManifest.xml"] = AXMLPrinter(z.read("AndroidManifest.xml")).get_xml().decode("utf-8", "replace")
except Exception as exc: # noqa: BLE001
out["AndroidManifest.xml"] = f"(could not decode: {exc})"
elf = {}
for i in infos:
if not (i.filename.startswith("lib/") and i.filename.endswith(".so")):
continue
elf[i.filename] = _elf_summary(z, i)
out["elf.json"] = elf
return out
def _elf_summary(z: zipfile.ZipFile, info: zipfile.ZipInfo) -> dict:
from elftools.elf.dynamic import DynamicSection
from elftools.elf.elffile import ELFFile
from elftools.elf.sections import SymbolTableSection
import shutil
import tempfile
try:
with contextlib.ExitStack() as stack:
f = stack.enter_context(z.open(info))
if info.compress_type != zipfile.ZIP_STORED:
# seeking in a deflated member re-decompresses from the start: unpack once (libs can be > 1 GB)
tmp = stack.enter_context(tempfile.TemporaryFile())
shutil.copyfileobj(f, tmp, 1 << 20)
tmp.seek(0)
f = tmp
elf = ELFFile(f)
res: dict = {"size": info.file_size, "needed": [], "exports": [], "imports": []}
for sec in elf.iter_sections():
if isinstance(sec, DynamicSection):
res["needed"] = [t.needed for t in sec.iter_tags() if t.entry.d_tag == "DT_NEEDED"]
elif isinstance(sec, SymbolTableSection) and sec.name == ".dynsym":
for sym in sec.iter_symbols():
name = sym.name.split("@")[0]
if not name.startswith(ELF_SYMBOL_PREFIXES):
continue
(res["imports"] if sym["st_shndx"] == "SHN_UNDEF" else res["exports"]).append(name)
for k in ("exports", "imports"):
res[k] = sorted(set(res[k]))[:3000]
return res
except Exception as exc: # noqa: BLE001
return {"size": info.file_size, "error": str(exc)[:300]}
def retriage(package: str, log: str, state: str = "UNKNOWN") -> dict:
from ..validate.triage import triage
r = triage(log, state, package)
return {"state": r.state, "verdict": r.verdict, "milestone": r.milestone, "milestones": r.milestones, "fps": r.fps,
"findings": [f.__dict__ for f in r.findings], "suggestions": r.suggestions()}
SOURCE_KEYS = ("apk", "origin", "data_dir", "game_dir") # where the user keeps game dumps: not shared, only names
def source_dirs(g: dict) -> list[str]:
"""Folders of the user's game dumps named by a library entry (redacted as <source>)."""
out = []
for k in SOURCE_KEYS:
v = g.get(k)
if isinstance(v, str) and v:
p = Path(v)
out.append(str(p.parent if k in ("apk",) or p.suffix else p))
ex = (g.get("analysis") or {}).get("extra") or {}
if isinstance(ex.get("folder"), str):
out.append(ex["folder"])
return [d for d in out if d not in ("", ".", "/")]
def _game(w: _Writer, g: dict, target, reporter: Reporter) -> dict:
from ..recommend import catalog
pkg = g["package"]
d = f"games/{pkg}/"
entry = {k: v for k, v in g.items() if k not in ENTRY_DROP}
w.json(d + "entry.json", entry)
try:
w.text(d + "recipe.yaml", catalog.to_yaml(catalog.entry_from_library(g, status=g.get("status") or "unknown")))
except Exception as exc: # noqa: BLE001
w.warnings.append(f"{pkg}: recipe: {exc}")
cat = catalog.lookup(pkg)
if cat:
w.text(d + f"catalog-{cat.origin}.yaml", catalog.to_yaml(cat))
logs = sorted((user_data_dir() / "logs").glob(f"{pkg}-*.log"))
for p in logs:
w.text(d + "logs/" + p.name, _read_tail(p))
if logs:
last = g.get("last_test") or {}
w.json(d + "triage.json", retriage(pkg, _read_tail(logs[-1]), last.get("state") or "UNKNOWN"))
reporter.log(f"{pkg}: game file listing")
try:
for name, data in package_listing(g).items():
if isinstance(data, str):
w.text(d + "package/" + name, data)
else:
w.json(d + "package/" + name, data)
except Exception as exc: # noqa: BLE001
w.warnings.append(f"{pkg}: package listing: {exc}")
remote = None
if target is not None:
reporter.log(f"{pkg}: logs from {target.label}")
try:
remote = target.collect_diag(pkg)
except Exception as exc: # noqa: BLE001
w.warnings.append(f"{pkg}: {target.label}: {exc}")
if remote:
for name, text in (remote.get("files") or {}).items():
w.text(d + "target/" + name.replace("/", "_"), text or "")
if remote.get("listing"):
w.json(d + "target/files.json", remote["listing"])
if not remote.get("installed", True):
w.warnings.append(f"{pkg}: not installed on {target.label}")
return {"package": pkg, "title": g.get("title"), "kind": g.get("kind", "quest"), "status": g.get("status"),
"from_target": bool(remote and remote.get("installed"))}
def collect(packages: list[str] | None, target=None, reporter: Reporter | None = None,
dest: Path | None = None, extra: dict[str, str] | None = None) -> Path:
"""Write the bundle; packages None/[] = app-wide only. target: a connected Target (None = PC-side data only).
extra: more text files to include (e.g. {"report.md": ...})."""
reporter = reporter or Reporter()
reporter.stage("Collecting diagnostics")
info = None
if target is not None:
try:
info = target.describe()
except Exception as exc: # noqa: BLE001
reporter.log(f"{target.label} not reachable: {exc}")
target = None
red = redact.default(info)
for pkg in packages or []:
for d in source_dirs(library.game(pkg) or {}):
red.add("source", d)
w = _Writer(red)
# app-wide
for p in sorted(applog.logs_dir().glob("app.log*"))[:2]:
w.text("app/" + p.name, _read_tail(p, 2 << 20))
jobs = sorted(applog.jobs_dir().glob("*.log"))
wanted = [p for p in jobs if not packages or any(pkg in p.name for pkg in packages)][-15:]
for p in sorted(set(wanted + jobs[-5:])):
w.text("app/jobs/" + p.name, _read_tail(p, 1 << 20))
try:
from ..recommend import catalog
entries = catalog.load()
origins: dict[str, int] = {}
for e in entries.values():
origins[e.origin] = origins.get(e.origin, 0) + 1
w.json("app/settings.json", {"settings": library.load().get("settings", {}), "catalog": origins,
"games": len(library.games())})
except Exception as exc: # noqa: BLE001
w.warnings.append(f"settings: {exc}")
if info:
w.json("frame/info.json", info)
if target is not None:
try:
host = target.collect_diag(None)
if host:
w.json("frame/host.json", host.get("host") or host)
for name, text in (host.get("files") or {}).items():
w.text("frame/" + name, text or "")
except Exception as exc: # noqa: BLE001
w.warnings.append(f"{target.label}: host info: {exc}")
games = []
for pkg in packages or []:
reporter.check_cancel()
g = library.game(pkg)
if not g:
w.warnings.append(f"{pkg}: not in the library")
continue
games.append(_game(w, g, target, reporter))
for name, text in (extra or {}).items():
w.text(name, text)
w.fit()
created = time.strftime("%Y-%m-%d %H:%M:%S")
manifest = {"schema": SCHEMA, "created": created, "env": env_info(info),
"target": target.kind if target is not None else None, "games": games,
"warnings": w.warnings}
w.json("manifest.json", manifest)
manifest["redactions"] = red.summary() # after the last redaction
w.json("manifest.json", manifest)
w.files["README.md"] = README.format(version=_version(), created=created).encode()
if dest is None:
from ..uninstall import default_backup_dir
dest = default_backup_dir()
dest = Path(dest)
if dest.suffix.lower() != ".zip":
dest.mkdir(parents=True, exist_ok=True)
tag = packages[0] if packages and len(packages) == 1 else ("games" if packages else "app")
dest = dest / f"FramePort-diag-{tag}-{time.strftime('%Y%m%d-%H%M%S')}.zip"
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
for name in ["README.md", "manifest.json"] + sorted(n for n in w.files if n not in ("README.md", "manifest.json")):
z.writestr(name, w.files[name])
reporter.check("Diagnostics bundle", True, f"{dest.name} ({dest.stat().st_size / 2**20:.1f} MB)")
applog.log.info("diagnostics bundle %s", dest)
return dest
def read(zip_path: Path) -> dict:
"""manifest + per-game summary of a bundle (for `frameport diag inspect`)."""
with zipfile.ZipFile(zip_path) as z:
names = z.namelist()
manifest = json.loads(z.read("manifest.json"))
out = {"manifest": manifest, "games": {}}
for g in manifest.get("games", []):
pkg = g["package"]
d = f"games/{pkg}/"
logs = sorted(n for n in names if n.startswith(d + "logs/"))
target_logs = [n for n in names if n.startswith(d + "target/")
and n.rsplit("/", 1)[1] in ("launch.log", "launch-test.log")]
entry = json.loads(z.read(d + "entry.json")) if d + "entry.json" in names else {}
last = entry.get("last_test") or {}
log_name = (logs or target_logs or [None])[-1]
res = {"title": g.get("title"), "status": g.get("status"), "last_test": last, "log": log_name,
"files": [n[len(d):] for n in names if n.startswith(d)]}
if log_name:
res["triage"] = retriage(pkg, z.read(log_name).decode("utf-8", "replace"),
last.get("state") or "UNKNOWN")
out["games"][pkg] = res
return out
+110
View File
@@ -0,0 +1,110 @@
"""Prefilled GitHub "new issue" links (issue forms in .github/ISSUE_TEMPLATE; field ids match the query keys).
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.
"""
from __future__ import annotations
from urllib.parse import urlencode
from . import redact
REPO = "https://github.com/spoopyghosty0/frameport"
MAX_URL = 7500
def _env_text(env: dict) -> str:
lines = [f"- FramePort {env.get('app')} (agent {env.get('agent_bundled')}), {env.get('os')} {env.get('machine')}"
+ (" (WSL)" if env.get("wsl") else "")]
if env.get("tools"):
lines.append("- tools: " + ", ".join(f"{k} {v}" for k, v in env["tools"].items()))
if env.get("revive") and "revive" not in (env.get("tools") or {}):
lines.append(f"- Revive {env['revive']}")
fr = env.get("frame")
if fr:
lines.append(f"- Frame: {fr.get('os')} {fr.get('os_version')} build {fr.get('build_id')}, "
f"agent {fr.get('agent_version')}" + (f", Proton: {fr['proton']}" if fr.get("proton") else ""))
return "\n".join(lines)
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."""
fields = {k: v for k, v in fields.items() if v}
while True:
url = f"{REPO}/issues/new?" + urlencode({"template": template, **fields})
over = len(url) - MAX_URL
if over <= 0:
return url
key = next((k for k in trim if k in fields), None)
if key is None:
return url # only untrimmable fields left (e.g. a huge recipe): let GitHub cut it
text = fields[key].removesuffix(CUT)
# encoded text is 1-3x longer than raw text: cutting `over` raw characters (+ slack) always makes progress
keep = len(text) - over - 100
if keep < 40:
fields.pop(key)
else:
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:
red = red or redact.Redactor()
title = g.get("title") or g["package"]
kind = "Oculus Rift (PC VR)" if g.get("kind") == "rift" else "Meta Quest"
a = g.get("analysis") or {}
game = f"{title} — {g['package']} ({kind}, version {a.get('version') or '?'}, {a.get('engine') or '?'} / " \
f"{a.get('xr') or '?'})"
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", {
"title": red.text(f"[Working config] {title}"),
"labels": "working-config",
"game": red.text(game),
"result": red.text(result),
"recipe": red.text(recipe_yaml),
"environment": red.text(_env_text(env)),
"notes": red.text(notes or ""),
}, trim=["notes", "environment", "result"])
def findings_text(summary: dict | None, limit: int = 8) -> str:
if not summary:
return ""
lines = [f"Launch test: {summary.get('state')} ({summary.get('verdict')}), furthest milestone: "
f"{summary.get('milestone') or '-'}" + (f", {summary['fps']:.0f} fps" if summary.get("fps") else "")]
for f in (summary.get("findings") or [])[:limit]:
lines.append(f"- {f.get('severity')} `{f.get('id')}`: {f.get('diagnosis')}")
if f.get("evidence"):
lines.append(f" `{str(f['evidence'])[:200]}`")
if summary.get("suggestions"):
lines.append("Suggested patches: " + ", ".join(summary["suggestions"]))
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:
red = red or redact.Redactor()
title = (g.get("title") or g["package"]) if g else "FramePort"
game = ""
if g:
a = g.get("analysis") or {}
game = f"{title} — {g['package']} (version {a.get('version') or '?'}, {a.get('engine') or '?'} / " \
f"{a.get('xr') or '?'})"
logs = f"⬇ Drag and drop {bundle_name} (the diagnostics zip FramePort just saved) into this box." \
if bundle_name else ""
return _url("bug-report.yml", {
"title": red.text(f"[Problem] {title}"),
"labels": "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"])
+171
View File
@@ -0,0 +1,171 @@
"""Scrub personal data from everything that leaves this PC (diagnostics bundles, GitHub issue text).
Literal values known at runtime (the data dir, home dirs, Frame hosts, Steam account ids, user names) are replaced
exactly; patterns catch the rest (IP/MAC addresses, SteamID64s, e-mail addresses, home paths of any user).
Generic names that carry debug value and no identity (the Frame's `steamos` user, `root`) are kept.
"""
from __future__ import annotations
import getpass
import json
import os
import re
from collections import Counter
from functools import lru_cache
from pathlib import Path
KEEP_USERS = {"steamos", "steamuser", "root", "user", "public", "default", "deck"} # steamuser = Proton prefix user
KEEP_WORDS = KEEP_USERS | {"localhost", "steam", "frame", "steamframe", "windows", "linux"}
MAC = re.compile(r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b")
IPV4 = re.compile(r"(?<![\w.])(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?![\w.]*\d)")
IPV6 = re.compile(r"(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,7}(?::|(?::[0-9A-Fa-f]{1,4}){1,7}|[0-9A-Fa-f]{1,4})(?![\w:])")
STEAMID64 = re.compile(r"\b7656119\d{10}\b")
USERDATA = re.compile(r"(userdata[\\/]+)(\d+)")
EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@(?:[A-Za-z][A-Za-z0-9-]*\.)+"
r"(?!(?:txt|log|gz|xz|zip|tmp|xml|json|png|jpg|dat|bin|so|dll|exe|conf)\b)[A-Za-z]{2,}\b")
# home folders of any user: /home/<u>, /Users/<u>, C:\Users\<u> (also JSON-escaped \\), /mnt/c/Users/<u>
HOME_PATHS = [
re.compile(r"(/home/)([^/\s'\"\\:]+)"),
re.compile(r"(/Users/)([^/\s'\"\\:]+)"),
re.compile(r"(/mnt/[A-Za-z]/Users/)([^/\s'\"\\:]+)", re.I),
re.compile(r"([A-Za-z]:\\{1,2}Users\\{1,2})([^\\/\s'\":]+)", re.I),
re.compile(r"([A-Za-z]:/Users/)([^/\s'\":]+)", re.I),
]
def _ipv4_ok(m: re.Match) -> bool:
"""True if this looks like a real, identifying IPv4 address (not loopback, a netmask or a version number)."""
octets = [int(x) for x in m.groups()]
if any(o > 255 for o in octets) or any(len(x) > 1 and x.startswith("0") for x in m.groups()):
return False
first = octets[0]
return first >= 10 and first != 127 and first != 255 and octets != [0, 0, 0, 0]
def _ipv6_ok(s: str) -> bool:
if s in ("::", "::1") or s.count(":") < 2:
return False
groups = [g for g in s.split(":") if g]
# needs a compressed "::" or all 8 groups, and some hex letter or "::" (timestamps are 12:34:56)
if "::" not in s and len(groups) != 8:
return False
return "::" in s or any(re.search(r"[a-fA-F]", g) for g in groups)
class Redactor:
def __init__(self, known: dict[str, list[str]] | None = None):
self.counts: Counter = Counter()
self.literals: list[tuple[str, str]] = [] # (value, placeholder), longest first
for kind, values in (known or {}).items():
for v in values or []:
self.add(kind, v)
def add(self, kind: str, value) -> None:
v = str(value or "").strip().rstrip("/\\")
if not v or v.lower() in KEEP_WORDS or v in ("/", "~"):
return
if kind == "steam-id" and (not v.isdigit() or len(v) < 6):
return
if kind in ("user", "host") and len(v) < 3:
return
variants = {v}
if kind in ("data", "home"):
variants |= {v.replace("\\", "/"), v.replace("/", "\\"), v.replace("\\", "\\\\")}
for x in variants:
if (x, f"<{kind}>") not in self.literals:
self.literals.append((x, f"<{kind}>"))
self.literals.sort(key=lambda p: -len(p[0]))
def _sub(self, kind: str, pattern: re.Pattern, s: str, repl) -> str:
def f(m):
out = repl(m)
if out != m.group(0):
self.counts[kind] += 1
return out
return pattern.sub(f, s)
def text(self, s: str) -> str:
if not s:
return s
for value, ph in self.literals:
if value in s:
kind = ph.strip("<>")
if kind in ("user", "host", "steam-id"): # whole words only (short values)
s, n = re.subn(r"(?<![\w.-])" + re.escape(value) + r"(?![\w-])", ph, s)
else:
n = s.count(value)
s = s.replace(value, ph)
self.counts[kind] += n
s = self._sub("email", EMAIL, s, lambda m: "<email>")
s = self._sub("mac", MAC, s, lambda m: "<mac>")
s = self._sub("ip", IPV4, s, lambda m: "<ip>" if _ipv4_ok(m) else m.group(0))
s = self._sub("ip", IPV6, s, lambda m: "<ip>" if _ipv6_ok(m.group(0)) else m.group(0))
s = self._sub("steam-id", STEAMID64, s, lambda m: "<steam-id>")
s = self._sub("steam-id", USERDATA, s, lambda m: m.group(1) + "<steam-id>")
for p in HOME_PATHS:
s = self._sub("home", p, s,
lambda m: m.group(0) if m.group(2).lower() in KEEP_USERS or m.group(2).startswith("<")
else m.group(1) + "<user>")
return s
def obj(self, o):
if isinstance(o, str):
return self.text(o)
if isinstance(o, dict):
return {self.text(str(k)) if isinstance(k, str) else k: self.obj(v) for k, v in o.items()}
if isinstance(o, (list, tuple)):
return [self.obj(v) for v in o]
return o
def json(self, o, **kw) -> str:
return json.dumps(self.obj(o), indent=1, default=str, **kw)
def summary(self) -> dict[str, int]:
return dict(sorted(self.counts.items()))
def default(frame_info: dict | None = None) -> Redactor:
"""A Redactor that knows this PC's personal values (and the Frame's, from agent `info`)."""
from ..core.paths import user_data_dir
known: dict[str, list[str]] = {"data": [str(user_data_dir())], "home": [str(Path.home())], "user": [],
"host": [], "steam-id": []}
for name in ("USER", "USERNAME", "LOGNAME"):
known["user"].append(os.environ.get(name, ""))
try:
known["user"].append(getpass.getuser())
except Exception: # noqa: BLE001
pass
for name in ("USERPROFILE", "HOMEPATH"):
if os.environ.get(name):
known["home"].append(os.environ[name])
try:
known["host"].append(os.uname().nodename)
except AttributeError:
known["host"].append(os.environ.get("COMPUTERNAME", ""))
try:
from ..frame.connection import saved_targets
for t in saved_targets():
known["host"] += [t.host, t.name]
except Exception: # noqa: BLE001
pass
if frame_info:
known["host"].append(frame_info.get("hostname", ""))
known["steam-id"] += [str(u) for u in frame_info.get("steam_users") or []]
known["steam-id"].append(pc_steam_user() or "")
return Redactor(known)
@lru_cache(maxsize=1)
def pc_steam_user() -> str | None:
"""This PC's Steam account id (Windows Steam; a few seconds under WSL, so cached)."""
try:
from ..core import winhost
root = winhost.steam_root() if winhost.available() else None
uid = winhost.steam_user(root) if root else None
return str(uid) if uid else None
except Exception: # noqa: BLE001
return None
+45
View File
@@ -446,3 +446,48 @@ def apply_suggestions(package: str, suggestions: list[str]) -> Recipe:
recipe.patches.pop(pid, None)
set_recipe(package, recipe)
return recipe
# ------------------------------------------------------------------------------------------ sharing / diagnostics
def collect_diagnostics(packages: list[str] | None, target: Target | None, reporter: Reporter,
dest: Path | None = None, extra: dict[str, str] | None = None) -> Path:
"""Redacted diagnostics zip (docs/DIAGNOSTICS.md) for some games (None = app-wide only)."""
from .diag import bundle
return bundle.collect(packages, target, reporter, dest, extra)
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)."""
from .diag import bundle, issue, redact
from .recommend import catalog
g = library.game(package)
if not g or not g.get("recipe"):
raise ValueError(f"{package} has no recipe")
env = bundle.env_info(frame_info)
verified = {"app": env.get("app"), "overport_cli": (env.get("tools") or {}).get("overport"),
"frame_build": (env.get("frame") or {}).get("build_id"),
"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))
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)."""
from .diag import bundle, issue, redact
from .recommend import catalog
g = library.game(package) if package else None
recipe = ""
if g and g.get("recipe"):
try:
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))
+48 -1
View File
@@ -118,11 +118,58 @@ def lookup(package: str) -> CatalogEntry | None:
def save_user_entry(entry: CatalogEntry) -> Path:
path = user_catalog_dir() / f"{entry.package}.yaml"
path.write_text(yaml.safe_dump(entry.to_dict(), sort_keys=False, allow_unicode=True, width=110), encoding="utf-8")
path.write_text(to_yaml(entry), encoding="utf-8")
load(refresh=True)
return path
def entry_from_library(g: dict, status: str = "works", notes: str | None = None,
verified: dict | None = None) -> CatalogEntry:
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working config" publish)."""
import time
from ..core import library
from ..patches import base
from ..patches.overport import DEFAULT_OVERPORT
package = g["package"]
r = library.recipe_from_dict(g["recipe"])
a = g.get("analysis") or {}
common = dict(package=package, title=g.get("title") or package, status=status,
notes=r.notes if notes is None else notes,
tested_version=a.get("version") or "", engine=a.get("engine") or "", xr=a.get("xr") or "",
verified={k: v for k, v in {"date": time.strftime("%Y-%m-%d"),
"known_good_sha256": (g.get("build") or {}).get("sha256"),
**(verified or {})}.items() if v},
source_hint=g.get("name", ""))
if g.get("kind") == "rift":
env = r.params("pcvr.proton_env").get("env") or ""
return CatalogEntry(
**common, kind="rift", quest_package=g.get("quest_package"), as_is=r.as_is,
pcvr=[p for p in r.patches if p not in ("pcvr.proton_env", "pcvr.proton_tool")],
pcvr_remove=[p for p in ("pcvr.revive",) if p not in r.patches],
proton_env=dict(line.split("=", 1) for line in env.splitlines() if "=" in line),
proton_tool=r.params("pcvr.proton_tool").get("tool") or "")
def cat(p):
try:
return base.get(p)
except KeyError:
return None
return CatalogEntry(
**common, as_is=r.as_is,
overport_extra=[p for p in r.patches if (c := cat(p)) and c.category == "overport" and p not in DEFAULT_OVERPORT],
overport_remove=[p for p in DEFAULT_OVERPORT if p not in r.patches],
alt_overport=r.alt_patches, use_alt=r.use_alt,
frame=[p for p in r.patches if (c := cat(p)) and c.category == "frame" and not c.default_on],
adapter={p.split(".", 1)[1]: v.get("value") for p, v in r.patches.items() if p.startswith("adapter.")},
device_files=r.params("device.files").get("files", {}))
def to_yaml(entry: CatalogEntry) -> str:
return yaml.safe_dump(entry.to_dict(), sort_keys=False, allow_unicode=True, width=110)
def write_index(folder: Path) -> Path:
"""Write index.json for publishing a folder of recipes as a remote catalog."""
import json
+4
View File
@@ -33,6 +33,10 @@ class Target(ABC):
"""Start an installed game for playing, through the target's Steam (Steam library shortcut)."""
raise NotImplementedError(f"{self.label} can't launch games")
def collect_diag(self, package: str | None = None) -> dict:
"""Debug data from the target for a diagnostics bundle: {"host": {...}, "files": {name: text}, ...}."""
return {}
@abstractmethod
def set_settings(self, package: str, settings: dict) -> dict: ...
+3
View File
@@ -44,6 +44,9 @@ class FrameLeptonTarget(Target):
def launch_test(self, package, reporter, seconds=45):
return device.launch_test(self.connect().frame, package, reporter, seconds)
def collect_diag(self, package=None):
return self.connect().frame.agent("collect_diag", timeout=180, package=package)
def launch(self, package):
return self.connect().frame.agent("launch", package=package, timeout=60)
+14
View File
@@ -122,6 +122,20 @@ class PcReviveTarget(Target):
except (OSError, ValueError):
raise RuntimeError(f"{package} is not installed on this PC") from None
def collect_diag(self, package=None):
files = {}
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
if log_path.exists():
files["ReviveInjector.txt"] = log_path.read_text(encoding="utf-8", errors="replace")[-(2 << 20):]
out = {"host": {"steamvr_running": winhost.steamvr_running()}, "files": files}
if package:
try:
files["deployment.json"] = json.dumps(self._dep(package), indent=1)
out["installed"] = True
except RuntimeError:
out["installed"] = False
return out
# ------------------------------------------------------------------ install
def install(self, package, title, apk, data_dir, recipe, reporter, apk_only=False):
raise NotImplementedError("Quest (APK) games install on the Steam Frame, not on the PC")
+102 -30
View File
@@ -16,9 +16,7 @@ from pathlib import Path
import flet as ft
from .. import pipeline
from ..artwork import fetch as artwork
from ..core import library
from ..patches import base
from ..core import applog, library
from ..recommend import catalog
from . import components as C
from . import theme as T
@@ -253,6 +251,7 @@ class FramePortApp:
fn(*args)
except Exception as exc: # noqa: BLE001
traceback.print_exc()
applog.log.exception("background task failed")
self.toast(f"{exc}", error=True)
self.page.run_thread(wrapper)
@@ -468,6 +467,9 @@ class FramePortApp:
out += [("Reset to suggested recipe", ft.Icons.RESTART_ALT_ROUNDED,
lambda e: (pipeline.reset_recipe(pkg), self.toast("Recipe reset"), self.refresh_view())),
("Save as known-good recipe", ft.Icons.VERIFIED_ROUNDED, lambda e: self.save_known_good(pkg)),
("Share working config…", ft.Icons.SHARE_ROUNDED, lambda e: self.share_config_dialog(pkg)),
("Collect logs", ft.Icons.FOLDER_ZIP_OUTLINED, lambda e: self.collect_logs(pkg)),
("Report a problem…", ft.Icons.BUG_REPORT_OUTLINED, lambda e: self.report_problem_dialog(pkg)),
None,
("Remove from library", ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self.remove_from_library(pkg))]
return out
@@ -593,6 +595,8 @@ class FramePortApp:
if job.log_path:
buttons.append(C.ghost("Launch log", ft.Icons.DESCRIPTION_ROUNDED,
lambda e, j=job: self.show_log_file(j.log_path, j.title)))
buttons.append(C.ghost("Report problem", ft.Icons.BUG_REPORT_OUTLINED,
lambda e, p=pkg: (self.page.pop_dialog(), self.report_problem_dialog(p))))
rows.append(C.card(ft.Column([
C.body(self._title(pkg) if pkg else job.title, T.TEXT, weight=ft.FontWeight.W_600),
C.body(why, T.TEXT_2, selectable=True),
@@ -748,35 +752,102 @@ class FramePortApp:
return self.target
def save_known_good(self, package: str) -> None:
g = library.game(package)
r = library.recipe_from_dict(g["recipe"])
from ..patches.overport import DEFAULT_OVERPORT
common = dict(package=package, title=g.get("title") or package, status="works", notes=r.notes,
tested_version=g["analysis"]["version"], engine=g["analysis"]["engine"], xr=g["analysis"]["xr"],
verified={"date": time.strftime("%Y-%m-%d"),
"known_good_sha256": g.get("build", {}).get("sha256")},
source_hint=g.get("name", ""))
if g.get("kind") == "rift":
env = r.params("pcvr.proton_env").get("env") or ""
e = catalog.CatalogEntry(
**common, kind="rift", quest_package=g.get("quest_package"),
pcvr=[p for p in r.patches if p not in ("pcvr.proton_env", "pcvr.proton_tool")],
pcvr_remove=[p for p in ("pcvr.revive",) if p not in r.patches],
proton_env=dict(line.split("=", 1) for line in env.splitlines() if "=" in line),
proton_tool=r.params("pcvr.proton_tool").get("tool") or "")
else:
e = catalog.CatalogEntry(
**common,
overport_extra=[p for p in r.patches if base.get(p).category == "overport" and p not in DEFAULT_OVERPORT],
overport_remove=[p for p in DEFAULT_OVERPORT if p not in r.patches],
alt_overport=r.alt_patches, use_alt=r.use_alt,
frame=[p for p in r.patches if base.get(p).category == "frame" and not base.get(p).default_on],
adapter={p.split(".", 1)[1]: v.get("value") for p, v in r.patches.items() if p.startswith("adapter.")},
device_files=r.params("device.files").get("files", {}))
catalog.save_user_entry(e)
catalog.save_user_entry(catalog.entry_from_library(library.game(package)))
self.toast("Saved as a known-good recipe")
# ================================================================== sharing / diagnostics
def open_url(self, url: str) -> None:
from ..core import winhost
if winhost.is_wsl() and winhost.open_url(url): # the desktop client would open a Linux browser in WSL
return
try:
self.page.run_task(ft.UrlLauncher().launch_url, url)
except Exception: # noqa: BLE001
winhost.open_url(url)
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 \
C.install_state(g, self.frame_info) not in ("installed", "outdated"):
return self._target_for("pc")
return self.target if self.frame_state == "connected" else None
def collect_logs(self, pkg: str | None = None, report: str | None = None) -> None:
"""Diagnostics zip (redacted) → shown in the file manager; report (a description, may be "") also opens a
prefilled GitHub problem report to attach it to."""
from ..core import winhost
target, info = self._diag_target(pkg), self.frame_info
def run(job: Job):
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))
return f"Saved {path.name}: drag it into the GitHub issue that just opened"
return f"Saved {path.name} (in {path.parent})"
self.submit(f"Collect logs: {self._title(pkg)}" if pkg else "Collect app logs", run, pkg, "diag")
def report_problem_dialog(self, pkg: str | None = None) -> None:
text = ft.TextField(label="What happens? (optional: you can also write it on GitHub)", multiline=True,
min_lines=3, max_lines=8, width=560, border_color=T.BORDER)
def go(e):
self.page.pop_dialog()
self.collect_logs(pkg, text.value or "")
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(f"Report a problem · {self._title(pkg)}" if pkg else "Report a problem"),
bgcolor=T.SURFACE_2,
content=ft.Column([
C.body("FramePort saves a diagnostics zip (logs, recipe, device info; no game files, personal data "
"removed) and opens a prefilled GitHub issue. Drag the zip into it, check the text, submit.",
T.TEXT_2),
ft.Row([text, C.help_icon("diag_bundle")]),
], tight=True, spacing=T.S3, width=600),
actions=[C.ghost("Cancel", on_click=lambda e: self.page.pop_dialog()),
C.primary("Collect & open GitHub", ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=go)]))
def share_config_dialog(self, pkg: str) -> None:
g = library.game(pkg) or {}
last = g.get("last_test") or {}
status = ft.RadioGroup(ft.Row([ft.Radio(value="works", label="Works"),
ft.Radio(value="issues", label="Works with issues")]), value="works")
notes = ft.TextField(label="Notes (what you checked, known issues)", multiline=True, min_lines=2,
max_lines=6, width=560, border_color=T.BORDER)
played = ft.Checkbox(label="I played it in the headset with this recipe", value=False)
send = C.primary("Open GitHub issue", ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=None)
def sync(e=None):
send.disabled = not played.value
C.update(send)
played.on_change = sync
sync()
def go(e):
self.page.pop_dialog()
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)
self.open_url(url)
self.toast("Saved as known-good. Check the issue on GitHub and submit it")
self.run_bg(work)
send.on_click = go
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(f"Share working config · {self._title(pkg)}"), bgcolor=T.SURFACE_2,
content=ft.Column([
C.body("Opens a prefilled GitHub issue with this game's recipe, so it can join the built-in catalog "
"(no account token needed; you review and submit it on GitHub). No game files or personal "
"data are sent.", T.TEXT_2),
*([C.body(f"Last launch test: {last.get('verdict')} · furthest: {last.get('milestone') or '—'}",
T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played,
C.body("Launch tests run without the headset worn, so only you can confirm the picture and controls.",
T.TEXT_3),
], tight=True, spacing=T.S3, width=600),
actions=[C.ghost("Cancel", on_click=lambda e: self.page.pop_dialog()), send]))
def settings_dialog(self, package: str) -> None:
from ..patches.settings import SETTINGS
@@ -1161,6 +1232,7 @@ def assets_dir() -> str:
def main(argv=None):
applog.setup("gui")
ft.run(lambda page: FramePortApp(page), assets_dir=assets_dir())
-1
View File
@@ -1,7 +1,6 @@
"""Reusable GUI building blocks (all styling comes from ui.theme)."""
from __future__ import annotations
from pathlib import Path
from typing import Callable
import flet as ft
+7
View File
@@ -94,6 +94,13 @@ HELP: dict[str, str] = {
"transfer_link": "Uploads use the fastest link to the Frame: a USB cable, or the Frame's own Wi-Fi hotspot when "
"this PC is connected to it, else your home network (several times slower: both go through "
"the router).",
# ---- sharing / diagnostics
"share_config": "Sends this game's recipe (the patches and settings it uses) to FramePort's GitHub as a prefilled "
"issue, so it can become a built-in recipe for everyone. You review and submit it in the browser; "
"no GitHub token and no game files are involved.",
"diag_bundle": "A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's "
"runtime details — enough to debug without the game files. IP addresses, host and user names, "
"home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.",
}
+16 -1
View File
@@ -10,6 +10,7 @@ import traceback
from dataclasses import dataclass, field
from typing import Callable
from ..core import applog
from ..core.events import Cancelled, Event, Reporter
_ids = itertools.count(1)
@@ -47,6 +48,14 @@ class Job:
def cancel(self) -> None:
self.reporter.cancelled.set()
def text(self) -> str:
"""Checks + log as plain text (Copy log, saved job logs, diagnostics bundles)."""
checks = [("PASS" if c["ok"] else "FAIL" if c["ok"] is False else "WARN") + f" {c['name']}"
+ (f": {c['detail']}" if c.get("detail") else "") for c in self.checks]
return "\n".join([f"{self.title} — {self.state}" + (f": {self.error}" if self.error else ""), "", "Checks:",
*checks, "", "Log:", *self.log]
+ (["", f"Full launch log: {self.log_path}"] if self.log_path else []))
def _on_event(self, ev: Event) -> None:
self.version += 1
if ev.kind == "stage":
@@ -66,7 +75,9 @@ class Job:
class JobManager:
def __init__(self, on_change: Callable[[Job | None], None] | None = None, throttle: float = 0.2):
def __init__(self, on_change: Callable[[Job | None], None] | None = None, throttle: float = 0.2,
save_logs: bool = True):
self.save_logs = save_logs # finished jobs' logs go to <data>/logs/jobs (diagnostics bundles)
self.jobs: list[Job] = []
self._queue: list[Job] = []
self._cv = threading.Condition()
@@ -155,7 +166,11 @@ class JobManager:
job.state = "cancelled"
except Exception as exc: # noqa: BLE001
traceback.print_exc()
applog.log.exception("job %r failed", job.title)
job.state, job.error = "failed", f"{exc}" or type(exc).__name__
job.finished = time.time()
job.version += 1
applog.log.info("job %r (%s, %s): %s", job.title, job.kind, job.package or "-", job.state)
if self.save_logs:
applog.save_job_log(job.kind, job.package, job.state, job.text())
self._notify(job, force=True)
+1 -4
View File
@@ -146,10 +146,7 @@ class ActivityPanel:
@staticmethod
def job_text(job: Job) -> str:
checks = [("PASS" if c["ok"] else "FAIL" if c["ok"] is False else "WARN") + f" {c['name']}"
+ (f": {c['detail']}" if c.get("detail") else "") for c in job.checks]
return "\n".join([f"{job.title} — {job.state}" + (f": {job.error}" if job.error else ""), "", "Checks:", *checks,
"", "Log:", *job.log] + ([f"", f"Full launch log: {job.log_path}"] if job.log_path else []))
return job.text()
def _toggle(self, s: set[int], jid: int):
s.symmetric_difference_update({jid})
+9 -1
View File
@@ -112,6 +112,13 @@ class SettingsView:
lambda e: app.copy(data))]), "data_folder"),
C.kv("Catalog", f"{len(catalog.load())} known-good recipes (bundled, remote and yours)", "catalog"),
], spacing=T.S2))),
C.section("Problems & feedback", C.card(ft.Row([
C.body("Something not working? Collect a diagnostics zip (logs, settings, device info; personal "
"data removed) and attach it to a GitHub issue. For one game, use its menu instead.",
expand=True),
C.ghost("Collect app logs", ft.Icons.FOLDER_ZIP_OUTLINED, lambda e: app.collect_logs()),
C.secondary("Report a problem…", ft.Icons.BUG_REPORT_OUTLINED, lambda e: app.report_problem_dialog()),
], spacing=T.S3, wrap=True)), help="diag_bundle"),
C.section("Remove FramePort", C.card(ft.Row([
C.body("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.",
@@ -124,7 +131,8 @@ class SettingsView:
C.section("About", C.card(ft.Column([
C.kv("Version", ver),
C.kv("Frame agent", self.agent_text(), "frame_agent"),
C.kv("Source", "github.com/spoopyghosty0/frameport"),
C.kv("Source", ft.TextButton("github.com/spoopyghosty0/frameport", icon=ft.Icons.OPEN_IN_NEW_ROUNDED,
url="https://github.com/spoopyghosty0/frameport")),
C.meta("Uses overport, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta."),
], spacing=T.S2))),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
-2
View File
@@ -1,8 +1,6 @@
"""Artwork for Rift games (mocked network), thumbnails, Quest/Rift twin titles, uninstall."""
import io
import json
import zipfile
from pathlib import Path
import pytest
+269
View File
@@ -0,0 +1,269 @@
"""Diagnostics bundles, redaction, GitHub issue links and the issue → catalog script."""
import importlib.util
import json
import zipfile
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
import yaml
from frameport.core import library
from frameport.core.paths import user_data_dir
from frameport.diag import bundle, issue, redact
from frameport.recommend import catalog
ROOT = Path(__file__).resolve().parents[1]
LOG = """
09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1150:com.example.game/u0a55 for activity
09-28 17:39:02.000 1150 1170 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "ovr_User_GetLoggedInUser" referenced by "libgame.so"
connecting to 192.168.1.23 from /home/alice/.local/share/frameport, steam id 76561198012345678
"""
@pytest.fixture(autouse=True)
def _no_pc_steam(monkeypatch):
monkeypatch.setattr(redact, "pc_steam_user", lambda: None)
# ------------------------------------------------------------------------------------------ redaction
def test_redactor_patterns():
r = redact.Redactor({"host": ["myframe.local"], "steam-id": ["87654321"], "user": ["alice"]})
s = r.text("ssh steamos@192.168.1.23 (myframe.local) fe80::1c2b:3d4e:5f60:7a8b mac aa:bb:cc:dd:ee:ff "
"/home/bob/x /home/steamos/Applications C:\\Users\\Carol\\AppData C:\\\\Users\\\\Dan\\\\x "
"/mnt/c/Users/Eve/Documents userdata/87654321/config 76561198012345678 me@example.com alice")
for leak in ("192.168", "myframe", "1c2b", "aa:bb", "bob", "Carol", "Dan", "Eve", "87654321", "7656119",
"example.com", "alice"):
assert leak not in s, (leak, s)
assert "/home/steamos/Applications" in s # the Frame's generic user stays (debug value, no identity)
assert r.summary()["ip"] == 2 and r.summary()["home"] >= 4
def test_redactor_keeps_versions_and_times():
r = redact.Redactor()
s = ("overport 1.2.3.4 runtime 3.4.3 at 17:39:01.000 ::1 127.0.0.1 netmask 255.255.255.0 "
"data/system/dropbox/system_server_wtf@1727712345.txt icon@2x.png")
assert r.text(s) == s
def test_redactor_objects_and_data_dir(tmp_path):
r = redact.default({"hostname": "steamframe-7", "steam_users": ["12345678"]})
out = r.obj({"apk": str(user_data_dir() / "out" / "g.apk"), "list": ["steamframe-7", 12345678, "12345678"]})
assert out == {"apk": "<data>/out/g.apk", "list": ["<host>", 12345678, "<steam-id>"]}
# ------------------------------------------------------------------------------------------ bundle
def _game(tmp_path):
apk = tmp_path / "dumps" / "g.apk"
apk.parent.mkdir()
with zipfile.ZipFile(apk, "w") as z:
z.writestr("assets/x.bin", b"x" * 10)
z.write(ROOT / "tests/fixtures/libfakeovrplugin_arm64.so", "lib/arm64-v8a/libOVRPlugin.so")
logs = user_data_dir() / "logs"
logs.mkdir(parents=True, exist_ok=True)
(logs / "com.example.game-20260930-120000.log").write_text(LOG)
library.upsert_game("com.example.game", title="Example", apk=str(apk), status="issues",
analysis={"package": "com.example.game", "version": "1.0", "engine": "Unity", "xr": "OpenXR"},
recipe={"package": "com.example.game", "patches": {"frame.ovrstubs": {}, "frame.nodebug": {}}},
details={"description": "long store text"},
last_test={"state": "EXITED", "verdict": "fail", "milestone": "Process started",
"findings": [{"id": "missing-ovr-symbol", "severity": "fatal",
"diagnosis": "d", "evidence": "e /home/alice/x"}],
"suggestions": ["frame.ovrstubs"]})
return library.game("com.example.game")
class FakeTarget:
kind, label = "frame", "myframe.local"
def describe(self):
return {"hostname": "steamframe-7", "steam_users": ["87654321"], "build_id": "20260922", "os": "SteamOS",
"agent_version": 21}
def collect_diag(self, package=None):
if package is None:
return {"host": {"openxr_runtime": {"name": "SteamVR"}, "podman": "lepton-steamlaunch-1 Exited"},
"files": {}}
return {"installed": True, "files": {"launch.log": LOG + "userdata/87654321/config steamframe-7",
"settings.conf": "scale=1.0"},
"listing": {"roots": [{"name": "Install folder", "files": [["game.apk", 1]]}], "missing": []}}
def test_bundle_contents_and_no_leaks(tmp_path):
_game(tmp_path)
from frameport.core import applog
applog.save_job_log("install", "com.example.game", "failed", "log from /home/alice/x")
out = bundle.collect(["com.example.game"], FakeTarget(), dest=tmp_path / "out")
assert out.name.startswith("FramePort-diag-com.example.game-") and out.suffix == ".zip"
with zipfile.ZipFile(out) as z:
names = set(z.namelist())
d = "games/com.example.game/"
for n in ("README.md", "manifest.json", "app/settings.json", "frame/info.json", "frame/host.json",
d + "entry.json", d + "recipe.yaml", d + "triage.json", d + "package/apk_files.json",
d + "package/elf.json", d + "target/launch.log", d + "target/settings.conf",
d + "target/files.json"):
assert n in names, n
assert any(n.startswith("app/jobs/") for n in names)
assert any(n.startswith(d + "logs/") for n in names)
blob = b"".join(z.read(n) for n in names).decode("utf-8", "replace")
for leak in (str(user_data_dir()), str(tmp_path), "192.168.1.23", "alice", "87654321", "steamframe-7",
"76561198012345678", "long store text"):
assert leak not in blob, leak
manifest = json.loads(z.read("manifest.json"))
assert manifest["games"][0]["from_target"] and manifest["env"]["frame"]["build_id"] == "20260922"
assert manifest["redactions"]["ip"] >= 1
elf = json.loads(z.read(d + "package/elf.json"))
assert "lib/arm64-v8a/libOVRPlugin.so" in elf
assert yaml.safe_load(z.read(d + "recipe.yaml"))["frame"] == ["frame.nodebug"]
res = bundle.read(out)
t = res["games"]["com.example.game"]["triage"]
assert "missing-ovr-symbol" in {f["id"] for f in t["findings"]} and "frame.ovrstubs" in t["suggestions"]
def test_bundle_offline_and_app_only(tmp_path):
out = bundle.collect(None, None, dest=tmp_path / "x.zip")
assert out == tmp_path / "x.zip"
with zipfile.ZipFile(out) as z:
m = json.loads(z.read("manifest.json"))
assert m["target"] is None and m["games"] == []
def test_bundle_size_cap():
w = bundle._Writer(redact.Redactor())
import os
w.files["big.log"] = os.urandom(3 << 20).hex().encode() # incompressible-ish
w.fit(limit=1 << 20)
assert len(w.files["big.log"]) < 3 << 20 and w.warnings
# ------------------------------------------------------------------------------------------ issue links
def _fields(url):
q = parse_qs(urlparse(url).query)
return {k: v[0] for k, v in q.items()}
def test_working_config_url(tmp_path):
g = _game(tmp_path)
e = catalog.entry_from_library(g, status="works", notes="n", verified={"app": "0.2.0"})
assert e.verified["app"] == "0.2.0" and e.frame == ["frame.nodebug"]
url = issue.working_config_url(g, catalog.to_yaml(e), "works", "played /home/alice fine" + "x" * 20000,
{"app": "0.2.0", "os": "Linux"}, redact.default())
assert url.startswith(issue.REPO + "/issues/new?") and len(url) <= issue.MAX_URL + 200
f = _fields(url)
assert f["template"] == "working-config.yml" and f["labels"] == "working-config"
assert yaml.safe_load(f["recipe"])["package"] == "com.example.game" # the recipe is never cut
assert "alice" not in url and "[... cut" in f["notes"]
def test_problem_url(tmp_path):
g = _game(tmp_path)
url = issue.problem_url(g, "crashes", {"app": "x"}, "FramePort-diag-a.zip", "package: com.example.game\n")
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
def test_entry_from_library_matches_save_known_good_shape(tmp_path):
g = _game(tmp_path)
d = catalog.entry_from_library(g).to_dict()
assert d["package"] == "com.example.game" and d["status"] == "works" and d["engine"] == "Unity"
assert d["verified"]["date"] and "known_good_sha256" not in d["verified"] # no build yet
rift = {"package": "rift.x", "kind": "rift", "title": "X", "recipe": {"package": "rift.x", "patches": {
"pcvr.xr_timefix": {}, "pcvr.proton_env": {"env": "A=1\nB=2"}}}, "analysis": {}}
r = catalog.entry_from_library(rift)
assert r.kind == "rift" and r.pcvr == ["pcvr.xr_timefix"] and r.proton_env == {"A": "1", "B": "2"}
assert r.pcvr_remove == ["pcvr.revive"]
# ------------------------------------------------------------------------------------------ issue → catalog
def _script():
spec = importlib.util.spec_from_file_location("catalog_from_issue", ROOT / "scripts/catalog_from_issue.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
BODY = """### Game
Example — com.example.game
### Result
works
### Recipe
```yaml
package: com.example.game
title: Example
status: works
frame:
- frame.ovrstubs
```
### Environment
_No response_
"""
def test_catalog_from_issue(tmp_path):
s = _script()
assert s.main([str(_write(tmp_path, BODY)), "--issue", "7", "--out", str(tmp_path)]) == 0
d = yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())
assert d["frame"] == ["frame.ovrstubs"] and d["verified"]["issue"] == 7
@pytest.mark.parametrize("bad", ["package: ../../etc/passwd\ntitle: x", "package: com.a.b\ntitle: x\nevil: 1",
"package: com.a.b\ntitle: x\nstatus: great", "package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list"])
def test_catalog_from_issue_rejects(tmp_path, bad):
s = _script()
body = BODY.split("```yaml")[0] + "```yaml\n" + bad + "\n```\n"
assert s.main([str(_write(tmp_path, body)), "--out", str(tmp_path)]) == 1
assert not list(tmp_path.glob("*.yaml"))
def _write(tmp_path, text):
p = tmp_path / "body.md"
p.write_text(text)
return p
# ------------------------------------------------------------------------------------------ agent
def test_agent_collect_diag(monkeypatch, tmp_path):
from test_agent import load_agent
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a, "run", lambda cmd, **kw: type("P", (), {"stdout": "", "returncode": 1, "stderr": ""})())
anchor = Path(a.ANCHORS) / "com.x.y"
base = tmp_path / "games" / "com.x.y"
anchor.mkdir(parents=True)
base.mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"base": str(base), "appid": 123, "package": "com.x.y",
"files": {"game": {"a": 1}}}))
(anchor / "launch.sh").write_text("#!/bin/sh\n")
(base / "launch.log").write_text("x" * 5000)
(base / "settings.conf").write_text("scale=1\n")
logcat = Path(a.STEAM) / "logs/lepton-logcats/steamlaunch-123"
logcat.mkdir(parents=True)
(logcat / "logcat-radio.log").write_text("radio")
(logcat / "logcat-main.log").write_text("logcat line")
(Path(a.STEAM) / "logs/lepton-steamlaunch-123.log").write_text("lepton")
xr = Path(a.STEAM) / "logs/XRService-2026.09.30"
xr.mkdir()
(xr / "XRService-12-31-50.log").write_text("xr")
host = a.cmd_collect_diag({})
assert host["agent_version"] == a.AGENT_VERSION and "openxr_layers" in host["host"]
assert host["files"] == {"XRService-12-31-50.log": "xr"}
res = a.cmd_collect_diag({"package": "com.x.y", "max_bytes": 1000})
f = res["files"]
assert res["installed"] and {"deployment.json", "launch.sh", "settings.conf", "launch.log", "logcat-main.log",
"lepton-steamlaunch-123.log"} <= set(f)
assert [n for n in f if n.startswith("logcat")] == ["logcat-main.log", "logcat-radio.log"]
assert f["launch.log"].startswith("[... first 4000 bytes cut") and "files" not in json.loads(f["deployment.json"])
assert res["listing"]["roots"]
assert a.cmd_collect_diag({"package": "com.not.installed"})["installed"] is False
-1
View File
@@ -227,7 +227,6 @@ def test_migration_respects_catalog_openxr_native(tmp_path, monkeypatch):
def test_pc_defaults_to_openvr_backend():
from frameport.core.models import Recipe
from frameport.patches import base
from frameport.targets.pc_revive import shortcut_fields
+8
View File
@@ -83,3 +83,11 @@ def test_play_is_the_quick_action_when_installed(monkeypatch):
assert app.quick_action(g) != _app(monkeypatch, g, {"installed": []}).quick_action(g)
r = {"package": "rift.r", "kind": "rift", "title": "R", "recipe": {}}
assert [o[0] for o in _app(monkeypatch, r, {"installed": []}, pc=("rift.r",)).play_options(r)] == ["Play on this PC"]
def test_menus_offer_sharing_and_diagnostics(monkeypatch):
g = {"package": "com.q", "title": "Q", "recipe": {"status": "works"}, "build": {"sha256": "x"}}
for quick in (True, False):
labels = _labels(_app(monkeypatch, g, {"installed": []}).game_actions("com.q", quick=quick))
assert {"Share working config…", "Collect logs", "Report a problem…"} <= set(labels)
assert labels[-1] == "Remove from library"
Generated
+1 -1
View File
@@ -756,7 +756,7 @@ wheels = [
[[package]]
name = "frameport"
version = "0.1.0"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "capstone" },