mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 10:00:36 +02:00
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:
1 parent
fc4aa9c8e5
commit
dfb4fc4ffd
34 files changed
+1849
-44
No files matched your search
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
@@ -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
|
||||
@@ -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 }}."
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Diagnostics: redacted log bundles and GitHub issue links (working configs, problem reports)."""
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user