mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 17:00:43 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4910440f56 | ||
|
|
44a056095d | ||
|
|
6037b13e88 | ||
|
|
c3a34ace99 | ||
|
|
05843633a7 | ||
|
|
5198f704e9 | ||
|
|
eeee7d2b84 | ||
|
|
772c26d91a | ||
|
|
db0a78ccc3 | ||
|
|
01d764e05c | ||
|
|
0c5c1638ed | ||
|
|
a49cfb0b57 | ||
|
|
0f975d2233 | ||
|
|
ccff9ab486 | ||
|
|
f2cf7db507 | ||
|
|
2f740bbb41 |
No files matched your search
@@ -5,6 +5,15 @@ on:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dev:
|
||||
description: "Publish the bundles as the dev build (testers: Settings → Updates → Install the latest dev build)"
|
||||
type: boolean
|
||||
default: true
|
||||
notes:
|
||||
description: "What testers should try (shown before they install it)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -50,6 +59,9 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev
|
||||
- run: uv sync --extra dev
|
||||
- name: Dev build version (next patch + .dev<run number>; not committed)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dev
|
||||
run: uv run --no-project python scripts/dev_version.py ${{ github.run_number }}
|
||||
- id: flet
|
||||
run: uv run python scripts/package.py
|
||||
continue-on-error: ${{ runner.os == 'macOS' || runner.arch == 'ARM64' }}
|
||||
@@ -148,3 +160,43 @@ jobs:
|
||||
echo; cat packaging/release-footer.md; } > "$notes" # short: the update dialog shows these notes
|
||||
gh release delete "$tag" --yes 2>/dev/null || true
|
||||
gh release create "$tag" out/* packaging/FramePort-selfsigned.cer --title "FramePort ${tag}" --notes-file "$notes"
|
||||
|
||||
dev-release:
|
||||
# "Run workflow" with dev ticked: replaces the rolling `dev` pre-release (never shown by the automatic update check;
|
||||
# testers install it from Settings → Updates). Same assets + SHA256SUMS as a release.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dev
|
||||
needs: bundle
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: astral-sh/setup-uv@v10.2.0
|
||||
- id: version
|
||||
run: echo "version=$(uv run --no-project python scripts/dev_version.py ${{ github.run_number }})" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: out
|
||||
merge-multiple: true
|
||||
- name: Wheel
|
||||
run: uv build --wheel -o out && ls out
|
||||
- name: Checksums
|
||||
run: cd out && sha256sum * > SHA256SUMS.txt && cat SHA256SUMS.txt
|
||||
- name: Publish the dev build
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
NOTES: ${{ inputs.notes }}
|
||||
run: |
|
||||
notes=$(mktemp)
|
||||
last=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)
|
||||
{ echo "Dev build **${VERSION}** from commit ${GITHUB_SHA::7} ($(date -u +%Y-%m-%d)). For testing fixes before"
|
||||
echo "the next release; you get that release as a normal update."; echo
|
||||
if [ -n "$NOTES" ]; then echo "## Please test"; echo; echo "$NOTES"; echo; fi
|
||||
if [ -n "$last" ]; then echo "## Changes since ${last}"; echo
|
||||
git log --no-merges --format='- %s' "${last}..HEAD" | cut -c1-160 | head -40; fi; } > "$notes"
|
||||
gh release delete dev --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create dev out/* packaging/FramePort-selfsigned.cer --prerelease --target "$GITHUB_SHA" \
|
||||
--title "FramePort dev build ${VERSION}" --notes-file "$notes"
|
||||
@@ -26,7 +26,14 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
launch.sh logs `start/end <unix>` to `<anchor>/plays.log` (upgrade_launchers adds it; Proton launchers exec → start
|
||||
only) and shots are matched by time; thumbnails cached in `<data>/screenshots-cache/<frame>/`, shown by asset URL;
|
||||
delete leaves screenshots.vdf alone (Steam rewrites it at exit); game menu → "Screenshots" = `go("screenshots",
|
||||
pkg)`); settings; welcome; activity panel).
|
||||
pkg)`); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
|
||||
(one `ft.ContextMenu` per view, filled on right-click; on one of several selected items they act on the whole
|
||||
selection, `C.menu_targets`) and click-and-drag multi-select (`C.DragSelect`: pan start/end on the area + item
|
||||
hover events, which Flutter also sends with the button held; Flet can't report item positions, so no rubber band).
|
||||
Selection bars sit below the list: above it, their appearing shifted the items mid-drag. Async handlers (they
|
||||
await a FilePicker) must be coroutine functions or go through `page.run_task`: Flet doesn't await a lambda's
|
||||
coroutine (the Files row Download button silently did nothing). `ui_smoke.py --fake-frame --gestures` drives real
|
||||
mouse drags/right-clicks.
|
||||
User tags live in library entries (`tags`), filters in library setting `ui.library`.
|
||||
**Performance rules** (the app froze before): never put image bytes in controls — artwork is served by URL from the
|
||||
GUI assets dir (= user data dir; `ft.run(assets_dir=…)`), as thumbnails (`artwork/thumbs.py`, Pillow); the
|
||||
@@ -162,7 +169,7 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
- `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout,
|
||||
launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it.
|
||||
- `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (sshd, app key, avahi service, Lepton).
|
||||
- `catalog/games/<package>.yaml` — 38 recipes (34 verified 2026-09-28; Deadpool VR, 4XVR, NEX Player and AC Nexus's
|
||||
- `catalog/games/<package>.yaml` (installed apps also fetch these from GitHub `main`, see "Catalog updates") — 38 recipes (34 verified 2026-09-28; Deadpool VR, 4XVR, NEX Player and AC Nexus's
|
||||
90 Hz default confirmed later by the owner); `catalog/triage.yaml` — log signatures → fixes.
|
||||
- `native/` — sources of the prebuilt binaries in `artifacts/` (adapter, VrApi bridge patches, GL shim, stubs).
|
||||
`native/build.py` rebuilds them with NDK r27c (downloaded on demand into `native/.cache`, git-ignored; uses
|
||||
@@ -509,11 +516,14 @@ picker at all: a FramePort fix would be an injected picker activity. Generated c
|
||||
Steam placeholder set (no Steam art at all) → fixed in artwork/steam.py.
|
||||
**Steam library failures (GitHub #21/#30):** on some Frames Steam never loads FramePort's shortcuts.vdf entries (same SteamOS build and arm64 beta client as the dev Frame; IDs correct; file written before Steam starts): Play → console_log `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` (= "Game configuration unavailable", Steam treats the id as a store app). Cause unknown; agent v42/43 diagnostics: `launch` returns `steam` (started / error + code / silent from console_log), `collect_diag` adds `steam_library` (real Steam dir, beta, Steam start vs vdf write time, entries per account, devkit games) + `steam-console.txt` (incl. Steam's `logs/shortcuts.previous.txt` if present; steamclient.so has "LoadShortcuts: rejecting attempt to load shortcuts: invalid account ID"). **Fallback (agent v43):** when Play gets AppError_9 the agent registers the game through Steam's devkit interface (what Valve's Devkit Management Tool does, MIT: `devkit-1 steam://devkit-1/<~/.steam/steam.token>/create-shortcut?response=<file>&gameid=<id>&directory=~/devkit-game` written to ~/.steam/steam.pipe; answer file / `.error`; `~/devkit-game/<id>/launch.sh` = link to the game's launch.sh, `<id>-argv.json` ["launch.sh"] (relative to the folder), `<id>-settings.json` {steam_play 0, compat_tool ""}). Steam adds it live (no restart), picks its own appid (read back from shortcuts.vdf by DevkitGameID), shows it as a normal non-Steam game named "Devkit Game: <id>" (OpenVR 0, but VR works: owner-verified 2026-10-04), art copied to grid/<appid>*. Ids: letter first, then letters/digits/_ only (spaces, '-', leading digit → "missing/invalid arguments"). Later Plays use the devkit entry; uninstall (`devkit_unregister`, `delete-shortcut` live) and purge remove it. Verified on the dev Frame: register/launch/unregister; the AppError_9 trigger itself only in unit tests.
|
||||
**Exit game (GitHub #36, 2026-10-04):** `Apps.TerminateApp` (what Exit game calls; reachable for tests through Steam's CEF devtools on 127.0.0.1:8080, SharedJSContext, `Runtime.evaluate` — a stdlib websocket client is enough) closed both a normal shortcut and a devkit entry on the dev Frame within 1-3 s. SIGTERM to Steam's `reaper` alone left launch.sh + Lepton (setsid) + the container running (an earlier headset launch ran on for 87 min); SIGTERM to launch.sh cleans up in ~4 s. Agent v44: launch.sh's 2 s loop ends the game when its parent (the reaper) is gone; `upgrade_launchers` (from ensure_host_fixes) adds that to existing launchers in place. Which in-headset exit path fails is still unknown. Screenshots on the Frame: all under SteamVR's appid 250820 (`760/remote/250820/screenshots`, screenshots.vdf has creation time, no game).
|
||||
**SteamVR dashboard at game start (2026-10-04):** the dashboard (Resume game / controller / VR options) was open whenever a FramePort game started (also over Lepton's 2D launcher). Steam's UI exposes `SteamClient.OpenVR.VROverlay.{IsDashboardVisible,HideDashboard,ShowDashboard}` (CEF devtools, `steam_js` in the agent). Agent v44: launch.sh starts `_dashboard_worker` (waits for FrameBridge's first `pacing:` line in launch.log, then hides a visible dashboard for 25 s, at most 3 times; log `<base>/dashboard.log`; opt out with env FRAMEPORT_KEEP_DASHBOARD=1). Verified on the dev Frame (dashboard shown → 4XVR launched via Steam → hidden ~5 s after the first frames); not yet seen in the headset.
|
||||
**SteamVR dashboard at game start (2026-10-04):** the dashboard (Resume game / controller / VR options) was open whenever a FramePort game started (also over Lepton's 2D launcher). Steam's UI exposes `SteamClient.OpenVR.VROverlay.{IsDashboardVisible,HideDashboard,ShowDashboard}` (CEF devtools, `steam_js` in the agent). Agent v44: launch.sh starts `_dashboard_worker` (waits for FrameBridge's first `pacing:` line in launch.log, then hides a visible dashboard for 25 s, at most 3 times; log `<base>/dashboard.log`; opt out with env FRAMEPORT_KEEP_DASHBOARD=1). Verified on the dev Frame (dashboard shown → 4XVR launched via Steam → hidden ~5 s after the first frames); not yet seen in the headset. **Headset (ITR2, 2026-10-05): too late** — Steam shows its frame menu (`valve.steam.gamepadui.frame.menu`, vrwebhelper_systemui.txt `[PooledPopups] Showing`) ~0.3 s after the game's first submitted frame (FrameBridge `new layer:`), but the first `pacing:` summary comes ~8 s later; the owner had pressed Resume (`[HideDashboard] return_to_game`) before the worker looked. Agent v52: watches from the first `new layer:` line (incremental log reads, 0.5 s polls, 30 s window). Agent v53: stops for good once the player opens the dashboard with the controller (`toggle_dashboard_action` in vrwebhelper_systemui.txt since the worker started): v52 closed it 60 ms after each press, and the game, paused for it, stayed paused.
|
||||
**Space warp / ITR2 (2026-10-04):** Into The Radius 2 (UE5, `libUnreal.so` + OVRPlugin, Vulkan) uses Application SpaceWarp (extra swapchains `376x376 format=97` motion vectors + `format=129` depth); #35 reports flickering textures. OVRPort's `patch_disable_space_warp` has no effect on UE5, and hiding XR_FB_space_warp in FrameBridge's enumerate doesn't reach the game either: **OVRPort's dispatcher offers/enables XR_FB_space_warp itself**. FrameBridge `hide_space_warp` (per game, off) hides it and strips `XrCompositionLayerSpaceWarpInfoFB` (1000171000) from the projection views (log `hide_space_warp: removed space warp info`); the game still renders its MV swapchains. Test build installed on the dev Frame; flicker not yet checked in a headset. Heuristic: UE5 + OVRPlugin shows it as an option (off). `equirect_emul` is suggested for Unreal games whose graphics API isn't detected ("GLES or unknown"); on Vulkan it switches itself off (Myst/Riven/ITR2): harmless, but Unreal Vulkan detection is a known gap.
|
||||
**PR #34 (merged 2026-10-04, Lucas-Mathieu):** `native/adapter/audio_metadata.h` patches Meta XR Audio Wwise (only build ID e1619e7f…, Batman: Arkham Shadow 1.4.1) so queued audio metadata isn't freed while the current audio frame references it (smoke-bomb crash). Verified headless: installs ("metadata reclamation follows …"), 72 fps; we added a once-per-second scan limit. Outside PRs: review source, rebuild `artifacts/` ourselves, merge locally (contributor's commit kept), push main.
|
||||
**Heuristics eval (2026-10-04):** `scripts/eval_heuristics.py "<VR CyberDeck Portable>"` → 19/44 recipes exact, 322/352 fields (most diffs: `frame.unity_text_input` suggested where older catalog recipes lack it; the rest are runtime-only findings: use_alt, vk_shader_fix, focus_hold, sync_guard, multipass). `PATCHED/` copies aren't scored (already converted).
|
||||
**Power (0.9, agent v47):** sidebar bar above the Frame card: Sleep / Restart / Shut down, each confirmed (`app.frame_power`, a running game asks again). logind answers CanSuspend/CanReboot/CanPowerOff = "challenge" for an SSH session but "yes" inside a user unit, so agent `power` runs `systemctl suspend|reboot|poweroff` from a transient `systemd-run --user --on-active=3` timer (the command answers first). Restart verified on the device (back in ~84 s, new boot id).
|
||||
**Proton default (agent v51, owner's choice 2026-10-05):** the newest **stable** ARM64 Proton is the default for PC VR games (`pick_proton`: asked-for name/alias, else newest installed stable, else any stable). Agent v49-50 defaulted to Experimental; the owner's headset A/B with Rick and Morty (Experimental 69 % reprojected frames, stable felt much smoother) reverted it. The game page's Customize shows `pcvr.proton_tool` as a dropdown (Stable (default) / Experimental = alias `proton-experimental`); `ensure_proton` installs a chosen one (it asks once more for the runtime Steam only names after Proton is installed; Experimental installed unattended on the device in 53 s). A PC VR launch test that fails on stable suggests Experimental (`pipeline.PROTON_TOOL`, `proton_alternative_worth_trying`, button "Try Proton Experimental and reinstall"). Remaining Rick and Morty stutter on head turns = the game rendering below the refresh rate (reprojection), not Proton.
|
||||
**USB cable link (verified 2026-10-05, cabled to the dev PC):** `usb0` is a USB **NCM** gadget (configfs g1: `ncm.usb0` + `ffs.adb`, Valve 28de:2460) set up by `usb-ncm-gadget@usb0.service`, which only runs with `ConditionPathExists=|/etc/systemd/system/adbd.service.d/steamos-devkit-enabled` = **Developer Mode only**. `usb-ncm-dnsmasq@usb0` serves DHCP with fixed `dhcp-host` entries: Frame 10.86.200.233, PC **10.86.200.234** (`frame/usb.py` FRAME_USB_IP/PC_USB_IP). Windows 11 binds its built-in "UsbNcm Host Device" (no driver); WSL mirrored sees eth 10.86.200.234. USB is high-speed (USB 2.0): upload 36.9 MB/s vs 11.3 MB/s home Wi-Fi. Frame→PC over the cable worked without a firewall change. `scripts/usb_autotest.py` (Windows via powershell.exe from WSL), `frameport frame usb-check`. Wizard "Set up with a USB cable" (`PairingServer(host=PC_USB_IP)`; the real bootstrap ran end to end over USB). Discovery dedupes by SSH host key and prefers USB > Frame hotspot > network (`discovery.dedupe`); a lost address is re-found by key (`Frame.relocate`, tested with a dead saved address).
|
||||
**Catalog updates without a release (2026-10-05):** apps fetch `catalog/games/*.yaml` from GitHub `main` (`recommend/catalog.refresh_remote`: one API call `git/trees/main?recursive=1` + raw.githubusercontent downloads of changed blobs, cached as `catalog-gh-<sha>.yaml`, every 6 h; GUI at start + Settings → Data "Check now"; CLI background thread + `frameport catalog-update`). `catalog.load()` only reads the cache. A remote entry is skipped (`unusable_reason`) when it names a patch/adapter key this app doesn't register, has a field `CatalogEntry` doesn't know, or `min_app` > this version: add `min_app` (or rely on new patch ids) when a config needs an unreleased FramePort. Remote beats bundled unless the bundled entry has a later updated/verified date; the user's own entries still win as before. Off: setting `catalog.auto_update`, env FRAMEPORT_NO_CATALOG_UPDATE; FRAMEPORT_CATALOG_URL still overrides the source. So: **pushing a catalog change to main reaches users within 6 h** (their recipes follow via catalog_rev → Update on Frame).
|
||||
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
|
||||
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
|
||||
|
||||
@@ -531,6 +541,11 @@ tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and
|
||||
`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + the short `packaging/release-footer.md`; owner: keep release
|
||||
notes short — a few "What's new" bullets, nothing long after them).
|
||||
Installed apps find the release themselves (self-update), so the notes are what users see in the update dialog.
|
||||
- **Dev builds (for testers, no release):** Actions → build → Run workflow (main), "dev" ticked, optional "notes"
|
||||
(what to test). The bundles get version `<next patch>.dev<run number>` (`scripts/dev_version.py`, not committed) and
|
||||
replace the rolling `dev` pre-release (`dev-release` job; same assets + SHA256SUMS). Automatic update checks ignore
|
||||
pre-releases; testers use Settings → Updates → "Install the latest dev build…" (`updates.check_dev`,
|
||||
`Updater.install_dev`). `parse_version` sorts 0.9.0 < 0.9.1.devN < 0.9.1, so testers get the next release normally.
|
||||
- **Release checklist:** bump `src/frameport/_version.py` (the only version; `scripts/package.py` fails a tag build
|
||||
whose tag ≠ `v<_version>`), commit, `git tag -a vX.Y.Z -m "FramePort X.Y.Z" -m "<What's new, Markdown bullets>"`,
|
||||
push the commit and the tag. Never publish a release without its `SHA256SUMS.txt` (the updater refuses it) and keep
|
||||
|
||||
@@ -52,7 +52,8 @@ Install games that target the Meta Quest, Android, or general PCVR onto your **V
|
||||
3. **Add games → Scan a folder** with your game backups (APK + OBB, or PC VR game folders).
|
||||
4. Open a game → **Install on Frame**, then play it from the Frame's Steam library.
|
||||
|
||||
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
Full guide, firewalls and troubleshooting: [docs/INSTALL.md](docs/INSTALL.md). Questions (e.g. how to lay out games
|
||||
with OBB files): [docs/FAQ.md](docs/FAQ.md).
|
||||
|
||||
## Type on Frame
|
||||
|
||||
|
||||
+122
-14
@@ -34,8 +34,9 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
AGENT_VERSION = 47
|
||||
AGENT_VERSION = 53
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -262,11 +263,17 @@ def compat_command(tool_dir, verb="waitforexitandrun", depth=0):
|
||||
|
||||
|
||||
def pick_proton(tools, wanted=None):
|
||||
ready = [t for t in tools if t["installed"] and t["require_installed"]]
|
||||
"""The Proton build a game uses: the one asked for (name, display name or alias such as proton-experimental),
|
||||
else the newest stable one (owner's choice 2026-10-05 after an A/B in the headset: Rick and Morty felt much
|
||||
smoother on Proton 11 than on Experimental; a game that fails gets "try Proton Experimental" suggested).
|
||||
Returned even when not installed yet: the installer installs it."""
|
||||
def matches(t, w):
|
||||
return w in (t["name"], t["display_name"]) or w in [a.strip() for a in t["aliases"].split(",")]
|
||||
if wanted:
|
||||
return next((t for t in tools if wanted in (t["name"], t["display_name"]) or wanted in t["aliases"].split(",")),
|
||||
None)
|
||||
return ready[0] if ready else (tools[0] if tools else None)
|
||||
return next((t for t in tools if matches(t, wanted)), None)
|
||||
stable = [t for t in tools if not t["experimental"]]
|
||||
ready = [t for t in stable if t["installed"] and t["require_installed"]]
|
||||
return (ready or stable or tools or [None])[0]
|
||||
|
||||
|
||||
def openxr_runtime():
|
||||
@@ -1163,9 +1170,14 @@ def cmd_shortcut_status(args):
|
||||
def deployment(pkg):
|
||||
path = os.path.join(ANCHORS, pkg, "deployment.json")
|
||||
try:
|
||||
return json.load(open(path))
|
||||
dep = json.load(open(path))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(dep, dict):
|
||||
return None
|
||||
dep.setdefault("base", os.path.dirname(path))
|
||||
dep.setdefault("title", pkg)
|
||||
return dep
|
||||
|
||||
|
||||
def cmd_list_installed(args):
|
||||
@@ -1175,6 +1187,12 @@ def cmd_list_installed(args):
|
||||
dep = json.load(open(dep_path))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
# incomplete records (an interrupted install, another tool's files in this folder) are skipped or completed
|
||||
# instead of failing every connection with a KeyError (GitHub #40)
|
||||
if not isinstance(dep, dict) or not dep.get("package") or not dep.get("appid"):
|
||||
continue
|
||||
dep.setdefault("base", os.path.dirname(dep_path))
|
||||
dep.setdefault("title", dep["package"])
|
||||
dep.setdefault("kind", "quest")
|
||||
dep["anchor"] = os.path.dirname(dep_path) # its artwork/ feeds the Steam grid (shortcuts)
|
||||
if dep["kind"] == "linux":
|
||||
@@ -2788,6 +2806,58 @@ def cmd_finalize_linux(args):
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "missing_libraries": missing}
|
||||
|
||||
|
||||
def cmd_usb_link(args):
|
||||
"""What the Frame's USB network link (usb0) looks like, for "connect with a USB cable" (read only): its address
|
||||
and state, the USB gadget functions behind it (ncm/ecm/rndis: decides which PCs need a driver), the USB speed,
|
||||
whether the Frame gives the PC an address (NetworkManager "shared" = DHCP server, dnsmasq) and whether sshd
|
||||
answers on it."""
|
||||
def sh(cmd): # a missing tool (nmcli, ss) leaves its field empty instead of failing the whole probe
|
||||
try:
|
||||
return run(cmd)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
return open(path).read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
out = {"present": os.path.isdir("/sys/class/net/usb0")}
|
||||
out["operstate"] = read("/sys/class/net/usb0/operstate")
|
||||
out["carrier"] = read("/sys/class/net/usb0/carrier")
|
||||
out["mac"] = read("/sys/class/net/usb0/address")
|
||||
p = sh(["ip", "-j", "addr", "show", "usb0"])
|
||||
try:
|
||||
out["addresses"] = [f"{a['local']}/{a['prefixlen']}" for i in json.loads(p.stdout or "[]")
|
||||
for a in i.get("addr_info", [])]
|
||||
except ValueError:
|
||||
out["addresses"] = []
|
||||
functions = []
|
||||
for g in glob.glob("/sys/kernel/config/usb_gadget/*"):
|
||||
functions += [f"{os.path.basename(g)}:{os.path.basename(f)}" for f in glob.glob(os.path.join(g, "functions/*"))]
|
||||
out.setdefault("udc", read(os.path.join(g, "UDC")))
|
||||
out.setdefault("gadget_ids", f"{read(os.path.join(g, 'idVendor'))}:{read(os.path.join(g, 'idProduct'))}")
|
||||
out["functions"] = functions
|
||||
out["speed"] = {os.path.basename(u): read(os.path.join(u, "current_speed")) for u in glob.glob("/sys/class/udc/*")}
|
||||
nm = sh(["nmcli", "-t", "-f", "DEVICE,STATE,CONNECTION", "device"])
|
||||
line = next((ln for ln in nm.stdout.splitlines() if ln.startswith("usb0:")), "")
|
||||
out["nm"] = line
|
||||
conn = line.split(":", 2)[2] if line.count(":") >= 2 else ""
|
||||
if conn:
|
||||
out["nm_ipv4_method"] = sh(["nmcli", "-g", "ipv4.method", "connection", "show", conn]).stdout.strip()
|
||||
out["dhcp_servers"] = [ln for ln in sh(["pgrep", "-a", "dnsmasq|udhcpd|kea|dhcpd"]).stdout.splitlines()][:5]
|
||||
out["leases"] = sorted(glob.glob("/var/lib/NetworkManager/dnsmasq-usb0*.leases") +
|
||||
glob.glob("/var/lib/misc/dnsmasq*.leases"))
|
||||
neigh = sh(["ip", "-j", "neigh", "show", "dev", "usb0"])
|
||||
try:
|
||||
out["neighbours"] = [n.get("dst") for n in json.loads(neigh.stdout or "[]")]
|
||||
except ValueError:
|
||||
out["neighbours"] = []
|
||||
out["sshd_listening"] = ":22 " in sh(["ss", "-ltn"]).stdout
|
||||
out["devkit_mode"] = os.path.exists("/etc/steamos-devkit-enabled")
|
||||
return out
|
||||
|
||||
|
||||
POWER_ACTIONS = {"sleep": "suspend", "restart": "reboot", "shutdown": "poweroff"}
|
||||
|
||||
|
||||
@@ -3557,29 +3627,67 @@ def steam_js(expression, timeout=5):
|
||||
return res.get("value")
|
||||
|
||||
|
||||
def dashboard_worker(log, parent, wait_start=240, window=25):
|
||||
"""Close SteamVR's dashboard once the game's first VR frames are logged (FrameBridge's "pacing:" line), checking
|
||||
for `window` seconds after that (Steam can open it a moment later; at most 3 times). Ends with the launcher."""
|
||||
FIRST_FRAME_MARKERS = (b"FrameBridge: new layer:", b"FrameBridge: pacing:")
|
||||
# SteamVR's dashboard UI logs a dashboard opened with the controller's button as "[ToggleDashboard]
|
||||
# toggle_dashboard_action"; Steam's own start-up menu comes without it (onShowOverlayRequestFromSteam / frame menu)
|
||||
USER_DASHBOARD_MARKER = b"toggle_dashboard_action"
|
||||
|
||||
|
||||
def user_opened_dashboard(log, pos):
|
||||
"""(opened, new position): whether the player opened the dashboard (controller button) since `pos` in SteamVR's
|
||||
vrwebhelper_systemui.txt. A missing log answers no (the worker then behaves as before)."""
|
||||
try:
|
||||
with open(log, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
end = f.tell()
|
||||
if pos is None or pos > end: # first look, or the log was rotated: only what comes from now on
|
||||
return False, end
|
||||
f.seek(pos)
|
||||
return USER_DASHBOARD_MARKER in f.read(), end
|
||||
except OSError:
|
||||
return False, pos
|
||||
|
||||
|
||||
def dashboard_worker(log, parent, wait_start=240, window=30, poll=0.5, ui_log=None):
|
||||
"""Close SteamVR's dashboard (Steam's "Resume game" frame menu) that opens when the game submits its first VR
|
||||
frame: watch from FrameBridge's first "new layer:" line (the first submitted frame; Steam showed the menu ~0.3 s
|
||||
later with ITR2), checking every `poll` s for `window` s after it (at most 3 hides). Waiting for the first
|
||||
"pacing:" summary (agent 44-51) was ~8 s too late: the owner had pressed Resume by then. Stops for good once the
|
||||
player opens the dashboard with the controller (agent 52 closed it 60 ms after each press: the game had paused
|
||||
for it and stayed paused). Ends with the launcher."""
|
||||
if ui_log is None:
|
||||
ui_log = os.path.join(STEAM, "logs/vrwebhelper_systemui.txt")
|
||||
_, ui_pos = user_opened_dashboard(ui_log, None)
|
||||
def alive():
|
||||
try:
|
||||
os.kill(int(parent), 0)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
deadline = time.time() + wait_start
|
||||
deadline, pos, tail = time.time() + wait_start, 0, b""
|
||||
while time.time() < deadline and alive():
|
||||
try:
|
||||
with open(log, "rb") as f:
|
||||
if b"FrameBridge: pacing:" in f.read():
|
||||
break
|
||||
f.seek(pos)
|
||||
chunk = f.read()
|
||||
pos += len(chunk)
|
||||
text = tail + chunk
|
||||
if any(m in text for m in FIRST_FRAME_MARKERS):
|
||||
break
|
||||
tail = text[-64:] # a marker split across two reads
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(1)
|
||||
time.sleep(poll)
|
||||
else:
|
||||
print("no VR frames logged; dashboard left as it is")
|
||||
return
|
||||
print(f"{time.strftime('%H:%M:%S')} first VR frame")
|
||||
hidden, end = 0, time.time() + window
|
||||
while time.time() < end and hidden < 3 and alive():
|
||||
user, ui_pos = user_opened_dashboard(ui_log, ui_pos)
|
||||
if user: # the player wants the dashboard (or the game's menu button opened it): never close it on them
|
||||
print(f"{time.strftime('%H:%M:%S')} dashboard opened with the controller: leaving it to the player")
|
||||
return
|
||||
try:
|
||||
if steam_js("SteamClient.OpenVR.VROverlay.IsDashboardVisible()"):
|
||||
steam_js("SteamClient.OpenVR.VROverlay.HideDashboard()")
|
||||
@@ -3588,7 +3696,7 @@ def dashboard_worker(log, parent, wait_start=240, window=25):
|
||||
except Exception as exc: # noqa: BLE001 (no devtools port, Steam restarting: leave it)
|
||||
print(f"steam ui: {exc}")
|
||||
return
|
||||
time.sleep(1)
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package: com.CMGames.IntoTheRadius2
|
||||
title: Into The Radius 2
|
||||
status: issues
|
||||
notes: Flickering textures were reported; space warp is turned off to address them (not yet confirmed in a headset).
|
||||
notes: Space warp is turned off (no flickering textures in the headset); the headset's brief focus losses are hidden
|
||||
(focus_hold, up to 2.5 s) because the game pauses on each. Loading a save can take over a minute.
|
||||
details: Unreal Engine 5 game that uses Meta's Application SpaceWarp (extra motion-vector and depth swapchains). OVRPort's
|
||||
patch_disable_space_warp doesn't reach Unreal Engine 5, so FrameBridge's hide_space_warp hides XR_FB_space_warp and
|
||||
removes the space warp info from the eye images. Working config shared in GitHub issue 35.
|
||||
removes the space warp info from the eye images. Working config shared in GitHub issue 35. In the headset
|
||||
(2026-10-05) the Frame dropped focus for 0.4 s and 1.0 s within ten seconds and Unreal paused each time; loading a save
|
||||
shows no frames for 40-80 s while the game's Sentry SDK (debug mode) logs its breadcrumbs.
|
||||
tested_version: 1.2.4
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
@@ -13,8 +16,10 @@ alt_overport:
|
||||
adapter:
|
||||
equirect_emul: 1
|
||||
hide_space_warp: 1
|
||||
focus_hold: 1
|
||||
focus_hold_ms: 2500
|
||||
verified:
|
||||
date: '2026-10-04'
|
||||
issue: 35
|
||||
updated: '2026-10-04'
|
||||
updated: '2026-10-05'
|
||||
source_hint: Into The Radius 2
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.resolutiongames.abvriop.santacruz
|
||||
title: palazzo_santacruz
|
||||
status: works
|
||||
notes: Played a few levels. Passthrough also works.
|
||||
tested_version: 3.7.6.10866-release
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-05'
|
||||
known_good_sha256: 394214a68297c1e5b019dc4f65b145e7bd0e98604205616364e0c151c1afd894
|
||||
app: 0.9.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260922.6101926'
|
||||
agent: 48
|
||||
issue: 46
|
||||
source_hint: abvriop
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# FAQ
|
||||
|
||||
## How should I lay out a game that has OBB files (an APK plus a data folder)?
|
||||
|
||||
Give every game its own folder, put the APK in it, and put the game's data next to the APK in a folder named
|
||||
after the game's **package name** (the name the `.obb` files contain, e.g. `com.Armature.VR4`):
|
||||
|
||||
```
|
||||
Games/ ← scan this folder (Add games → Scan a folder)
|
||||
├── Resident Evil 4/ ← one folder per game (any name)
|
||||
│ ├── VR4.apk ← the APK (any file name)
|
||||
│ └── com.Armature.VR4/ ← the data folder: exactly the package name
|
||||
│ ├── main.203.com.Armature.VR4.obb
|
||||
│ └── patch.203.com.Armature.VR4.obb
|
||||
└── Beat Saber/
|
||||
└── com.beatgames.beatsaber.apk ← games without OBBs: just the APK
|
||||
```
|
||||
|
||||
- **Data folder name:** the package name (`com.Armature.VR4` above), or `obb`. FramePort uses the first of the two
|
||||
that exists and isn't empty.
|
||||
- **Everything in that folder is copied** to the game's `Android/obb/<package>/` on the Frame, subfolders included.
|
||||
So games that ship raw data files instead of `.obb` files work the same way.
|
||||
- **One game per folder.** Several APKs in one folder count as one game; FramePort uses the first and keeps the others
|
||||
as alternates.
|
||||
- **Scanning:** pick the folder that contains the game folders (`Games/` above) to add them all, or one game's folder
|
||||
to add just that game. FramePort looks up to 5 levels deep.
|
||||
- **A single game:** Add games → Add an APK file… works with a lone APK too. Its data is found when the data folder sits next
|
||||
to the APK, named as above.
|
||||
|
||||
Not sure of the package name? Look at the OBB file names: `main.<version>.<package name>.obb`. Or add the APK
|
||||
first: the game's page shows the package name under **Details**.
|
||||
+1
-1
@@ -54,7 +54,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
|
||||
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
|
||||
| I Am Cat | Quest | ⚠️ Works with issues | The view vibrates slightly, even when holding still. |
|
||||
| Into The Radius 2 | Quest | ⚠️ Works with issues | Flickering textures were reported; space warp is turned off to address them (not yet confirmed in a headset). |
|
||||
| Into The Radius 2 | Quest | ⚠️ Works with issues | Space warp is turned off (no flickering textures in the headset); the headset's brief focus losses are hidden (focus_hold, up to 2.5 s) because the game paus… |
|
||||
| Lucky's Tale | Quest | ⚠️ Works with issues | Runs out of memory within a few minutes and freezes the Frame until the game is killed. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
|
||||
+28
-2
@@ -49,6 +49,20 @@ The Frame and the computer must be on the same network.
|
||||
and approve it on the Frame (Valve's own devkit pairing; it only sends this computer's key to the Frame). Install
|
||||
Lepton from the Steam Frame page afterwards if it's missing.
|
||||
|
||||
### With a USB cable
|
||||
|
||||
For networks that block the setup (guest Wi-Fi, firewalls, discovery not working), and for faster uploads:
|
||||
|
||||
1. On the Frame, turn on **Developer Mode** (Settings → System → Developer Mode). The Frame's USB network only exists
|
||||
in Developer Mode.
|
||||
2. Connect the Frame's USB-C port to the computer.
|
||||
3. In FramePort: **Steam Frame → Set up with a USB cable**. FramePort detects the cable and shows the setup command,
|
||||
which reaches the computer over the cable. Already set up? It connects over the cable right away.
|
||||
|
||||
The cable needs no driver on Windows 10/11, macOS or Linux, and the computer gets an address from the Frame
|
||||
automatically. Uploads use the cable whenever it's plugged in (about 37 MB/s, ~3× typical Wi-Fi), even when FramePort
|
||||
connected over Wi-Fi. Unplug it any time: FramePort finds the Frame on Wi-Fi again by itself.
|
||||
|
||||
### Firewalls
|
||||
|
||||
If the setup command only says "timed out", a firewall on your computer blocks the Frame; the setup page
|
||||
@@ -101,6 +115,12 @@ reality), showing only what matters for that game. Changes are kept with the gam
|
||||
|
||||
## Updating
|
||||
|
||||
**Game configs** (the tested recipes in the catalog) update by themselves: FramePort checks GitHub every 6 hours for
|
||||
newly confirmed or fixed configs and uses them without a FramePort update; a game whose recipe changed then shows
|
||||
**Update on Frame**. Configs that need a newer FramePort are skipped until you update. Settings → Data shows the last
|
||||
check (**Check now**) and turns this off.
|
||||
|
||||
|
||||
FramePort checks for a new release at start and every 6 hours (it only downloads the release information). When one
|
||||
exists, the Library shows **Update now**: FramePort downloads the new version, verifies it against the release's
|
||||
`SHA256SUMS.txt` (on Windows also the signature), restarts and opens as the new version. Games, settings, signing keys
|
||||
@@ -110,6 +130,10 @@ Settings → **Updates**: turn the check off, or turn on **Install updates autom
|
||||
installs at the next start). If FramePort's folder isn't writable, **Update now** opens the release page instead. The
|
||||
update log is `logs/update.log` in the data folder.
|
||||
|
||||
**Dev builds:** when you're asked to test a fix before it's released, use Settings → Updates → **Install the latest
|
||||
dev build…**. It shows what to test, then installs like an update (same checks). Dev builds are less tested; the next
|
||||
release is offered to you as a normal update.
|
||||
|
||||
Command line: `frameport update` (`--check` only checks, exit code 10 = update available; `--yes` doesn't ask).
|
||||
`FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off.
|
||||
|
||||
@@ -150,8 +174,10 @@ work like for other games.
|
||||
|
||||
The **Files** tab manages files on the Frame over the same connection as installs; no other transfer app is needed.
|
||||
Pick a location, browse folders, and use **Upload files** / **Upload folder**, **New folder**, or the download, rename
|
||||
and delete buttons on each entry. Tick several entries (or the box above the list for all) to download or delete them
|
||||
together. In the downloaded app you can also drag files and folders from Explorer / Finder / your file manager onto
|
||||
and delete buttons on each entry. Right-click an entry (or the empty space) for the same actions in a menu. Tick
|
||||
several entries (or the box above the list for all), or click and drag across them, to download or delete them
|
||||
together; a right-click on one of them then acts on all. The **Screenshots** tab and the **Library** work the same
|
||||
way: right-click for a menu, drag across cards to select several. In the downloaded app you can also drag files and folders from Explorer / Finder / your file manager onto
|
||||
the list to upload them into the open folder. Uploads and downloads run in the Activity panel, resume after an interruption and
|
||||
skip files that are already there.
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ company = "FramePort"
|
||||
path = "src"
|
||||
module = "main"
|
||||
|
||||
[tool.flet.macos]
|
||||
entitlement."com.apple.security.files.user-selected.read-write" = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stamp a dev build's version into src/frameport/_version.py (CI only; never committed): the next patch release plus
|
||||
.dev<build number>, e.g. 0.9.0 -> 0.9.1.dev57. It sorts above the current release and below the next one, so
|
||||
testers on a dev build get the next release offered as a normal update.
|
||||
|
||||
python scripts/dev_version.py <build number> # prints the stamped version
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION_FILE = Path(__file__).resolve().parents[1] / "src/frameport/_version.py"
|
||||
|
||||
|
||||
def dev_version(current: str, build: int) -> str:
|
||||
base = re.match(r"(\d+)\.(\d+)\.(\d+)", current)
|
||||
if not base:
|
||||
raise ValueError(f"unexpected version {current!r}")
|
||||
major, minor, patch = (int(x) for x in base.groups())
|
||||
return f"{major}.{minor}.{patch + 1}.dev{int(build)}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
text = VERSION_FILE.read_text(encoding="utf-8")
|
||||
current = re.search(r'__version__\s*=\s*"([^"]+)"', text).group(1)
|
||||
version = dev_version(current, int(sys.argv[1]))
|
||||
VERSION_FILE.write_text(text.replace(f'"{current}"', f'"{version}"', 1), encoding="utf-8")
|
||||
print(version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -239,6 +239,35 @@ def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threadin
|
||||
time.sleep(STEP_SECONDS)
|
||||
|
||||
|
||||
def find_button(root, text: str):
|
||||
"""The first button under `root` whose label is `text` (walks Flet's control tree)."""
|
||||
seen, stack = set(), [root]
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c is None or id(c) in seen:
|
||||
continue
|
||||
seen.add(id(c))
|
||||
label = getattr(c, "content", None)
|
||||
if (label == text or getattr(c, "text", None) == text) and getattr(c, "on_click", None):
|
||||
return c
|
||||
for attr in ("controls", "content", "actions"):
|
||||
v = getattr(c, attr, None)
|
||||
if isinstance(v, list):
|
||||
stack.extend(v)
|
||||
elif v is not None and not isinstance(v, str):
|
||||
stack.append(v)
|
||||
return None
|
||||
|
||||
|
||||
def click_usb_setup(app) -> None:
|
||||
app.navigate(1)
|
||||
time.sleep(2)
|
||||
button = find_button(app.body, "Set up with a USB cable")
|
||||
print(f"usb button found: {button is not None}", flush=True)
|
||||
if button:
|
||||
button.on_click(None)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", type=Path, required=True)
|
||||
@@ -253,7 +282,11 @@ def main() -> int:
|
||||
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
|
||||
help="open the install questions for these games, then click each dialog's main button twice "
|
||||
"(a double click must not duplicate dialogs); nothing is installed")
|
||||
ap.add_argument("--usb-setup", action="store_true", help="click 'Set up with a USB cable' on the connect page "
|
||||
"(a Frame cabled to this PC) and screenshot what follows")
|
||||
ap.add_argument("--docs", action="store_true", help="only the screens used in the docs (Library, --game, Frame)")
|
||||
ap.add_argument("--gestures", action="store_true", help="with --fake-frame: real mouse drags (drag-select) and "
|
||||
"right-clicks (menus) in Files, Screenshots and the Library; prints what got selected")
|
||||
ap.add_argument("--linux", action="store_true", help="add two pretend arm64 Linux apps and render their pages")
|
||||
args = ap.parse_args()
|
||||
if args.linux:
|
||||
@@ -292,6 +325,51 @@ def main() -> int:
|
||||
steps.append(("screenshot-viewer", lambda a: a.screenshots_view.viewer(0)))
|
||||
steps.append(("type-on-frame", lambda a: (a.page.pop_dialog(), open_type_dialog(a))))
|
||||
steps.append(("power-confirm", lambda a: (a.page.pop_dialog(), a.frame_power("restart"))))
|
||||
mouse: dict[str, callable] = {} # step name -> mouse actions (Playwright page) after its screenshot
|
||||
if args.gestures:
|
||||
STEP_SECONDS = 14 # the mouse actions run after each step's screenshot, before the next step
|
||||
|
||||
def drag(*points):
|
||||
def run(page):
|
||||
page.mouse.move(*points[0])
|
||||
time.sleep(0.5)
|
||||
page.mouse.down()
|
||||
for pt in points[1:]:
|
||||
page.mouse.move(*pt, steps=25)
|
||||
time.sleep(0.4)
|
||||
page.mouse.up()
|
||||
return run
|
||||
|
||||
def right_click(x, y):
|
||||
return lambda page: (page.mouse.move(x, y), time.sleep(0.5), page.mouse.click(x, y, button="right"))
|
||||
|
||||
def report(label, view, extra=""):
|
||||
def run(a):
|
||||
v = getattr(a, view)
|
||||
print(f"GESTURE {label}: selected={sorted(v.selected)} {extra and eval(extra)}", flush=True)
|
||||
return run
|
||||
|
||||
def escape(page):
|
||||
page.keyboard.press("Escape")
|
||||
steps = [("files", lambda a: a.go("files")),
|
||||
("files-dragged", report("files drag", "files_view")),
|
||||
("files-menu", lambda a: None),
|
||||
("files-space-menu", lambda a: None),
|
||||
("screenshots", lambda a: a.go("screenshots")),
|
||||
("screenshots-dragged", report("screenshots drag", "screenshots_view")),
|
||||
("screenshots-menu", lambda a: None),
|
||||
("library", lambda a: a.navigate(0)),
|
||||
("library-dragged", report("library drag", "library_view", "a.library_view.select_mode")),
|
||||
("library-undrag", report("library drag back", "library_view"))]
|
||||
mouse = {"files": drag((900, 268), (900, 320), (900, 372)), # the folder + 2 files
|
||||
"files-dragged": right_click(900, 320), # a selected one: "… 3 items"
|
||||
"files-menu": lambda page: (escape(page), time.sleep(0.5), right_click(900, 600)(page)),
|
||||
"files-space-menu": escape,
|
||||
"screenshots": drag((680, 280), (950, 280), (950, 430)), # 3 shots
|
||||
"screenshots-dragged": right_click(950, 430),
|
||||
"screenshots-menu": escape,
|
||||
"library": drag((360, 300), (560, 300), (760, 300)), # 3 cards, select mode on
|
||||
"library-dragged": drag((560, 300), (760, 300))} # from a selected card: deselects 2
|
||||
if args.linux:
|
||||
def linux_filter(a, value):
|
||||
a.navigate(0)
|
||||
@@ -304,6 +382,14 @@ def main() -> int:
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-menu", lambda a: (a.navigate(0), time.sleep(3), a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.usb_setup:
|
||||
def continue_usb(a):
|
||||
dialog = [d for d in a.page._dialogs.controls if d.open and type(d).__name__ == "AlertDialog"][-1]
|
||||
button = find_button(dialog, "Done: look for the cable") or dialog.actions[-1] # (label inside a Row)
|
||||
print(f"dialog button found: {button is not None}", flush=True)
|
||||
button.on_click(None)
|
||||
steps = [("usb-explain", click_usb_setup), ("usb-continue", continue_usb),
|
||||
("usb-wait", lambda a: time.sleep(8)), ("usb-after", lambda a: time.sleep(10))]
|
||||
if args.install_questions:
|
||||
queued: list[str] = []
|
||||
|
||||
@@ -395,6 +481,11 @@ def main() -> int:
|
||||
time.sleep(1.5)
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}.png"))
|
||||
shot += 1
|
||||
if name in mouse:
|
||||
mouse[name](page)
|
||||
time.sleep(2)
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}-mouse.png"))
|
||||
shot += 1
|
||||
browser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("browser: " + traceback.format_exc())
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
# ruff: noqa: E501 (the shell snippet for the Frame keeps its lines whole)
|
||||
"""Autonomous USB-cable test (Frame cabled to this PC): everything needed to decide on a "Connect with a USB cable"
|
||||
setup option, in one run, without touching the headset. Writes a Markdown report.
|
||||
|
||||
uv run python scripts/usb_autotest.py --out <report.md> [--no-speed]
|
||||
|
||||
Checks: Windows (via powershell.exe from WSL, or directly): new network adapters, Valve USB devices (VID 28DE), the
|
||||
driver Windows bound and the USB class codes (NCM 02/0D, ECM 02/06, RNDIS E0 or EF/04); the adapter's IPv4 and
|
||||
network category; Frame side (over the normal connection): agent `usb_link`, the systemd units / NetworkManager
|
||||
connection behind usb0 (does it depend on Developer Mode?); Frame -> PC over the cable (the setup direction: a
|
||||
temporary listener on a pairing port); PC -> Frame SSH over the cable (same host key); upload speed USB vs normal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
PS_PATHS = ("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "powershell.exe", "powershell")
|
||||
LISTEN_PORT = 8766 # in FramePort's pairing range (8765-8767)
|
||||
|
||||
|
||||
def powershell() -> str | None:
|
||||
for p in PS_PATHS:
|
||||
if os.path.exists(p) or shutil.which(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def ps(script: str, timeout: int = 60) -> str:
|
||||
exe = powershell()
|
||||
if not exe:
|
||||
return "(no PowerShell)"
|
||||
env = {k: v for k, v in os.environ.items() if k != "PSModulePath"}
|
||||
try:
|
||||
p = subprocess.run([exe, "-NoProfile", "-NonInteractive", "-Command", script], capture_output=True,
|
||||
text=True, timeout=timeout, env=env)
|
||||
return (p.stdout + p.stderr).replace("\r", "").strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return f"(powershell failed: {exc})"
|
||||
|
||||
|
||||
WINDOWS = r"""
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
'== adapters'
|
||||
Get-NetAdapter -IncludeHidden | Select-Object Name,InterfaceDescription,Status,LinkSpeed,MacAddress |
|
||||
Format-Table -AutoSize | Out-String -Width 250
|
||||
'== Valve USB devices (VID 28DE)'
|
||||
Get-PnpDevice -PresentOnly | Where-Object { $_.InstanceId -match 'VID_28DE' } | ForEach-Object {
|
||||
$d = $_
|
||||
$compat = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName 'DEVPKEY_Device_CompatibleIds').Data -join ' '
|
||||
$svc = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName 'DEVPKEY_Device_Service').Data
|
||||
$drv = (Get-PnpDeviceProperty -InstanceId $d.InstanceId -KeyName 'DEVPKEY_Device_DriverDesc').Data
|
||||
"{0} | {1} | status={2} | class={3} | service={4} | driver={5} | compat={6}" -f $d.FriendlyName, $d.InstanceId,
|
||||
$d.Status, $d.Class, $svc, $drv, $compat
|
||||
}
|
||||
'== problem devices'
|
||||
Get-PnpDevice -PresentOnly | Where-Object { $_.Status -ne 'OK' -and $_.InstanceId -match 'USB' } |
|
||||
Select-Object FriendlyName,Status,InstanceId | Format-Table -AutoSize | Out-String -Width 250
|
||||
'== IPv4 on 10.86.200.*'
|
||||
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -like '10.86.200.*' } |
|
||||
Select-Object InterfaceAlias,IPAddress,PrefixLength,PrefixOrigin | Format-Table | Out-String
|
||||
'== network profiles'
|
||||
Get-NetConnectionProfile | Select-Object InterfaceAlias,NetworkCategory | Format-Table | Out-String
|
||||
'== SSH to the Frame over the cable (from Windows)'
|
||||
(Test-NetConnection 10.86.200.233 -Port 22 -WarningAction SilentlyContinue).TcpTestSucceeded
|
||||
'== FramePort firewall rules'
|
||||
Get-NetFirewallRule -DisplayName '*FramePort*' | Select-Object DisplayName,Enabled,Profile,Direction |
|
||||
Format-Table | Out-String
|
||||
"""
|
||||
|
||||
FRAME_UNITS = r"""
|
||||
echo "== units mentioning usb/gadget"; systemctl list-units --all --no-pager 2>/dev/null | grep -i -E 'gadget|usb' | head -20
|
||||
for u in $(systemctl list-unit-files --no-pager 2>/dev/null | grep -i -E 'gadget|usb' | awk '{print $1}' | head -8); do
|
||||
echo "== $u"; systemctl cat "$u" --no-pager 2>/dev/null | grep -v '^#' | grep -v '^$' | head -30; done
|
||||
echo "== NetworkManager usb0"; nmcli -f GENERAL.CONNECTION,IP4.ADDRESS device show usb0 2>/dev/null
|
||||
c=$(nmcli -g GENERAL.CONNECTION device show usb0 2>/dev/null); [ -n "$c" ] && nmcli -f ipv4.method,ipv4.addresses,connection.autoconnect connection show "$c" 2>/dev/null
|
||||
echo "== configfs"; ls -la /sys/kernel/config/usb_gadget/ 2>&1 | head; ls /sys/kernel/config/usb_gadget/*/functions/ 2>&1 | head
|
||||
echo "== udc"; for u in /sys/class/udc/*; do echo "$u $(cat $u/current_speed 2>/dev/null) $(cat $u/state 2>/dev/null)"; done
|
||||
echo "== devkit"; ls -la /etc/steamos-devkit-enabled 2>&1
|
||||
"""
|
||||
|
||||
|
||||
def frame_to_pc(frame, pc_ips: list[str]) -> list[str]:
|
||||
"""Can the Frame open a TCP connection to this PC over the cable (what the setup step needs)?"""
|
||||
lines, got = [], []
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
srv.bind(("0.0.0.0", LISTEN_PORT))
|
||||
except OSError as exc:
|
||||
return [f"couldn't listen on {LISTEN_PORT}: {exc}"]
|
||||
srv.listen(4)
|
||||
srv.settimeout(1)
|
||||
|
||||
def accept():
|
||||
end = time.time() + 20
|
||||
while time.time() < end:
|
||||
try:
|
||||
conn, addr = srv.accept()
|
||||
got.append(addr[0])
|
||||
conn.sendall(b"HTTP/1.0 200 OK\r\n\r\nframeport-usb-ok\n")
|
||||
conn.close()
|
||||
except OSError:
|
||||
continue
|
||||
t = threading.Thread(target=accept, daemon=True)
|
||||
t.start()
|
||||
for ip in pc_ips:
|
||||
code, out, err = frame.run(f"curl -s --max-time 6 http://{ip}:{LISTEN_PORT}/ping || echo FAILED", timeout=20)
|
||||
lines.append(f"Frame -> {ip}:{LISTEN_PORT}: {(out or err).strip()[:80]}")
|
||||
t.join(timeout=22)
|
||||
srv.close()
|
||||
lines.append(f"connections seen from: {got or 'none'}")
|
||||
return lines
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", type=Path, default=Path("usb-autotest.md"))
|
||||
ap.add_argument("--no-speed", action="store_true")
|
||||
args = ap.parse_args()
|
||||
from frameport.frame import usb
|
||||
from frameport.frame.connection import saved_targets
|
||||
from frameport.targets.frame_lepton import FrameLeptonTarget
|
||||
|
||||
report = [f"# USB cable autotest {time.strftime('%Y-%m-%d %H:%M')}", ""]
|
||||
|
||||
def section(title, text):
|
||||
report.extend([f"## {title}", "```", text.strip() or "(nothing)", "```", ""])
|
||||
print(f"--- {title}\n{text.strip()[:3000]}\n", flush=True)
|
||||
|
||||
section("Windows", ps(WINDOWS, timeout=120))
|
||||
wsl = subprocess.run(["ip", "-4", "-o", "addr", "show"], capture_output=True, text=True).stdout
|
||||
section("This side (WSL/Linux) IPv4", wsl)
|
||||
frame = FrameLeptonTarget(saved_targets()[0], None).connect().frame
|
||||
section("Frame units / NetworkManager / configfs", frame.run(FRAME_UNITS, timeout=60)[1])
|
||||
result = usb.check(frame, measure=not args.no_speed)
|
||||
section("frameport frame usb-check", json.dumps(result, indent=1, default=str))
|
||||
ips = result.get("pc_addresses") or []
|
||||
section("Frame -> PC over the cable (setup direction)", "\n".join(frame_to_pc(frame, ips)) if ips else
|
||||
"skipped: this side has no address on the Frame's USB network")
|
||||
args.out.write_text("\n".join(report), encoding="utf-8")
|
||||
print(f"report: {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.9.0"
|
||||
__version__ = "0.9.1"
|
||||
@@ -84,6 +84,9 @@ def _update_hint() -> None:
|
||||
age = updates.cache_age()
|
||||
if age is None or age > updates.CHECK_EVERY:
|
||||
threading.Thread(target=updates.refresh_cache, daemon=True).start()
|
||||
from .recommend import catalog as _catalog
|
||||
|
||||
threading.Thread(target=_catalog.refresh_remote, daemon=True).start() # confirmed configs from GitHub main
|
||||
up = updates.cached_update()
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
if up and library.setting("update.cli_hint") != today:
|
||||
@@ -227,6 +230,18 @@ def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs
|
||||
typer.echo(f"{g['package']:40} {g.get('title', '')[:34]:34} {r['status']:11} {r['source']}")
|
||||
|
||||
|
||||
@app.command("catalog-update")
|
||||
def catalog_update():
|
||||
"""Fetch confirmed game configs from the repo's main branch now (normally automatic, every 6 h)."""
|
||||
from .recommend import catalog
|
||||
|
||||
n = catalog.refresh_remote(force=True)
|
||||
st = catalog.remote_status()
|
||||
typer.echo(f"{n} config(s) downloaded; {st['entries']} on GitHub main")
|
||||
for pkg, why in sorted(st["skipped"].items()):
|
||||
typer.echo(f" skipped {pkg}: {why}")
|
||||
|
||||
|
||||
@app.command("add-linux")
|
||||
def add_linux(path: Path = typer.Argument(..., help="an arm64 AppImage, a folder, or a .zip/.tar.gz with the app"),
|
||||
exe: Optional[str] = typer.Option(None, help="the program to start (relative to the app's folder)")):
|
||||
@@ -493,6 +508,16 @@ def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
typer.echo(json.dumps(_target(frame).describe(), indent=1, default=str))
|
||||
|
||||
|
||||
@frame_app.command("usb-check")
|
||||
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
|
||||
"""Check the USB cable link to the Frame: what the Frame presents, this PC's address on it, SSH over it and its
|
||||
upload speed vs the normal connection (JSON)."""
|
||||
from .frame import usb
|
||||
|
||||
typer.echo(json.dumps(usb.check(_target(frame).connect().frame, measure=not no_speed), indent=1, default=str))
|
||||
|
||||
|
||||
@frame_app.command("controller-models")
|
||||
def frame_controller_models(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
convert: bool = typer.Option(False, help="also convert them (cached on the Frame)")):
|
||||
|
||||
@@ -152,8 +152,41 @@ def browse(seconds: float = 4.0, scan: bool = True) -> list[Found]:
|
||||
known = {f.host for f in out}
|
||||
for ip in _scan(local_subnets(), known):
|
||||
out.append(Found(ip, ip, 22, "steamos", "scan", [ip]))
|
||||
rank = {"frameport": 0, "devkit": 1, "saved": 2, "scan": 3}
|
||||
return sorted(out, key=lambda f: (rank.get(f.source, 9), f.name, link_label(f.host) != "network", f.host))
|
||||
out = dedupe(out)
|
||||
return sorted(out, key=lambda f: (SOURCE_RANK.get(f.source, 9), f.name, LINK_RANK.get(f.via, 9), f.host))
|
||||
|
||||
|
||||
SOURCE_RANK = {"frameport": 0, "devkit": 1, "saved": 2, "scan": 3}
|
||||
LINK_RANK = {"USB": 0, "Frame hotspot": 1, "network": 2} # fastest first (USB ~37 MB/s, hotspot ~85, but the hotspot
|
||||
# needs the PC on the Frame's Wi-Fi: when both answer, the cable is the deliberate choice)
|
||||
|
||||
|
||||
def dedupe(found: list[Found], key_of=None) -> list[Found]:
|
||||
"""One entry per Frame: entries whose SSH host key matches (the same device reached over home Wi-Fi, its hotspot
|
||||
and USB, or found by mDNS, the saved list and the network scan) merge into one that uses the fastest link, keeps
|
||||
the best-known source/name and lists the other addresses. Entries whose key can't be read stay as they are."""
|
||||
if key_of is None:
|
||||
from .connection import server_key
|
||||
|
||||
def key_of(f: Found):
|
||||
k = server_key(f.host, f.port, timeout=3)
|
||||
return k.get_base64() if k else None
|
||||
with ThreadPoolExecutor(16) as pool:
|
||||
keys = list(pool.map(key_of, found))
|
||||
groups: dict[str, list[Found]] = {}
|
||||
out = []
|
||||
for f, k in zip(found, keys, strict=True):
|
||||
if k is None:
|
||||
out.append(f)
|
||||
else:
|
||||
groups.setdefault(k, []).append(f)
|
||||
for members in groups.values():
|
||||
best = min(members, key=lambda f: (LINK_RANK.get(f.via, 9), f.host))
|
||||
named = min(members, key=lambda f: (SOURCE_RANK.get(f.source, 9), f.source == "scan"))
|
||||
addresses = list(dict.fromkeys([best.host] + [a for f in members for a in [f.host, *f.addresses]]))
|
||||
out.append(Found(named.name if named.source != "scan" else best.name, best.host, best.port, named.user,
|
||||
named.source, addresses, {**best.properties, **named.properties}))
|
||||
return out
|
||||
|
||||
|
||||
def local_ip_towards(host: str = "8.8.8.8") -> str:
|
||||
|
||||
@@ -102,11 +102,13 @@ class PairingServer:
|
||||
requests: int = 0 # requests from the network (right or wrong code): the Frame can reach us
|
||||
flag: Path | None = None # WSL: the temporary firewall rule stays while this file exists
|
||||
hint: str = "" # set by the UI when nothing reached us after HINT_AFTER seconds: what may block the Frame
|
||||
host: str = "" # the address the Frame uses to reach this PC; "" = the PC's address towards the internet. The USB
|
||||
# setup uses the cable's fixed PC address (10.86.200.234), so no Wi-Fi, router or discovery is involved
|
||||
_httpd: http.server.ThreadingHTTPServer | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"http://{local_ip_towards()}:{self.port}"
|
||||
return f"http://{self.host or local_ip_towards()}:{self.port}"
|
||||
|
||||
@property
|
||||
def one_liner(self) -> str:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""USB cable link to the Frame (usb0, a USB network gadget): one check that answers whether "connect with a USB
|
||||
cable" can work on this PC: what the Frame presents, whether this PC got an address on it, whether SSH answers
|
||||
over it (same host key), and how fast an upload is compared with the normal connection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
USB_IFACE = "usb0"
|
||||
FRAME_USB_IP, PC_USB_IP = "10.86.200.233", "10.86.200.234" # fixed by the Frame's dnsmasq (dhcp-host by MAC)
|
||||
TEST_BYTES = 128 << 20
|
||||
|
||||
|
||||
def pc_usb_addresses(frame_ip: str) -> list[str]:
|
||||
"""This PC's IPv4 addresses in the same /24 as the Frame's USB address (Windows via ipconfig, else `ip`)."""
|
||||
prefix = frame_ip.rsplit(".", 1)[0] + "."
|
||||
texts = []
|
||||
for cmd in (["ip", "-4", "-o", "addr", "show"], ["ipconfig"], ["ifconfig"], ["ipconfig.exe"]):
|
||||
try:
|
||||
texts.append(subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
text = "\n".join(texts)
|
||||
# interface addresses only (`ip`/ifconfig "inet X", ipconfig "IPv4 Address ... : X"): not broadcast/netmask values
|
||||
found = re.findall(r"\binet\s+(\d+\.\d+\.\d+\.\d+)", text) + \
|
||||
re.findall(r"IPv4[^:\n]*:\s*(\d+\.\d+\.\d+\.\d+)", text)
|
||||
return sorted({ip for ip in found if ip.startswith(prefix) and ip != frame_ip})
|
||||
|
||||
|
||||
def upload_speed(frame, size: int = TEST_BYTES) -> float:
|
||||
"""MB/s of one SFTP upload of `size` bytes to /tmp on the Frame (removed afterwards)."""
|
||||
path = f"/tmp/frameport-speed-{os.getpid()}"
|
||||
data = io.BytesIO(os.urandom(1 << 20) * (size >> 20))
|
||||
start = time.time()
|
||||
frame.sftp.putfo(data, path)
|
||||
elapsed = time.time() - start
|
||||
frame.run(f"rm -f {path}")
|
||||
return size / elapsed / 1e6
|
||||
|
||||
|
||||
def check(frame, measure: bool = True) -> dict:
|
||||
"""{frame: <agent usb_link>, frame_ip, pc_addresses, reachable, same_frame, speed_usb, speed_default, notes}."""
|
||||
info = frame.agent("usb_link", timeout=60)
|
||||
out = {"frame": info, "platform": sys.platform, "notes": []}
|
||||
ip = next((a.split("/")[0] for a in info.get("addresses") or []), None)
|
||||
out["frame_ip"] = ip
|
||||
if not info.get("present"):
|
||||
out["notes"].append("The Frame has no usb0 link: is the cable in the Frame's USB-C port and the PC?")
|
||||
return out
|
||||
if not ip:
|
||||
out["notes"].append("usb0 exists but has no address on the Frame (cable not connected to a PC?)")
|
||||
return out
|
||||
out["pc_addresses"] = pc_usb_addresses(ip)
|
||||
if not out["pc_addresses"]:
|
||||
out["notes"].append("This PC has no address on the Frame's USB network: no driver for the gadget "
|
||||
f"({', '.join(info.get('functions') or []) or 'unknown type'}) or no DHCP from the Frame")
|
||||
try:
|
||||
socket.create_connection((ip, frame.target.port), timeout=3).close()
|
||||
out["reachable"] = True
|
||||
except OSError:
|
||||
out["reachable"] = False
|
||||
out["notes"].append(f"SSH ({ip}:{frame.target.port}) doesn't answer over the cable from this PC")
|
||||
return out
|
||||
from .connection import Frame, FrameTarget
|
||||
|
||||
other = Frame(FrameTarget(ip, frame.target.user, frame.target.port), frame.password)
|
||||
try:
|
||||
other.connect(timeout=10)
|
||||
out["same_frame"] = (other.client.get_transport().get_remote_server_key()
|
||||
== frame.client.get_transport().get_remote_server_key())
|
||||
if measure:
|
||||
out["speed_usb"] = round(upload_speed(other), 1)
|
||||
out["speed_default"] = round(upload_speed(frame), 1)
|
||||
finally:
|
||||
other.close()
|
||||
return out
|
||||
@@ -281,6 +281,7 @@ def ensure_proton(frame: Frame, reporter: Reporter, tool: str | None = None, tim
|
||||
reporter.log(r.get("hint") or f"installing {r.get('tool')}")
|
||||
end = time.time() + timeout
|
||||
last = None
|
||||
asked_runtime = False
|
||||
while time.time() < end:
|
||||
reporter.check_cancel()
|
||||
time.sleep(10)
|
||||
@@ -292,6 +293,14 @@ def ensure_proton(frame: Frame, reporter: Reporter, tool: str | None = None, tim
|
||||
reporter.check("Proton installed", True, st["ready"]["display_name"])
|
||||
return st["ready"]
|
||||
dl = st.get("download") or {}
|
||||
sug = st.get("suggested") or {}
|
||||
if sug.get("installed") and not sug.get("require_installed") and not dl.get("total") and not asked_runtime:
|
||||
# Steam names the runtime a Proton needs (e.g. Experimental) only once Proton itself is installed: ask
|
||||
# for it now
|
||||
asked_runtime = True
|
||||
reporter.log("installing the Steam Linux Runtime this Proton needs")
|
||||
frame.agent("install_proton", mode="unattended", tool=tool)
|
||||
continue
|
||||
if dl.get("total"):
|
||||
reporter.progress(dl["done"] / dl["total"], f"downloading Proton {dl['done'] / 2**20:.0f}/"
|
||||
f"{dl['total'] / 2**20:.0f} MiB")
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"Artwork: {value}": "",
|
||||
"As the game sends it": "",
|
||||
"Asked Steam to install Lepton": "",
|
||||
"Asked to test a fix? Dev builds come before the next release.": "",
|
||||
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 72 Hz.": "",
|
||||
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 90 Hz.": "",
|
||||
"Assassin's Creed Nexus: smoother picture at 72 Hz (turns off Meta's frame-rate trick).": "",
|
||||
@@ -151,6 +152,8 @@
|
||||
"Best guess": "",
|
||||
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (e.g. Assassin's Creed Nexus).": "",
|
||||
"Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, /sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files by browsing folders. Video players that list only their own folder (e.g. 4XVR's Internal Storage = 4XPlayer): upload into that folder in the game's storage.": "",
|
||||
"Build page": "",
|
||||
"Cable connected.": "",
|
||||
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
|
||||
"Can't run": "",
|
||||
"Cancel": "",
|
||||
@@ -172,21 +175,23 @@
|
||||
"Check for new versions automatically": "",
|
||||
"Check for updates": "",
|
||||
"Check game files": "",
|
||||
"Check now": "",
|
||||
"Check that the Frame and this computer are on the same network.": "",
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Choose PNG, JPEG or WebP images. Steam fills any missing shape from the ones you choose; FramePort never replaces your images automatically.": "",
|
||||
"Chooses which Proton version runs the game on the Frame.": "",
|
||||
"Choose…": "",
|
||||
"Chosen by you.": "",
|
||||
"Clean up Frida leftovers in smali": "",
|
||||
"Cleans up graphics data that crashes some Unreal games on the Frame.": "",
|
||||
"Clear": "",
|
||||
"Clear android:debuggable": "",
|
||||
"Clear finished": "",
|
||||
"Clear search": "",
|
||||
"Clear selection": "",
|
||||
"Clear tags": "",
|
||||
"Click here, then type. Everything you type goes to the Frame (Esc and shortcuts too).": "",
|
||||
"Click to open · right-click for quick actions": "",
|
||||
"Click to open · right-click for quick actions · drag across cards to select several": "",
|
||||
"Close": "",
|
||||
"Close FramePort": "",
|
||||
"Collect and open GitHub": "",
|
||||
@@ -195,6 +200,7 @@
|
||||
"Collect logs: {title}": "",
|
||||
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, e.g. Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
|
||||
"Connect": "",
|
||||
"Connect the Frame's USB-C port to this computer with a USB cable (a data cable, not a charge-only one).": "",
|
||||
"Connect your Frame": "",
|
||||
"Connect your Frame first": "",
|
||||
"Connect your Steam Frame": "",
|
||||
@@ -212,6 +218,7 @@
|
||||
"Copy": "",
|
||||
"Copy all": "",
|
||||
"Copy failed; select the text instead": "",
|
||||
"Copy folder path": "",
|
||||
"Copy log": "",
|
||||
"Copy OVRPlugin if VrApi is present": "",
|
||||
"Copy OVRPort libraries": "",
|
||||
@@ -238,16 +245,21 @@
|
||||
"Declares Meta's permissions some mixed-reality games ask for, so they don't stop.": "",
|
||||
"Delete": "",
|
||||
"Delete {names}?": "",
|
||||
"Delete {n} items…": "",
|
||||
"Delete {n} screenshot?": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Delete {n} screenshots…": "",
|
||||
"Deleted {n} screenshot": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Deletes the rollback copies kept from each game's previous install and leftover uploads. The games and saves stay.": "",
|
||||
"Delete…": "",
|
||||
"Deselect": "",
|
||||
"Details": "",
|
||||
"Dev builds let you test fixes before they're released. They're less tested than releases and may have bugs. You get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
|
||||
"Didn't finish ({value}): {error}": "",
|
||||
"Disable application space warp if used": "",
|
||||
@@ -259,16 +271,21 @@
|
||||
"Don't pause on short interruptions": "",
|
||||
"done": "",
|
||||
"Done": "",
|
||||
"Done: look for the cable": "",
|
||||
"Download": "",
|
||||
"Download all": "",
|
||||
"Download all ({n})…": "",
|
||||
"Download selected": "",
|
||||
"Download to this PC": "",
|
||||
"Download to this PC…": "",
|
||||
"Download to which folder on this PC?": "",
|
||||
"Download {name}": "",
|
||||
"Download {n} items to this PC…": "",
|
||||
"Download {n} screenshot": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Download {n} screenshots…": "",
|
||||
"Downloaded when first needed": "",
|
||||
"Downloaded {n} file to {folder}": [
|
||||
"",
|
||||
@@ -280,6 +297,7 @@
|
||||
],
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Download…": "",
|
||||
"Draws each eye separately; try it when one eye shows a grey or broken picture.": "",
|
||||
"Drop invalid layers": "",
|
||||
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
|
||||
@@ -361,6 +379,7 @@
|
||||
"FramePort picked {value} to start this game, but there are other candidates. Check it before installing.": "",
|
||||
"FramePort runs in WSL with its default NAT network, which other devices can't reach. Set networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig, run wsl --shutdown and start FramePort again.": "",
|
||||
"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.": "",
|
||||
"FramePort then finds the Frame on the cable by itself.": "",
|
||||
"FramePort updates and restarts after the current job": "",
|
||||
"FramePort was removed": "",
|
||||
"FramePort {version} is available": "",
|
||||
@@ -384,6 +403,8 @@
|
||||
"Game": "",
|
||||
"Game arguments (select SteamVR / OpenXR)": "",
|
||||
"Game config files": "",
|
||||
"Game configs are up to date": "",
|
||||
"Game configs from GitHub: last checked {when}": "",
|
||||
"Game launch capacity": "",
|
||||
"Game not found": "",
|
||||
"Game settings": "",
|
||||
@@ -405,6 +426,7 @@
|
||||
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview shaders fail on single-view render targets (e.g. Path of the Warrior).": "",
|
||||
"GL shim: hide multiview": "",
|
||||
"Go to my library": "",
|
||||
"Go to System and turn on \"Enable Developer Mode\". A Developer page appears in Settings; nothing else changes, your games and data stay as they are.": "",
|
||||
"Graphics": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
"Hide log": "",
|
||||
@@ -433,6 +455,8 @@
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
"Install": "",
|
||||
"Install anyway": "",
|
||||
"Install dev build": "",
|
||||
"Install dev build {version}?": "",
|
||||
"Install it anyway to try it, e.g. with different patches.": "",
|
||||
"Install Lepton on the Frame": "",
|
||||
"Install missing": "",
|
||||
@@ -442,6 +466,7 @@
|
||||
"Install Proton on the Frame?": "",
|
||||
"Install SteamVR from Steam to play PC VR games": "",
|
||||
"Install the alternate build (": "",
|
||||
"Install the latest dev build…": "",
|
||||
"Install updates automatically (downloads in the background, installs when FramePort next starts)": "",
|
||||
"Install {len} on this PC": "",
|
||||
"Install {n} on Frame": "",
|
||||
@@ -524,6 +549,7 @@
|
||||
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Looking for Frames on your network…": "",
|
||||
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this computer).": "",
|
||||
"Looking for your Frame…": "",
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
@@ -559,7 +585,9 @@
|
||||
"Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't have — it crashes at startup there. Play it on this PC.": "",
|
||||
"Needs Windows (or WSL on Windows)": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"never": "",
|
||||
"New folder": "",
|
||||
"New folder…": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest. Did this once already? Your Frame appears under step 2.": "",
|
||||
"New name": "",
|
||||
"Next": "",
|
||||
@@ -567,15 +595,18 @@
|
||||
"No activity": "",
|
||||
"no answer from the Frame": "",
|
||||
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
|
||||
"No dev build is published right now": "",
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No games found in that folder": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
"No new games in {name}": "",
|
||||
"No notes.": "",
|
||||
"No release notes.": "",
|
||||
"No screenshots yet": "",
|
||||
"No Unreal crash reporter": "",
|
||||
"No Wi-Fi needed: for networks that block the setup, and faster game uploads": "",
|
||||
"No: a 2D app": "",
|
||||
"None": "",
|
||||
"None found": "",
|
||||
@@ -607,7 +638,9 @@
|
||||
"Offline": "",
|
||||
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
|
||||
"On Frame": "",
|
||||
"On the Frame, open Steam's Settings (Steam button → Settings).": "",
|
||||
"On the Frame, place Revive's runtime (FramePort's Revive, or the repack's bundled LibRevive) where the game's Oculus SDK looks for LibOVRRT so it can load a VR runtime (pure runtime substitution — what Revive's LoadLibrary hook does on Windows; that hook doesn't work under Proton on the Frame). It does NOT bypass the Oculus runtime signature check: a game that verifies the runtime's signature will still refuse it. Ignored on this PC (Revive's hook handles it there).": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run (it reaches this computer over the USB cable):": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run:": "",
|
||||
"On this PC": "",
|
||||
"On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).": "",
|
||||
@@ -623,6 +656,7 @@
|
||||
"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.": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR runtime": "",
|
||||
"OR": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect there and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
@@ -748,6 +782,7 @@
|
||||
"Removes {title}'s game files from the Frame. Saves are kept; the Steam entry disappears after the next Steam restart.": "",
|
||||
"Rename": "",
|
||||
"Rename {name}": "",
|
||||
"Rename…": "",
|
||||
"Replace application label and icon": "",
|
||||
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the Frame: GLES sessions, cylinder→quad layers, sRGB format fallback, 30 s VR-mode deadline, emulated time conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (e.g. The Climb 2, Path of the Warrior); OVRPort cannot translate those.": "",
|
||||
"Report a problem": "",
|
||||
@@ -816,6 +851,7 @@
|
||||
"Set by you.": "",
|
||||
"Set up the connection to your Steam Frame first": "",
|
||||
"Set up the Frame": "",
|
||||
"Set up with a USB cable": "",
|
||||
"Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang the Frame's GL driver ('zink: DEVICE LOST', e.g. Sniper Elite VR). Try it for GLES Unity games that freeze or crash the GPU.": "",
|
||||
"Settings": "",
|
||||
"shader cache: makes later starts load faster; rebuilt if deleted": "",
|
||||
@@ -831,6 +867,8 @@
|
||||
"Show less": "",
|
||||
"Show log": "",
|
||||
"Show more": "",
|
||||
"Show only screenshots not from a FramePort game": "",
|
||||
"Show only this game's screenshots": "",
|
||||
"Show progress": "",
|
||||
"Show setup command": "",
|
||||
"Show Steam Frame controllers": "",
|
||||
@@ -922,6 +960,7 @@
|
||||
"The Frame is shutting down.": "",
|
||||
"the Frame isn't connected": "",
|
||||
"The Frame isn't connected": "",
|
||||
"The Frame only offers its USB connection in Developer Mode:": "",
|
||||
"The Frame refused that password. Check it, or run the first-time setup instead: Steam Frame → Show setup command.": "",
|
||||
"The Frame restarts in a few seconds; FramePort reconnects when it's back.": "",
|
||||
"The Frame turns off in a few seconds. Turn it on again with its power button.": "",
|
||||
@@ -974,8 +1013,10 @@
|
||||
"Troubleshooting": "",
|
||||
"Try again": "",
|
||||
"Try now": "",
|
||||
"Try Proton Experimental and reinstall": "",
|
||||
"Turn off only for games you play with your hands instead of controllers.": "",
|
||||
"Turn off space warp": "",
|
||||
"Turn on Developer Mode first": "",
|
||||
"Turn the Frame off": "",
|
||||
"Turn this on if your copy is already patched: an APK is installed unchanged instead of being patched again. PC VR games are never modified on this PC; the Frame's copy only gets launch fixes.": "",
|
||||
"Turned around": "",
|
||||
@@ -1019,6 +1060,7 @@
|
||||
"Update available": "",
|
||||
"Update failed: {exc}": "",
|
||||
"Update FramePort to {version}": "",
|
||||
"Update game configs from GitHub automatically (confirmed configs arrive without a FramePort update)": "",
|
||||
"Update now": "",
|
||||
"Update on Frame": "",
|
||||
"Update on this PC": "",
|
||||
@@ -1031,7 +1073,9 @@
|
||||
"Updates, tools, this PC and where FramePort keeps its data": "",
|
||||
"Updating works in the desktop app": "",
|
||||
"Upload files": "",
|
||||
"Upload files…": "",
|
||||
"Upload folder": "",
|
||||
"Upload folder…": "",
|
||||
"Upload to {label}": "",
|
||||
"Uploaded {n} file": [
|
||||
"",
|
||||
@@ -1058,6 +1102,7 @@
|
||||
"Video players' virtual theatres and 360° videos would otherwise stay black.": "",
|
||||
"Videos": "",
|
||||
"Videos, documents, mods and saves on your Steam Frame": "",
|
||||
"View": "",
|
||||
"View screenshot": "",
|
||||
"VR (OpenXR)": "",
|
||||
"VrApi → OpenXR adapter (OVRPort)": "",
|
||||
@@ -1069,6 +1114,7 @@
|
||||
"Wait for the game's GPU before showing an image": "",
|
||||
"Waiting": "",
|
||||
"Waiting ({n})": "",
|
||||
"Waiting for the cable…": "",
|
||||
"Waiting for the Frame": "",
|
||||
"Waiting for your Frame": "",
|
||||
"Waiting for your Frame… (code {code})": "",
|
||||
@@ -1079,7 +1125,8 @@
|
||||
"Where it's installed": "",
|
||||
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
"Which program starts {get}?": "",
|
||||
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
|
||||
"Which Proton build runs the game on the Frame: the stable Proton (the default: smoother in our tests) or Proton Experimental (gets Valve's ARM64 fixes first; try it when a game doesn't start or runs badly). Also takes any compat tool name from the Frame's ARM64 list.": "",
|
||||
"Which Proton runs the game on the Frame: Stable is smoother; choose Experimental if the game doesn't start or runs badly.": "",
|
||||
"Wide cover": "",
|
||||
"wide, 1920 × 620": "",
|
||||
"Windows": "",
|
||||
@@ -1104,6 +1151,7 @@
|
||||
"Yes": "",
|
||||
"Yes: uses the Frame's OpenXR runtime": "",
|
||||
"Yesterday": "",
|
||||
"You already have the latest dev build ({version})": "",
|
||||
"You have the latest version as of the last check ({when}).": "",
|
||||
"You have {version}. {how}": "",
|
||||
"Your copy is used as it is (only the Frame's copy gets launch patches)": "",
|
||||
@@ -1155,6 +1203,10 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game config was updated": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game didn't work out": [
|
||||
"",
|
||||
""
|
||||
@@ -1168,6 +1220,10 @@
|
||||
""
|
||||
],
|
||||
"{n} lines, the last 4000 shown": "",
|
||||
"{n} needs a newer FramePort": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} new game in {name}": [
|
||||
"",
|
||||
""
|
||||
|
||||
@@ -202,10 +202,12 @@ class ProtonLog(_PcvrPatch):
|
||||
class ProtonTool(_PcvrPatch):
|
||||
id = "pcvr.proton_tool"
|
||||
title = "Proton version (Frame)"
|
||||
description = ("Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 "
|
||||
"compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.")
|
||||
description = ("Which Proton build runs the game on the Frame: the stable Proton (the default: smoother in our "
|
||||
"tests) or Proton Experimental (gets Valve's ARM64 fixes first; try it when a game doesn't start "
|
||||
"or runs badly). Also takes any compat tool name from the Frame's ARM64 list.")
|
||||
order = 40
|
||||
params = [Param("tool", "str", "", "compat tool name")]
|
||||
params = [Param("tool", "str", "", "proton-stable (default), proton-experimental, or a compat tool name")]
|
||||
CHOICES = (("", "Stable (default)"), ("proton-experimental", "Experimental"))
|
||||
|
||||
|
||||
class ProtonEnv(_PcvrPatch):
|
||||
|
||||
@@ -74,7 +74,8 @@ SUMMARIES = {
|
||||
"pcvr.revive_openvr": "On this PC, runs Revive through SteamVR (works with more headsets).",
|
||||
"pcvr.xr_timefix": "Lets games written for newer OpenXR versions run on the Frame.",
|
||||
"pcvr.proton_log": "Writes a detailed Proton log for troubleshooting.",
|
||||
"pcvr.proton_tool": "Chooses which Proton version runs the game on the Frame.",
|
||||
"pcvr.proton_tool": "Which Proton runs the game on the Frame: Stable is smoother; choose Experimental if the "
|
||||
"game doesn't start or runs badly.",
|
||||
"pcvr.steamvr_tuning": "Picks a refresh rate and motion smoothing that suit the game on this PC.",
|
||||
"pcvr.proton_env": "Extra settings for Proton (for testing).",
|
||||
}
|
||||
@@ -668,14 +668,30 @@ def test_game(package: str, target: Target, reporter: Reporter, seconds: int = 4
|
||||
for old in sorted(logs.glob(f"{package}-*.log"))[:-5]: # keep the last 5 per game
|
||||
old.unlink(missing_ok=True)
|
||||
reporter.log(f"full launch log ({len((log or '').splitlines())} lines): {log_path}")
|
||||
suggestions = useful_suggestions(package, result.suggestions())
|
||||
if proton_alternative_worth_trying(package, result.verdict):
|
||||
suggestions.append(PROTON_TOOL)
|
||||
reporter.log("it failed on the stable Proton: the suggestion is to try Proton Experimental")
|
||||
summary = {"state": result.state, "verdict": result.verdict, "milestone": result.milestone, "fps": result.fps,
|
||||
"findings": [f.__dict__ for f in result.findings],
|
||||
"suggestions": useful_suggestions(package, result.suggestions()),
|
||||
"suggestions": suggestions,
|
||||
"time": time.time(), "target": target.label, "log_path": str(log_path)}
|
||||
library.upsert_game(package, last_test=summary)
|
||||
return summary
|
||||
|
||||
|
||||
PROTON_TOOL = "pcvr.proton_tool" # as a suggestion: run the game with Proton Experimental instead of the stable one
|
||||
|
||||
|
||||
def proton_alternative_worth_trying(package: str, verdict: str | None) -> bool:
|
||||
"""A PC VR game failed its launch test on the stable Proton (the default): Proton Experimental is worth a try."""
|
||||
entry = library.game(package) or {}
|
||||
if not is_rift(entry) or verdict == "pass":
|
||||
return False
|
||||
chosen = ((entry.get("recipe") or {}).get("patches") or {}).get(PROTON_TOOL) or {}
|
||||
return "experimental" not in (chosen.get("tool") or "")
|
||||
|
||||
|
||||
def useful_suggestions(package: str, suggestions: list[str]) -> list[str]:
|
||||
"""Triage suggestions that would change something: not already on, and not conflicting with the recipe (e.g. no
|
||||
Revive for a repack that runs its own)."""
|
||||
@@ -697,6 +713,10 @@ def apply_suggestions(package: str, suggestions: list[str]) -> Recipe:
|
||||
entry = library.game(package)
|
||||
recipe = library.recipe_from_dict(entry["recipe"])
|
||||
for pid in suggestions:
|
||||
if pid == PROTON_TOOL:
|
||||
recipe.patches[pid] = {"tool": "proton-experimental"}
|
||||
recipe.reasons[pid] = "It failed on the stable Proton: trying Proton Experimental."
|
||||
continue
|
||||
if pid.startswith("adapter."):
|
||||
from .patches.base import get
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
Sources, highest priority first:
|
||||
1. user entries <user data>/catalog/games/*.yaml (written by "Save as known-good" after a successful test)
|
||||
2. remote catalog FRAMEPORT_CATALOG_URL (a folder URL serving index.json + <package>.yaml), cached; lets the
|
||||
catalog be updated without a new app release
|
||||
2. remote catalog the repo's catalog/games on GitHub `main` (default; or FRAMEPORT_CATALOG_URL, a folder URL serving
|
||||
index.json + <package>.yaml): confirmed configs reach installed apps without a release. Fetched
|
||||
in the background (refresh_remote, at most every 6 h); loading only reads the cache. An entry that
|
||||
needs a newer FramePort (a patch, adapter setting or recipe field this version doesn't know, or
|
||||
min_app above this version) is skipped: the bundled entry stays.
|
||||
3. bundled catalog/games/*.yaml in this repo
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -50,6 +53,7 @@ class CatalogEntry:
|
||||
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
|
||||
verified: dict = field(default_factory=dict)
|
||||
updated: str = "" # date of the last maintainer change without a new headset test (e.g. a fix from a report)
|
||||
min_app: str = "" # oldest FramePort version this entry is for (remote entries for a newer app are skipped)
|
||||
source_hint: str = ""
|
||||
origin: str = "bundled"
|
||||
|
||||
@@ -96,10 +100,143 @@ def _load_dir(path: Path, origin: str) -> dict[str, CatalogEntry]:
|
||||
PACKAGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
|
||||
|
||||
|
||||
REMOTE_EVERY = 6 * 3600
|
||||
GITHUB_TREE = "https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1"
|
||||
GITHUB_RAW = "https://raw.githubusercontent.com/{repo}/{ref}/{path}"
|
||||
GITHUB_DIR = "catalog/games/"
|
||||
|
||||
|
||||
def _repo() -> str:
|
||||
from .. import REPO_URL
|
||||
|
||||
return REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
|
||||
|
||||
def remote_enabled() -> bool:
|
||||
"""Automatic catalog updates from GitHub (Settings; FRAMEPORT_NO_CATALOG_UPDATE=1 turns them off)."""
|
||||
from ..core import library
|
||||
|
||||
if os.environ.get("FRAMEPORT_NO_CATALOG_UPDATE"):
|
||||
return False
|
||||
try:
|
||||
return bool(library.setting("catalog.auto_update", True))
|
||||
except Exception: # noqa: BLE001 - no library yet
|
||||
return True
|
||||
|
||||
|
||||
def unusable_reason(entry_dict: dict) -> str | None:
|
||||
"""Why this FramePort can't use a (remote) entry, or None: a recipe field, patch or adapter setting it doesn't
|
||||
know (the entry was written for a newer FramePort), or min_app above this version."""
|
||||
from .. import __version__
|
||||
from ..patches import base
|
||||
from ..updates import parse_version
|
||||
|
||||
unknown = [k for k in entry_dict if k not in CatalogEntry.__dataclass_fields__]
|
||||
if unknown:
|
||||
return f"unknown field(s) {', '.join(sorted(unknown))}"
|
||||
if entry_dict.get("min_app") and parse_version(str(entry_dict["min_app"])) > parse_version(__version__):
|
||||
return f"needs FramePort {entry_dict['min_app']}"
|
||||
base.load_all() # every patch registered (overport ids come from this app's OVRPort list)
|
||||
known = set(base.REGISTRY)
|
||||
ids = [i for key in ("overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "device",
|
||||
"pcvr", "pcvr_remove") for i in (entry_dict.get(key) or [])]
|
||||
ids += [f"adapter.{k}" for k in (entry_dict.get("adapter") or {})]
|
||||
missing = [i for i in ids if isinstance(i, str) and i not in known]
|
||||
return f"unknown patch(es) {', '.join(missing)}" if missing else None
|
||||
|
||||
|
||||
def _remote_index_path() -> Path:
|
||||
return cache.cache_dir() / "catalog-github.json"
|
||||
|
||||
|
||||
def refresh_remote(force: bool = False) -> int:
|
||||
"""Fetch new/changed entries from GitHub main into the cache (one API call for the file list + one raw download
|
||||
per changed file). Returns the number of files downloaded. Never raises (offline: the cache stays)."""
|
||||
import json
|
||||
import time
|
||||
|
||||
if not remote_enabled() or os.environ.get("FRAMEPORT_CATALOG_URL"):
|
||||
return 0
|
||||
idx_path = _remote_index_path()
|
||||
try:
|
||||
old = json.loads(idx_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
old = {}
|
||||
if not force and time.time() - old.get("checked", 0) < REMOTE_EVERY:
|
||||
return 0
|
||||
repo, fetched = _repo(), 0
|
||||
try:
|
||||
tree = cache.http_get(GITHUB_TREE.format(repo=repo, ref="main"), timeout=20).json()
|
||||
files = {t["path"][len(GITHUB_DIR):]: t["sha"] for t in tree.get("tree") or []
|
||||
if t.get("type") == "blob" and t.get("path", "").startswith(GITHUB_DIR)
|
||||
and t["path"].endswith(".yaml") and "/" not in t["path"][len(GITHUB_DIR):]}
|
||||
for name, sha in files.items():
|
||||
dest = cache.cache_dir() / f"catalog-gh-{sha}.yaml"
|
||||
if dest.exists() or not PACKAGE_RE.match(name.removesuffix(".yaml")):
|
||||
continue
|
||||
text = cache.http_get(GITHUB_RAW.format(repo=repo, ref="main", path=GITHUB_DIR + name), timeout=20).text
|
||||
cache.write_atomic(dest, text)
|
||||
fetched += 1
|
||||
for f in cache.cache_dir().glob("catalog-gh-*.yaml"): # files gone from main (or old versions)
|
||||
if f.name.removeprefix("catalog-gh-").removesuffix(".yaml") not in files.values():
|
||||
f.unlink(missing_ok=True)
|
||||
cache.write_atomic(idx_path, json.dumps({"checked": time.time(), "files": files}))
|
||||
except Exception: # noqa: BLE001 - offline / rate limited: keep what we have, try again next time
|
||||
return 0
|
||||
if fetched or files != old.get("files"):
|
||||
load(refresh=True)
|
||||
if not old.get("files"): # the first download (a new install): nothing was "updated"
|
||||
return 0
|
||||
return fetched
|
||||
|
||||
|
||||
def remote_status() -> dict:
|
||||
"""{checked: time or None, entries: n, skipped: {package: reason}} for Settings."""
|
||||
import json
|
||||
|
||||
try:
|
||||
idx = json.loads(_remote_index_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
idx = {}
|
||||
load()
|
||||
return {"checked": idx.get("checked"), "entries": len(idx.get("files") or {}), "skipped": dict(_skipped)}
|
||||
|
||||
|
||||
_skipped: dict[str, str] = {}
|
||||
|
||||
|
||||
def _load_github() -> dict[str, CatalogEntry]:
|
||||
"""Cached entries from GitHub main (no network here)."""
|
||||
import json
|
||||
|
||||
try:
|
||||
files = json.loads(_remote_index_path().read_text(encoding="utf-8")).get("files") or {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
out = {}
|
||||
for sha in files.values():
|
||||
path = cache.cache_dir() / f"catalog-gh-{sha}.yaml"
|
||||
try:
|
||||
d = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError):
|
||||
continue
|
||||
if not isinstance(d, dict) or not d.get("package"):
|
||||
continue
|
||||
reason = unusable_reason(d)
|
||||
if reason:
|
||||
_skipped[d["package"]] = reason
|
||||
continue
|
||||
try:
|
||||
out[d["package"]] = CatalogEntry.from_dict(d, "remote")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _load_remote() -> dict[str, CatalogEntry]:
|
||||
base = os.environ.get("FRAMEPORT_CATALOG_URL")
|
||||
if not base:
|
||||
return {}
|
||||
return _load_github() if remote_enabled() else {}
|
||||
base = base.rstrip("/") + "/"
|
||||
index = cache.cached_json("catalog-index.json", base + "index.json", max_age=6 * 3600, fallback=[])
|
||||
out = {}
|
||||
@@ -109,7 +246,12 @@ def _load_remote() -> dict[str, CatalogEntry]:
|
||||
text = cache.cached_text(f"catalog-{pkg}.yaml", f"{base}{pkg}.yaml", max_age=6 * 3600)
|
||||
if text:
|
||||
try:
|
||||
out[pkg] = CatalogEntry.from_dict(yaml.safe_load(text), "remote")
|
||||
d = yaml.safe_load(text)
|
||||
reason = unusable_reason(d)
|
||||
if reason:
|
||||
_skipped[pkg] = reason
|
||||
continue
|
||||
out[pkg] = CatalogEntry.from_dict(d, "remote")
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
@@ -122,7 +264,11 @@ def load(refresh: bool = False) -> dict[str, CatalogEntry]:
|
||||
global _cache
|
||||
if _cache is None or refresh:
|
||||
entries = _load_dir(catalog_dir() / "games", "bundled")
|
||||
entries.update(_load_remote())
|
||||
_skipped.clear()
|
||||
for pkg, e in _load_remote().items():
|
||||
# main is normally ahead of the bundled copy; a source checkout or dev build may be ahead of main
|
||||
if pkg not in entries or _when(e) >= _when(entries[pkg]):
|
||||
entries[pkg] = e
|
||||
for pkg, e in _load_dir(user_catalog_dir(), "user").items():
|
||||
if not _newer(entries.get(pkg), e): # the user's own recipe, unless a maintained one was verified later
|
||||
entries[pkg] = e
|
||||
@@ -130,6 +276,10 @@ def load(refresh: bool = False) -> dict[str, CatalogEntry]:
|
||||
return _cache
|
||||
|
||||
|
||||
def _when(e: CatalogEntry) -> str:
|
||||
return max(str(e.updated or ""), str(e.verified.get("date") or ""))
|
||||
|
||||
|
||||
def _newer(maintained: CatalogEntry | None, user: CatalogEntry) -> bool:
|
||||
"""True when the bundled/remote entry was verified after the user's (e.g. a fix for a config the user shared)."""
|
||||
if maintained is None:
|
||||
|
||||
+20
-1
@@ -127,6 +127,7 @@ class FramePortApp:
|
||||
threading.Thread(target=self._poll, daemon=True).start()
|
||||
threading.Thread(target=self._backfill_covers, daemon=True).start()
|
||||
self.updater.start()
|
||||
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
|
||||
|
||||
# ================================================================== shell
|
||||
def _on_window_event(self, e) -> None:
|
||||
@@ -509,7 +510,9 @@ class FramePortApp:
|
||||
lambda e: self.share_config_dialog(job.package)))
|
||||
if summary and summary.get("suggestions") and job.package:
|
||||
sugg = summary["suggestions"]
|
||||
out.append(C.primary(tr("Apply the suggested patches and reinstall"), ft.Icons.HEALING_ROUNDED,
|
||||
label = (tr("Try Proton Experimental and reinstall") if sugg == [pipeline.PROTON_TOOL]
|
||||
else tr("Apply the suggested patches and reinstall"))
|
||||
out.append(C.primary(label, ft.Icons.HEALING_ROUNDED,
|
||||
lambda e: (pipeline.apply_suggestions(job.package, sugg),
|
||||
self.install(job.package, getattr(job, "to", "frame")))))
|
||||
if job.package and job.state != "running" and library.game(job.package):
|
||||
@@ -1462,6 +1465,20 @@ class FramePortApp:
|
||||
return tr("{title}: analyzed again").format(title=self._title(pkg))
|
||||
self.submit(tr("Analyze {title} again").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
def _refresh_catalog(self, force: bool = False) -> int:
|
||||
"""New/changed confirmed configs from the repo's main branch; games following the catalog get them (their
|
||||
page then offers Update on Frame). Quiet unless something changed or `force` (Settings → Check now)."""
|
||||
from ..recommend import catalog
|
||||
|
||||
n = catalog.refresh_remote(force=force)
|
||||
if n:
|
||||
library.load() # re-derives recipes whose catalog entry changed
|
||||
self.refresh_view()
|
||||
self.toast(tr_n("{n} game config was updated", "{n} game configs were updated", n))
|
||||
elif force:
|
||||
self.toast(tr("Game configs are up to date"))
|
||||
return n
|
||||
|
||||
def frame_power(self, action: str, force: bool = False) -> None:
|
||||
"""Sleep / restart / shut down the Frame (agent `power`, run a few seconds later so the answer arrives)."""
|
||||
heading = {"sleep": tr("Put the Frame to sleep?"), "restart": tr("Restart the Frame?"),
|
||||
@@ -1746,6 +1763,8 @@ class FramePortApp:
|
||||
self.frame_info = None
|
||||
# reachable, but it doesn't let FramePort in: the Frame page points at the first-time setup
|
||||
self._not_paired = isinstance(exc, FrameNotPaired)
|
||||
if not isinstance(exc, (FrameNotPaired, OSError)): # unexpected: keep the traceback for reports
|
||||
applog.log.exception("connect to %s failed", target.label)
|
||||
if not quiet:
|
||||
self.toast(tr("Couldn't connect: {exc}").format(exc=explain(exc)), error=True)
|
||||
if self.route[0] in ("frame", "library", "game", "welcome"):
|
||||
|
||||
@@ -399,6 +399,56 @@ def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
|
||||
return out
|
||||
|
||||
|
||||
def menu_targets(clicked, selected) -> list:
|
||||
"""What a right-click acts on: the whole selection when the clicked item is part of a selection of several (as in
|
||||
a file manager), else just that item. `selected` keeps its order."""
|
||||
selected = list(selected)
|
||||
return selected if clicked in selected and len(selected) > 1 else [clicked]
|
||||
|
||||
|
||||
class DragSelect:
|
||||
"""Click-and-drag multi-select ("paint" selection) for a grid or list, without knowing where items are on screen.
|
||||
|
||||
The area's GestureDetector calls start() on pan start and end() on pan end; each item calls hover(key, inside)
|
||||
from its hover (enter/exit) event, which Flutter also sends while the mouse button is held. A drag selects every
|
||||
item it passes over, or deselects them when it started on a selected item. No Flet in here (tested directly)."""
|
||||
|
||||
def __init__(self, is_selected, set_selected, can_select=lambda key: True, on_start=None):
|
||||
self.is_selected, self.set_selected, self.can_select = is_selected, set_selected, can_select
|
||||
self.on_start = on_start
|
||||
self.under = None # the item the pointer is over
|
||||
self.active = False
|
||||
self.mode = True # select (True) or deselect (False) during this drag
|
||||
self.touched: set = set()
|
||||
|
||||
def hover(self, key, inside: bool) -> None:
|
||||
if inside:
|
||||
self.under = key
|
||||
if self.active:
|
||||
self._apply(key)
|
||||
elif self.under == key:
|
||||
self.under = None
|
||||
|
||||
def start(self, e=None) -> None:
|
||||
if self.on_start:
|
||||
self.on_start()
|
||||
under = self.under if self.under is not None and self.can_select(self.under) else None
|
||||
self.mode = not self.is_selected(under) if under is not None else True
|
||||
self.active, self.touched = True, set()
|
||||
if under is not None:
|
||||
self._apply(under)
|
||||
|
||||
def end(self, e=None) -> None:
|
||||
self.active = False
|
||||
|
||||
def _apply(self, key) -> None:
|
||||
if key in self.touched or not self.can_select(key):
|
||||
return
|
||||
self.touched.add(key)
|
||||
if self.is_selected(key) != self.mode:
|
||||
self.set_selected(key, self.mode)
|
||||
|
||||
|
||||
def is_media_player(g: dict) -> bool:
|
||||
"""A video player (360° layers in its recipe or analysis, e.g. 4XVR): its page offers "Add videos" up front."""
|
||||
patches = ((g.get("recipe") or {}).get("patches") or {})
|
||||
|
||||
@@ -124,6 +124,48 @@ class Updater:
|
||||
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
|
||||
|
||||
# ---------------------------------------------------------------- dev builds
|
||||
def install_dev(self) -> None:
|
||||
"""Settings → "Install the latest dev build": the rolling `dev` pre-release the maintainer publishes for
|
||||
testing a fix before a release (CI "Run workflow", dev build)."""
|
||||
def work():
|
||||
up = updates.check_dev()
|
||||
if not up:
|
||||
self.app.toast(tr("No dev build is published right now"), error=True)
|
||||
return
|
||||
if up.version == __version__:
|
||||
self.app.toast(tr("You already have the latest dev build ({version})").format(version=up.version))
|
||||
return
|
||||
self.app.page.run_thread(lambda: self._confirm_dev(up))
|
||||
self.app.run_bg(work)
|
||||
|
||||
def _confirm_dev(self, up: updates.Update) -> None:
|
||||
page = self.app.page
|
||||
notes = up.notes.strip() or tr("No notes.")
|
||||
|
||||
def go(e):
|
||||
page.pop_dialog()
|
||||
self.found = up
|
||||
self.install()
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Install dev build {version}?").format(version=up.version), color=T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2,
|
||||
content=ft.Container(ft.Column([
|
||||
C.callout(C.body(tr("Dev builds let you test fixes before they're released. They're less tested "
|
||||
"than releases and may have bugs. You get the next release as a normal update "
|
||||
"(you have {version}).").format(version=__version__), T.TEXT), "warn"),
|
||||
ft.Container(ft.Markdown(notes, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
md_style_sheet=_notes_style(),
|
||||
on_tap_link=lambda e: page.launch_url(e.data)),
|
||||
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE,
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
width=T.px(560), height=T.px(min(440, 190 + 26 * len(notes.splitlines())))),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
|
||||
C.primary(tr("Install dev build"), ft.Icons.SCIENCE_OUTLINED, go)]))
|
||||
|
||||
# ---------------------------------------------------------------- installing
|
||||
def install(self) -> None:
|
||||
up = self.found or updates.ready_update_info()
|
||||
|
||||
@@ -107,6 +107,15 @@ class FilesView:
|
||||
C.meta("")], horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True),
|
||||
left=0, right=0, top=0, bottom=0, alignment=ft.Alignment.CENTER, bgcolor=T.soft("#000000", 0.7),
|
||||
border_radius=T.RADIUS, border=ft.Border.all(2, T.ACCENT), visible=False)
|
||||
# click and drag across rows to select them (C.DragSelect)
|
||||
self.drag = C.DragSelect(lambda p: p in self.selected, self._toggle,
|
||||
can_select=lambda p: any(x.path == p and self._selectable(x) for x in self.entries))
|
||||
# one right-click menu for the listing: filled with the clicked entry's actions (or the folder's) when it opens
|
||||
self.menu = ft.ContextMenu(content=ft.GestureDetector(
|
||||
content=C.card(self.listing, padding=T.px(4), expand=True), expand=True,
|
||||
on_secondary_tap_down=lambda e: self.open_menu(None, e.global_position),
|
||||
on_pan_start=self.drag.start, on_pan_end=self.drag.end),
|
||||
secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
self.root = None
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
@@ -130,9 +139,10 @@ class FilesView:
|
||||
self.toolbar,
|
||||
ft.Row([self.select_all, ft.Container(self.where, expand=True), self.hidden_switch],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self._drop_area(ft.Stack([self.menu, self.drop_hint], expand=True)),
|
||||
# below the list (as in the Library): above it, its appearing pushed the rows down in the
|
||||
# middle of a drag-select
|
||||
self.sel_bar,
|
||||
self._drop_area(ft.Stack([C.card(self.listing, padding=T.px(4), expand=True),
|
||||
self.drop_hint], expand=True)),
|
||||
self.status,
|
||||
], spacing=T.S2, expand=True),
|
||||
], spacing=T.S4, expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
@@ -304,7 +314,7 @@ class FilesView:
|
||||
info = (("folder" if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "")
|
||||
+ (f" · {when}" if when else ""))
|
||||
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC"),
|
||||
lambda ev, x=e: self.download([x]))]
|
||||
lambda ev, x=e: self._download_later([x]))]
|
||||
if not self._protected(e):
|
||||
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, tr("Rename"),
|
||||
lambda ev, x=e: self.rename(x)),
|
||||
@@ -313,14 +323,79 @@ class FilesView:
|
||||
on_change=lambda ev, p=e.path: self._toggle(p, ev.control.value),
|
||||
disabled=self._protected(e))
|
||||
self.checks[e.path] = check
|
||||
return ft.Container(
|
||||
row = ft.Container(
|
||||
ft.Row([check,
|
||||
ft.Icon(file_icon(e.name, e.is_dir), size=T.px(20), color=T.ACCENT if e.is_dir else T.TEXT_2),
|
||||
ft.Column([C.body(e.name, T.TEXT, weight=ft.FontWeight.W_500, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS), C.meta(info)], spacing=0, expand=True),
|
||||
*actions], spacing=T.S3),
|
||||
padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=e.is_dir,
|
||||
on_click=(lambda ev, p=e.path: self.cd(p)) if e.is_dir else None)
|
||||
on_click=(lambda ev, p=e.path: self.cd(p)) if e.is_dir else None,
|
||||
on_hover=lambda ev, p=e.path: self.drag.hover(p, ev.data in (True, "true")))
|
||||
return ft.GestureDetector(content=row, on_secondary_tap_down=lambda ev, x=e: self.open_menu(x,
|
||||
ev.global_position))
|
||||
|
||||
# ---------------------------------------------------------------- right-click menu
|
||||
def menu_actions(self, e) -> list[tuple | None]:
|
||||
"""[(label, icon, handler) | None] for a right-click on entry `e` (None = on the folder's empty space). With
|
||||
several entries selected and `e` among them, the actions act on the whole selection."""
|
||||
if e is None:
|
||||
return [(tr("Upload files…"), ft.Icons.UPLOAD_FILE_ROUNDED, self.upload_files),
|
||||
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_ROUNDED, self.upload_folder),
|
||||
(tr("New folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda ev: self.new_folder()),
|
||||
None,
|
||||
*([(tr("Select all"), ft.Icons.SELECT_ALL_ROUNDED, lambda ev: self._select_all())]
|
||||
if any(self._selectable(x) for x in self.entries) else []),
|
||||
(tr("Copy folder path"), ft.Icons.CONTENT_COPY_ROUNDED,
|
||||
lambda ev: self.app.copy(self._shown_path(self.path))),
|
||||
(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda ev: self.load())]
|
||||
by_path = {x.path: x for x in self.entries}
|
||||
items = [by_path[p] for p in C.menu_targets(e.path, [x.path for x in self.entries if x.path in self.selected])]
|
||||
several = len(items) > 1
|
||||
deletable = [x for x in items if not self._protected(x)]
|
||||
out: list[tuple | None] = []
|
||||
if not several and e.is_dir:
|
||||
out.append((tr("Open"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda ev: self.cd(e.path)))
|
||||
out.append((tr("Download {n} items to this PC…").format(n=len(items)) if several
|
||||
else tr("Download to this PC…"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda ev: self._download_later(items)))
|
||||
if not several and not self._protected(e):
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, lambda ev: self.rename(e)))
|
||||
if not several:
|
||||
out.append((tr("Copy path"), ft.Icons.CONTENT_COPY_ROUNDED, lambda ev: self.app.copy(self._shown_path(
|
||||
e.path))))
|
||||
out.append(None)
|
||||
if several:
|
||||
out.append((tr("Clear selection"), ft.Icons.CLOSE_ROUNDED, lambda ev: self._clear_selection()))
|
||||
elif self._selectable(e):
|
||||
on = e.path in self.selected
|
||||
out.append((tr("Deselect") if on else tr("Select"),
|
||||
ft.Icons.CHECK_BOX_OUTLINE_BLANK_ROUNDED if on else ft.Icons.CHECK_BOX_OUTLINED,
|
||||
lambda ev: self._toggle(e.path, not on)))
|
||||
if deletable:
|
||||
out += [None, (tr("Delete {n} items…").format(n=len(deletable)) if len(deletable) > 1 else tr("Delete…"),
|
||||
ft.Icons.DELETE_OUTLINE_ROUNDED, lambda ev: self.delete(deletable))]
|
||||
return out
|
||||
|
||||
def open_menu(self, e, position=None) -> None:
|
||||
if not self.loc:
|
||||
return
|
||||
self.menu.items = C.menu_items(self.menu_actions(e))
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
|
||||
def _shown_path(self, path: str) -> str:
|
||||
"""The path as games see it (/sdcard/…) where the location has one, else the Frame's own path."""
|
||||
android = (self.loc or {}).get("android")
|
||||
if not android:
|
||||
return path
|
||||
rel = posixpath.relpath(path, self.loc["path"])
|
||||
return android if rel == "." else posixpath.join(android, rel)
|
||||
|
||||
def _download_later(self, items: list) -> None:
|
||||
"""Start the (async: it asks for a folder) download from a plain click handler: Flet only awaits handlers
|
||||
that are coroutine functions themselves, not lambdas returning a coroutine."""
|
||||
self.app.page.run_task(self.download, items)
|
||||
|
||||
def cd(self, path: str) -> None:
|
||||
self.path = path
|
||||
@@ -343,6 +418,10 @@ class FilesView:
|
||||
self.selected.clear()
|
||||
self._render_listing()
|
||||
|
||||
def _select_all(self) -> None:
|
||||
self.selected = {x.path for x in self.entries if self._selectable(x)}
|
||||
self._render_listing()
|
||||
|
||||
def _selectable(self, e) -> bool:
|
||||
return not self._protected(e)
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class FrameView:
|
||||
|
||||
pair_box = ft.Column(spacing=T.S3)
|
||||
|
||||
def pair(e):
|
||||
def pair(e, host: str = ""):
|
||||
from ...frame.connection import FrameTarget
|
||||
from ...frame.pairing import PairingServer
|
||||
|
||||
@@ -190,7 +190,7 @@ class FrameView:
|
||||
|
||||
def on_paired(info):
|
||||
app.connect(FrameTarget(info["host"], info["user"], 22, info["name"]))
|
||||
server = app.pairing = PairingServer(on_paired=on_paired).start()
|
||||
server = app.pairing = PairingServer(on_paired=on_paired, host=host).start()
|
||||
show_command()
|
||||
|
||||
def check_network():
|
||||
@@ -210,12 +210,92 @@ class FrameView:
|
||||
show_command()
|
||||
app.run_bg(check_network)
|
||||
|
||||
def usb_explain(e):
|
||||
"""The Frame turns its USB network on only in Developer Mode: say how first (Valve's labels on the current
|
||||
Frame OS: Settings → System → Enable Developer Mode), then watch for the cable."""
|
||||
def close(): # this dialog itself: pop_dialog() closes the topmost overlay, which may be a toast
|
||||
dlg.open = False
|
||||
C.update(dlg)
|
||||
|
||||
def go(ev):
|
||||
close()
|
||||
usb_setup(ev)
|
||||
steps = [
|
||||
tr("On the Frame, open Steam's Settings (Steam button → Settings)."),
|
||||
tr("Go to System and turn on \"Enable Developer Mode\". A Developer page appears in Settings; nothing "
|
||||
"else changes, your games and data stay as they are."),
|
||||
tr("Connect the Frame's USB-C port to this computer with a USB cable (a data cable, not a charge-only "
|
||||
"one)."),
|
||||
]
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text(tr("Turn on Developer Mode first"), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
C.body(tr("The Frame only offers its USB connection in Developer Mode:"), T.TEXT),
|
||||
*[ft.Row([ft.Container(ft.Text(str(i), weight=ft.FontWeight.W_700, color=T.ACCENT),
|
||||
width=T.px(24), height=T.px(24), border_radius=T.px(12),
|
||||
bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
C.body(text, T.TEXT_2, expand=True)], spacing=T.S3,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
for i, text in enumerate(steps, 1)],
|
||||
C.meta(tr("FramePort then finds the Frame on the cable by itself.")),
|
||||
], spacing=T.S3, tight=True), width=T.px(480)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda ev: close()),
|
||||
C.primary(tr("Done: look for the cable"), ft.Icons.USB_ROUNDED, go)])
|
||||
app.page.show_dialog(dlg)
|
||||
|
||||
def usb_setup(e):
|
||||
"""Setup over a USB-C cable: no Wi-Fi, router, discovery or network firewall involved (the Frame's USB
|
||||
network gives this PC the fixed address 10.86.200.234). The Frame enables its USB network only in
|
||||
Developer Mode, so that comes first."""
|
||||
from ...frame import usb
|
||||
from ...frame.connection import FrameTarget, server_key
|
||||
|
||||
status = ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
|
||||
C.meta(tr("Waiting for the cable…"))], spacing=T.S2)
|
||||
pair_box.controls = [
|
||||
C.body(tr("Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this "
|
||||
"computer)."), T.TEXT),
|
||||
status,
|
||||
]
|
||||
C.update(pair_box)
|
||||
token = object()
|
||||
app._usb_setup = token
|
||||
|
||||
def watch():
|
||||
for _ in range(600): # up to 20 minutes
|
||||
if app._usb_setup is not token or app.route[0] != "frame":
|
||||
return
|
||||
if usb.PC_USB_IP in usb.pc_usb_addresses(usb.FRAME_USB_IP) and server_key(usb.FRAME_USB_IP,
|
||||
timeout=3):
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
return
|
||||
status.controls = [ft.Icon(ft.Icons.USB_ROUNDED, size=T.px(16), color=T.OK),
|
||||
C.meta(tr("Cable connected."), T.OK)]
|
||||
C.update(status)
|
||||
target = FrameTarget(usb.FRAME_USB_IP, "steamos", 22)
|
||||
try: # already set up for FramePort: just connect over the cable
|
||||
from ...frame.connection import Frame
|
||||
|
||||
Frame(target, None).connect(timeout=10).close()
|
||||
app.connect(target)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - not set up yet: show the setup command for the cable
|
||||
pass
|
||||
app.page.run_thread(lambda: pair(None, host=usb.PC_USB_IP))
|
||||
app.run_bg(watch)
|
||||
|
||||
def show_command(update=True):
|
||||
"""The setup command of the running pairing server: also when the page is redrawn (connection checks,
|
||||
discovery), which used to close it."""
|
||||
line = app.pairing.one_liner
|
||||
over_usb = bool(getattr(app.pairing, "host", ""))
|
||||
pair_box.controls = [
|
||||
C.body(tr("On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run (it reaches this computer over the USB cable):") if over_usb else
|
||||
tr("On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run:"), T.TEXT),
|
||||
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=T.px(12),
|
||||
color=T.TEXT, expand=True),
|
||||
@@ -240,7 +320,7 @@ class FrameView:
|
||||
"there and approve FramePort. That way needs no connection into this computer.")),
|
||||
], spacing=T.S2), "warn"))
|
||||
if update:
|
||||
pair_box.update()
|
||||
C.update(pair_box) # (a redraw of the page may have replaced it)
|
||||
|
||||
if app.pairing and app.pairing.running:
|
||||
show_command(update=False)
|
||||
@@ -288,7 +368,13 @@ class FrameView:
|
||||
*([offline] if offline else []),
|
||||
step(1, tr("First-time setup"), tr("New Frame? Run one command on it and FramePort does the rest. Did this "
|
||||
"once already? Your Frame appears under step 2."),
|
||||
C.primary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
|
||||
ft.Row([C.primary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair),
|
||||
C.meta(tr("OR"), weight=ft.FontWeight.W_600),
|
||||
C.secondary(tr("Set up with a USB cable"), ft.Icons.USB_ROUNDED, usb_explain,
|
||||
tooltip=tr("No Wi-Fi needed: for networks that block the setup, and faster "
|
||||
"game uploads"))], wrap=True, spacing=T.S3,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
pair_box,
|
||||
help="first_time_setup"),
|
||||
step(2, tr("Already set up: on your network"), tr("Frames in Developer Mode show up here."), found,
|
||||
help="developer_mode"),
|
||||
|
||||
@@ -531,6 +531,18 @@ class GameView:
|
||||
save(state["recipe"])
|
||||
return handler
|
||||
|
||||
def choose(pid, key):
|
||||
def handler(e):
|
||||
r = state["recipe"]
|
||||
value = e.control.value or ""
|
||||
if value: # a non-default choice is stored as the patch's parameter
|
||||
r.patches[pid] = {key: value}
|
||||
r.reasons[pid] = tr("Chosen by you.")
|
||||
else:
|
||||
r.patches.pop(pid, None)
|
||||
save(r)
|
||||
return handler
|
||||
|
||||
def set_param(pid, key):
|
||||
def handler(e):
|
||||
r = state["recipe"]
|
||||
@@ -571,6 +583,22 @@ class GameView:
|
||||
plain_reason(reason, p.default_on)
|
||||
sub.insert(0, C.meta(shown, T.ACCENT))
|
||||
extra = None
|
||||
choices = getattr(p, "CHOICES", None)
|
||||
if choices: # a plain choice instead of a switch + text field (e.g. Proton: Experimental / Stable)
|
||||
current = recipe.params(p.id).get(p.params[0].key, "") if on else ""
|
||||
keys = [k for k, _label in choices]
|
||||
if current not in keys: # a tool name typed under technical details
|
||||
choices = (*choices, (current, current))
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500), *sub],
|
||||
spacing=T.px(3), expand=True),
|
||||
ft.Dropdown(value=current, width=T.px(280), dense=True, border_color=T.BORDER,
|
||||
text_size=T.T_BODY,
|
||||
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
|
||||
on_select=choose(p.id, p.params[0].key)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.px(10), T.S4, T.px(10)), border=ft.Border(
|
||||
top=ft.BorderSide(1, T.BORDER))))
|
||||
continue
|
||||
if not technical:
|
||||
pass
|
||||
elif cat == "adapter":
|
||||
|
||||
@@ -203,7 +203,14 @@ class LibraryView:
|
||||
self.filters = ft.Container()
|
||||
self.hint = ft.Container(visible=False)
|
||||
# one right-click menu for every card: filled with that game's actions when it opens (open_menu)
|
||||
self.menu = ft.ContextMenu(content=self.grid, secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
# click and drag across cards to select them (turns on select mode; C.DragSelect)
|
||||
self.drag = C.DragSelect(lambda pkg: pkg in self.selected,
|
||||
lambda pkg, on: self.toggle_selected(pkg) if (pkg in self.selected) != on else None,
|
||||
can_select=lambda pkg: pkg in self.cards,
|
||||
on_start=lambda: None if self.select_mode else self.set_select_mode(True))
|
||||
self.menu = ft.ContextMenu(content=ft.GestureDetector(content=self.grid, expand=True,
|
||||
on_pan_start=self.drag.start, on_pan_end=self.drag.end),
|
||||
secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
self.body = ft.Container(self.menu, expand=True)
|
||||
add = ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.ADD_ROUNDED, color=T.ON_ACCENT, size=T.px(18)),
|
||||
@@ -594,11 +601,13 @@ class LibraryView:
|
||||
border_radius=T.RADIUS, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER), expand=True,
|
||||
scale=1.0, animate_scale=ft.Animation(140, ft.AnimationCurve.EASE_OUT),
|
||||
shadow=card_shadow(),
|
||||
tooltip=ft.Tooltip(message=tr("Click to open · right-click for quick actions"), wait_duration=1500),
|
||||
tooltip=ft.Tooltip(message=tr("Click to open · right-click for quick actions · drag across cards to "
|
||||
"select several"), wait_duration=1500),
|
||||
on_click=lambda e: self.toggle_selected(pkg) if self.select_mode else app.open_game(pkg))
|
||||
|
||||
def hover(e):
|
||||
on = e.data in (True, "true")
|
||||
self.drag.hover(pkg, on)
|
||||
tile.scale = 1.03 if on else 1.0
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
tile.shadow = card_shadow(on)
|
||||
|
||||
@@ -92,6 +92,14 @@ class ScreenshotsView:
|
||||
bgcolor=T.ACCENT_SOFT, visible=False)
|
||||
self.grid = ft.Column(spacing=T.S3, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.status = C.meta("")
|
||||
# one right-click menu for the grid: filled with the clicked screenshot's actions (or the tab's) when it opens
|
||||
# click and drag across screenshots to select them (C.DragSelect)
|
||||
self.drag = C.DragSelect(lambda p: p in self.selected, self._toggle)
|
||||
self.menu = ft.ContextMenu(content=ft.GestureDetector(
|
||||
content=C.card(self.grid, padding=T.S3, expand=True), expand=True,
|
||||
on_secondary_tap_down=lambda e: self.open_menu(None, e.global_position),
|
||||
on_pan_start=self.drag.start, on_pan_end=self.drag.end),
|
||||
secondary_trigger=None, tertiary_trigger=None, expand=True)
|
||||
self.root = None
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
@@ -106,8 +114,8 @@ class ScreenshotsView:
|
||||
tr("Screenshots on the Frame can be shown once FramePort is connected to it."),
|
||||
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
|
||||
if self.root is None:
|
||||
self.root = ft.Column([app.top_bar(heading, sub), self.toolbar, self.sel_bar,
|
||||
C.card(self.grid, padding=T.S3, expand=True), self.status],
|
||||
# the selection bar below the grid (as in the Library): above it, it pushed the cards down mid-drag
|
||||
self.root = ft.Column([app.top_bar(heading, sub), self.toolbar, self.menu, self.sel_bar, self.status],
|
||||
spacing=T.S3, expand=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
if package is not None and filter_key(package) != self.filter:
|
||||
self.filter = filter_key(package)
|
||||
@@ -216,13 +224,70 @@ class ScreenshotsView:
|
||||
end=ft.Alignment.BOTTOM_CENTER,
|
||||
colors=[ft.Colors.TRANSPARENT, T.soft("#000000", 0.8)]),
|
||||
border_radius=ft.BorderRadius(0, 0, T.RADIUS_SM, T.RADIUS_SM))
|
||||
return ft.Container(
|
||||
tile = ft.Container(
|
||||
ft.Stack([C.art_fill(self._thumb_url(s), radius=T.RADIUS_SM, placeholder_icon=ft.Icons.IMAGE_OUTLINED,
|
||||
left=0, right=0, top=0, bottom=0),
|
||||
caption, ft.Container(check, left=0, top=0)]),
|
||||
width=T.px(256), height=T.px(144), border_radius=T.RADIUS_SM, ink=True,
|
||||
tooltip=f"{s.get('title') or ''} · {day_label(s.get('time'))} {when}",
|
||||
on_click=lambda e, i=i: self.viewer(i))
|
||||
on_click=lambda e, i=i: self.viewer(i),
|
||||
on_hover=lambda e, p=s["path"]: self.drag.hover(p, e.data in (True, "true")))
|
||||
return ft.GestureDetector(content=tile, on_secondary_tap_down=lambda e, s=s: self.open_menu(s,
|
||||
e.global_position))
|
||||
|
||||
# ---------------------------------------------------------------- right-click menu
|
||||
def menu_actions(self, s: dict | None) -> list[tuple | None]:
|
||||
"""[(label, icon, handler) | None] for a right-click on screenshot `s` (None = on the grid's empty space). With
|
||||
several screenshots selected and `s` among them, the actions act on the whole selection."""
|
||||
if s is None:
|
||||
if not self.shots:
|
||||
return [(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: self.load())]
|
||||
return [(tr("Download all ({n})…").format(n=len(self.shots)), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: self._later(self.download, list(self.shots))),
|
||||
(tr("Select all"), ft.Icons.SELECT_ALL_ROUNDED, lambda e: self._select_all()),
|
||||
*([(tr("Clear selection"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection())]
|
||||
if self.selected else []),
|
||||
None, (tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: self.load())]
|
||||
by_path = {x["path"]: x for x in self.shots}
|
||||
shots = [by_path[p] for p in C.menu_targets(s["path"], [x["path"] for x in self.shots
|
||||
if x["path"] in self.selected])]
|
||||
several = len(shots) > 1
|
||||
out: list[tuple | None] = []
|
||||
if not several:
|
||||
out.append((tr("View"), ft.Icons.OPEN_IN_FULL_ROUNDED, lambda e: self.viewer(self.shots.index(s))))
|
||||
out.append((tr("Download {n} screenshots…").format(n=len(shots)) if several else tr("Download…"),
|
||||
ft.Icons.DOWNLOAD_ROUNDED, lambda e: self._later(self.download, shots)))
|
||||
key = filter_key(s.get("package") or "")
|
||||
if not several and self.filter == ALL and key in {o.key for o in self.dropdown.options}:
|
||||
out.append((tr("Show only this game's screenshots") if s.get("package")
|
||||
else tr("Show only screenshots not from a FramePort game"),
|
||||
ft.Icons.FILTER_ALT_OUTLINED, lambda e: self._filter_to(key)))
|
||||
out.append(None)
|
||||
if several:
|
||||
out.append((tr("Clear selection"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection()))
|
||||
else:
|
||||
on = s["path"] in self.selected
|
||||
out.append((tr("Deselect") if on else tr("Select"),
|
||||
ft.Icons.CHECK_BOX_OUTLINE_BLANK_ROUNDED if on else ft.Icons.CHECK_BOX_OUTLINED,
|
||||
lambda e: self._toggle(s["path"], not on)))
|
||||
out += [None, (tr("Delete {n} screenshots…").format(n=len(shots)) if several else tr("Delete…"),
|
||||
ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self.delete(shots))]
|
||||
return out
|
||||
|
||||
def open_menu(self, s: dict | None, position=None) -> None:
|
||||
self.menu.items = C.menu_items(self.menu_actions(s))
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
|
||||
def _later(self, coro_fn, *args) -> None:
|
||||
"""Run an async action (it asks for a folder) from a plain click handler: Flet only awaits handlers that
|
||||
are coroutine functions themselves."""
|
||||
self.app.page.run_task(coro_fn, *args)
|
||||
|
||||
def _filter_to(self, key: str) -> None:
|
||||
self.dropdown.value = key
|
||||
C.update(self.dropdown)
|
||||
self.set_filter(key)
|
||||
|
||||
# ---------------------------------------------------------------- selection
|
||||
def _toggle(self, path: str, on: bool) -> None:
|
||||
@@ -242,6 +307,14 @@ class ScreenshotsView:
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
def _select_all(self) -> None:
|
||||
self.selected = {s["path"] for s in self.shots}
|
||||
for check in self.checks.values():
|
||||
if not check.value:
|
||||
check.value = True
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
def _clear_selection(self) -> None:
|
||||
self.selected.clear()
|
||||
for check in self.checks.values():
|
||||
|
||||
@@ -8,7 +8,7 @@ import flet as ft
|
||||
from ... import REPO_URL, __version__, i18n, pipeline
|
||||
from ...core.paths import user_data_dir
|
||||
from ...errors import explain
|
||||
from ...i18n import fmt_size, tr
|
||||
from ...i18n import fmt_size, tr, tr_n
|
||||
from ...recommend import catalog
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
@@ -133,8 +133,38 @@ class SettingsView:
|
||||
C.switch(tr("Install updates automatically (downloads in the background, installs when FramePort "
|
||||
"next starts)"), value=bool(library.setting("update.auto_install", False)),
|
||||
on_change=auto_install),
|
||||
ft.Row([C.meta(tr("Asked to test a fix? Dev builds come before the next release."), expand=True),
|
||||
C.ghost(tr("Install the latest dev build…"), ft.Icons.SCIENCE_OUTLINED,
|
||||
lambda e: app.updater.install_dev())],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
], spacing=T.S3)
|
||||
|
||||
def catalog_updates(self) -> ft.Control:
|
||||
"""Settings → Data: confirmed game configs from GitHub main (recommend/catalog.refresh_remote)."""
|
||||
import time
|
||||
|
||||
from ...core import library
|
||||
from ...recommend import catalog
|
||||
|
||||
app = self.app
|
||||
st = catalog.remote_status()
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(st["checked"])) if st["checked"] else tr("never")
|
||||
line = tr("Game configs from GitHub: last checked {when}").format(when=when)
|
||||
if st["skipped"]:
|
||||
line += " · " + tr_n("{n} needs a newer FramePort", "{n} need a newer FramePort", len(st["skipped"]))
|
||||
|
||||
def toggle(e):
|
||||
library.set_setting("catalog.auto_update", bool(e.control.value))
|
||||
return ft.Column([
|
||||
ft.Row([C.meta(line, expand=True),
|
||||
C.ghost(tr("Check now"), ft.Icons.SYNC_ROUNDED,
|
||||
lambda e: app.run_bg(lambda: app._refresh_catalog(force=True)))],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
C.switch(tr("Update game configs from GitHub automatically (confirmed configs arrive without a "
|
||||
"FramePort update)"), value=bool(library.setting("catalog.auto_update", True)),
|
||||
on_change=toggle),
|
||||
], spacing=T.S2)
|
||||
|
||||
def appearance(self) -> ft.Control:
|
||||
from ...core import library
|
||||
|
||||
@@ -210,6 +240,7 @@ class SettingsView:
|
||||
lambda e: app.copy(data))]), "data_folder"),
|
||||
C.kv(tr("Catalog"), tr("{len} known-good recipes (bundled, remote and yours)")
|
||||
.format(len=len(catalog.load())), "catalog"),
|
||||
self.catalog_updates(),
|
||||
], spacing=T.S2))),
|
||||
# text above, buttons below (side by side, the buttons squeezed the text in a narrow window)
|
||||
C.section(tr("Problems and feedback"), C.card(ft.Column([
|
||||
|
||||
@@ -38,6 +38,7 @@ _log = logging.getLogger("frameport.updates")
|
||||
|
||||
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
DEV_API = f"https://api.github.com/repos/{REPO}/releases/tags/dev" # the rolling pre-release CI publishes on demand
|
||||
CHECK_EVERY = 6 * 3600 # seconds between automatic checks
|
||||
ASSETS = {"win32": "FramePort-windows-x64.zip", "darwin": "FramePort-macos-arm64.zip",
|
||||
"linux": "FramePort-linux-x64.tar.gz", "linux-arm64": "FramePort-linux-arm64.tar.gz"} # never rename
|
||||
@@ -63,11 +64,15 @@ class Update:
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- versions
|
||||
def parse_version(text: str) -> tuple:
|
||||
"""(major, minor, patch, final) from "v1.2.3" / "1.2.3-rc1"; pre-releases sort before the final release."""
|
||||
"""(major, minor, patch, final, dev) from "v1.2.3" / "1.2.3-rc1" / "1.2.4.dev57": pre-releases and dev builds sort
|
||||
before the final release, dev builds among themselves by build number."""
|
||||
m = re.match(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$", (text or "").strip())
|
||||
if not m:
|
||||
return (0, 0, 0, 0)
|
||||
return (int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0), 0 if m.group(4).strip() else 1)
|
||||
return (0, 0, 0, 0, 0)
|
||||
rest = m.group(4).strip()
|
||||
dev = re.match(r"[.-]?dev(\d+)", rest)
|
||||
return (int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0), 0 if rest else 1,
|
||||
int(dev.group(1)) if dev else 0)
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str = __version__) -> bool:
|
||||
@@ -81,16 +86,24 @@ def platform_asset() -> str | None:
|
||||
return ASSETS.get(key)
|
||||
|
||||
|
||||
def update_from_release(release: dict, asset_name: str | None = None) -> Update | None:
|
||||
"""The Update a GitHub "latest release" JSON describes (None for drafts/pre-releases or unusable data)."""
|
||||
if (not isinstance(release, dict) or release.get("draft") or release.get("prerelease")
|
||||
def update_from_release(release: dict, asset_name: str | None = None, dev: bool = False) -> Update | None:
|
||||
"""The Update a GitHub "latest release" JSON describes (None for drafts/pre-releases or unusable data). dev=True:
|
||||
the rolling `dev` pre-release, whose version comes from its wheel (frameport-0.9.1.dev57-py3-none-any.whl)."""
|
||||
if (not isinstance(release, dict) or release.get("draft") or (release.get("prerelease") and not dev)
|
||||
or not release.get("tag_name")):
|
||||
return None
|
||||
assets = {a.get("name"): a.get("browser_download_url") for a in release.get("assets") or [] if isinstance(a, dict)}
|
||||
asset_name = asset_name if asset_name is not None else platform_asset()
|
||||
wheel = next((u for n, u in assets.items() if n and n.endswith(".whl")), None)
|
||||
wheel_name = next((n for n in assets if n and n.endswith(".whl")), None)
|
||||
wheel = assets.get(wheel_name) if wheel_name else None
|
||||
tag = release["tag_name"]
|
||||
return Update(version=tag.lstrip("v"), tag=tag, notes=release.get("body") or "", page=release.get("html_url") or "",
|
||||
version = tag.lstrip("v")
|
||||
if dev:
|
||||
m = re.match(r"frameport-([^-]+)-", wheel_name or "")
|
||||
if not m:
|
||||
return None
|
||||
version = m.group(1)
|
||||
return Update(version=version, tag=tag, notes=release.get("body") or "", page=release.get("html_url") or "",
|
||||
asset=asset_name if asset_name in assets else None, asset_url=assets.get(asset_name),
|
||||
sums_url=assets.get(SUMS), wheel_url=wheel, published=release.get("published_at") or "")
|
||||
|
||||
@@ -116,6 +129,16 @@ def check(force: bool = False) -> Update | None:
|
||||
return up
|
||||
|
||||
|
||||
def check_dev() -> Update | None:
|
||||
"""The latest dev build (Settings → "Install the latest dev build"), or None when none is published or GitHub
|
||||
can't be reached. Returned even when it isn't newer: the caller says so."""
|
||||
try:
|
||||
data = cache.cached_json("app-dev-release.json", DEV_API, max_age=0)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return update_from_release(data, dev=True) if data else None
|
||||
|
||||
|
||||
def refresh_cache() -> None:
|
||||
"""Fetch the latest release into the cache only (no settings written: safe in a CLI background thread)."""
|
||||
try:
|
||||
|
||||
+91
-6
@@ -103,9 +103,17 @@ def test_cleanup_refuses_outside_paths(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC VR under Proton
|
||||
def fake_steam_tools(a, tmp_path):
|
||||
"""A Steam library with an ARM64 Proton (needing a runtime) installed, like the Frame's."""
|
||||
def fake_steam_tools(a, tmp_path, experimental=False):
|
||||
"""A Steam library with an ARM64 Proton (needing a runtime) installed, like the Frame's; experimental=True also
|
||||
installs Proton Experimental (chosen per game; the default is the stable one)."""
|
||||
apps = tmp_path / ".local/share/Steam/steamapps"
|
||||
if experimental:
|
||||
(apps / "common/Proton - Experimental (ARM64)").mkdir(parents=True)
|
||||
(apps / "common/Proton - Experimental (ARM64)/toolmanifest.vdf").write_text(
|
||||
'"manifest"\n{\n "commandline" "/proton %verb%"\n "require_tool_appid" "4185400"\n}\n')
|
||||
(apps / "appmanifest_4427310.acf").write_text(
|
||||
'"AppState"\n{\n\t"appid"\t\t"4427310"\n\t"name"\t\t"Proton Experimental (ARM64)"\n'
|
||||
'\t"StateFlags"\t\t"4"\n\t"installdir"\t\t"Proton - Experimental (ARM64)"\n}\n')
|
||||
(apps / "common/Proton 11.0 (ARM64)").mkdir(parents=True)
|
||||
(apps / "common/SteamLinuxRuntime_4-arm64").mkdir(parents=True)
|
||||
(apps / "common/Proton 11.0 (ARM64)/toolmanifest.vdf").write_text(
|
||||
@@ -131,6 +139,8 @@ def test_proton_status_and_command(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
apps = fake_steam_tools(a, tmp_path)
|
||||
st = a.cmd_proton_status({})
|
||||
assert st["ready"]["name"] == "proton_11-arm64" and st["suggested"]["name"] == "proton_11-arm64" # stable
|
||||
st = a.cmd_proton_status({"tool": "proton-stable-arm64"})
|
||||
assert st["ready"]["name"] == "proton_11-arm64"
|
||||
assert [t["name"] for t in st["tools"]] == ["proton_11-arm64", "proton-experimental-arm64"] # stable first
|
||||
cmd = a.compat_command(st["ready"]["dir"])
|
||||
@@ -146,7 +156,8 @@ def test_install_proton_request_mode(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(a.subprocess, "Popen", lambda args, **k: calls.append(args))
|
||||
r = a.cmd_install_proton({"tool": "proton-experimental-arm64"})
|
||||
assert r["requested"] == [4427310] and calls == [["steam", "-ifrunning", "steam://install/4427310"]]
|
||||
assert a.cmd_install_proton({})["installed"] is True
|
||||
assert a.cmd_install_proton({"tool": "proton-stable-arm64"})["installed"] is True
|
||||
assert a.cmd_install_proton({})["installed"] is True # the default: the stable one, already installed
|
||||
|
||||
|
||||
def test_stub_manifest(monkeypatch, tmp_path):
|
||||
@@ -161,7 +172,7 @@ def test_stub_manifest(monkeypatch, tmp_path):
|
||||
|
||||
def test_pcvr_install_flow(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
fake_steam_tools(a, tmp_path, experimental=True)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
prep = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
|
||||
inc = Path(prep["incoming"])
|
||||
@@ -179,7 +190,7 @@ def test_pcvr_install_flow(monkeypatch, tmp_path):
|
||||
"xrlayer": {"XR_APILAYER_FRAMEPORT_timefix.json": len(layer_json), "libxr_frameport_timefix.so": 3}}
|
||||
r = a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
|
||||
"manifests": manifests, "env": {"PROTON_LOG": "1", "bad key": "x"}, "xr_layer": True})
|
||||
assert r["ok"] and r["proton"] == "proton_11-arm64"
|
||||
assert r["ok"] and r["proton"] == "proton_11-arm64" # the default: stable, also with Experimental installed
|
||||
launch = Path(prep["anchor"]) / "launch.sh"
|
||||
text = launch.read_text()
|
||||
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
|
||||
@@ -321,7 +332,7 @@ def test_pcvr_launcher_oculus_hmd_helper(monkeypatch, tmp_path):
|
||||
import pytest
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, tmp_path)
|
||||
fake_steam_tools(a, tmp_path, experimental=True)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
|
||||
def install(pkg, helper=True, revive=True, oculus_hmd=True):
|
||||
@@ -1142,6 +1153,54 @@ def test_dashboard_worker_hides_dashboard_after_first_frames(monkeypatch, tmp_pa
|
||||
assert calls.count("SteamClient.OpenVR.VROverlay.HideDashboard()") == 3 # at most three times
|
||||
|
||||
|
||||
def test_dashboard_worker_starts_at_the_first_submitted_frame(monkeypatch, tmp_path):
|
||||
"""Steam's "Resume game" menu opens at the game's first frame (ITR2: 0.3 s after FrameBridge's "new layer:"); the
|
||||
first "pacing:" summary comes ~8 s later, after the player had pressed Resume. The log grows while it waits."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
log = tmp_path / "launch.log"
|
||||
log.write_text("Lepton starting\nI FrameBridge: xrCreateSwapchain 2016x1728\nI FrameBridge: new la")
|
||||
pieces = ["yer: type=35 swapchain=0x0\n"] # the marker split across two reads
|
||||
|
||||
def sleep(s):
|
||||
if pieces:
|
||||
with open(log, "a") as f:
|
||||
f.write(pieces.pop(0))
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
clock = iter(range(0, 10000))
|
||||
monkeypatch.setattr(a.time, "time", lambda: next(clock))
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "steam_js", lambda expr, timeout=5: calls.append(expr) or expr.endswith("Visible()"))
|
||||
a.dashboard_worker(str(log), os.getpid(), wait_start=10, window=4)
|
||||
assert "SteamClient.OpenVR.VROverlay.HideDashboard()" in calls
|
||||
|
||||
|
||||
def test_dashboard_worker_leaves_a_dashboard_the_player_opened(monkeypatch, tmp_path):
|
||||
"""ITR2 (agent 52): the player's controller button opened the dashboard and the worker closed it 60 ms later; the
|
||||
game had paused for it and stayed paused. After a toggle_dashboard_action the worker must stop for good."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
log, ui = tmp_path / "launch.log", tmp_path / "vrwebhelper_systemui.txt"
|
||||
log.write_text("I FrameBridge: new layer: type=35\n")
|
||||
ui.write_text("| [Dashboard] [ToggleDashboard] toggle_dashboard_action (an earlier session)\n")
|
||||
visible = iter([True, False, False, True, True])
|
||||
calls = []
|
||||
|
||||
def js(expr, timeout=5):
|
||||
calls.append(expr)
|
||||
return next(visible, True) if expr.endswith("Visible()") else None
|
||||
monkeypatch.setattr(a, "steam_js", js)
|
||||
steps = iter(range(10000))
|
||||
|
||||
def sleep(s):
|
||||
if next(steps) == 2: # the player presses the dashboard button
|
||||
with open(ui, "a") as f:
|
||||
f.write("| [Dashboard] [ToggleDashboard] toggle_dashboard_action bSourceIsVRLinkRemote false\n")
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
clock = iter(range(0, 10000))
|
||||
monkeypatch.setattr(a.time, "time", lambda: next(clock))
|
||||
a.dashboard_worker(str(log), os.getpid(), wait_start=10, window=50, ui_log=str(ui))
|
||||
assert calls.count("SteamClient.OpenVR.VROverlay.HideDashboard()") == 1 # Steam's start-up menu only
|
||||
|
||||
|
||||
def test_dashboard_worker_waits_for_vr_frames(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
||||
@@ -1220,3 +1279,29 @@ def test_power_schedules_systemctl_in_a_user_timer(monkeypatch, tmp_path):
|
||||
assert a.cmd_power({"action": "sleep", "force": True})["action"] == "sleep"
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_power({"action": "format"})
|
||||
|
||||
|
||||
def test_incomplete_deployment_records_dont_break_listing(monkeypatch, tmp_path):
|
||||
"""GitHub #40: a deployment.json without "base" made every connection fail with KeyError: 'base'."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
good = tmp_path / "Applications/quest-frame/com.x.ok"
|
||||
good.mkdir(parents=True)
|
||||
(good / "deployment.json").write_text(json.dumps({"package": "com.x.ok", "appid": 5})) # no base / title
|
||||
junk = tmp_path / "Applications/quest-frame/other"
|
||||
junk.mkdir(parents=True)
|
||||
(junk / "deployment.json").write_text(json.dumps({"name": "something else"}))
|
||||
games = a.cmd_list_installed({})["games"]
|
||||
assert [g["package"] for g in games] == ["com.x.ok"] and games[0]["base"] == str(good)
|
||||
assert a.deployment("com.x.ok")["base"] == str(good)
|
||||
|
||||
|
||||
def test_proton_defaults_to_stable(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
stable = {"name": "proton_11-arm64", "display_name": "Proton 11.0-2 (ARM64)", "aliases": "proton-stable-arm64,"
|
||||
"proton-stable", "experimental": False, "installed": True, "require_installed": True}
|
||||
exp = {"name": "proton-experimental-arm64", "display_name": "Proton Experimental (ARM64)",
|
||||
"aliases": "proton-experimental", "experimental": True, "installed": False, "require_installed": True}
|
||||
assert a.pick_proton([stable, exp])["name"] == "proton_11-arm64"
|
||||
assert a.pick_proton([exp, stable])["name"] == "proton_11-arm64" # whatever the order
|
||||
assert a.pick_proton([stable, exp], "proton-experimental")["name"] == "proton-experimental-arm64" # by alias
|
||||
assert a.pick_proton([exp])["name"] == "proton-experimental-arm64" # only Experimental offered
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Confirmed game configs from GitHub main (recommend/catalog.refresh_remote): fetched without a release, skipped when
|
||||
they need a newer FramePort."""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.recommend import catalog
|
||||
|
||||
GOOD = ("package: com.x.good\ntitle: Good\nstatus: works\nframe:\n- frame.unity_text_input\n"
|
||||
"verified:\n date: '2099-01-01'\n")
|
||||
FUTURE_PATCH = "package: com.x.future\ntitle: Future\nstatus: works\nframe:\n- frame.not_yet_released\n"
|
||||
FUTURE_FIELD = "package: com.x.field\ntitle: Field\nstatus: works\nlinux_runtime: x\n"
|
||||
FUTURE_APP = "package: com.x.app\ntitle: App\nstatus: works\nmin_app: 99.0.0\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github(monkeypatch):
|
||||
files = {"catalog/games/com.x.good.yaml": ("s1", GOOD), "catalog/games/com.x.future.yaml": ("s2", FUTURE_PATCH),
|
||||
"catalog/games/com.x.field.yaml": ("s3", FUTURE_FIELD), "catalog/games/com.x.app.yaml": ("s4", FUTURE_APP),
|
||||
"catalog/triage.yaml": ("s5", "x: 1\n")}
|
||||
calls = []
|
||||
|
||||
def http_get(url, timeout=20, **kw):
|
||||
calls.append(url)
|
||||
if "git/trees" in url:
|
||||
return SimpleNamespace(json=lambda: {"tree": [{"path": p, "type": "blob", "sha": s}
|
||||
for p, (s, _t) in files.items()]})
|
||||
path = url.split("/main/", 1)[1]
|
||||
return SimpleNamespace(text=files[path][1])
|
||||
monkeypatch.setattr(catalog.cache, "http_get", http_get)
|
||||
monkeypatch.delenv("FRAMEPORT_CATALOG_URL", raising=False)
|
||||
monkeypatch.delenv("FRAMEPORT_NO_CATALOG_UPDATE", raising=False)
|
||||
return files, calls
|
||||
|
||||
|
||||
def test_remote_configs_are_used_and_newer_app_configs_skipped(github):
|
||||
files, calls = github
|
||||
assert catalog.refresh_remote(force=True) == 0 # the first download: nothing "updated" (no toast)
|
||||
entries = catalog.load(refresh=True)
|
||||
assert entries["com.x.good"].origin == "remote"
|
||||
st = catalog.remote_status()
|
||||
assert set(st["skipped"]) == {"com.x.future", "com.x.field", "com.x.app"}
|
||||
assert "frame.not_yet_released" in st["skipped"]["com.x.future"]
|
||||
assert "com.x.future" not in entries and "com.x.app" not in entries
|
||||
# a second check downloads nothing new (only the file list), and only after 6 h unless forced
|
||||
calls.clear()
|
||||
assert catalog.refresh_remote() == 0 and calls == []
|
||||
assert catalog.refresh_remote(force=True) == 0 and len(calls) == 1
|
||||
|
||||
|
||||
def test_changed_and_removed_files(github):
|
||||
files, _ = github
|
||||
catalog.refresh_remote(force=True)
|
||||
files["catalog/games/com.x.good.yaml"] = ("s9", GOOD.replace("status: works", "status: issues"))
|
||||
del files["catalog/games/com.x.app.yaml"]
|
||||
assert catalog.refresh_remote(force=True) == 1
|
||||
assert catalog.load(refresh=True)["com.x.good"].status == "issues"
|
||||
assert not list(catalog.cache.cache_dir().glob("catalog-gh-s4.yaml")) # removed from main: removed here
|
||||
|
||||
|
||||
def test_offline_keeps_the_cache(github, monkeypatch):
|
||||
catalog.refresh_remote(force=True)
|
||||
monkeypatch.setattr(catalog.cache, "http_get", lambda *a, **k: (_ for _ in ()).throw(OSError("offline")))
|
||||
assert catalog.refresh_remote(force=True) == 0
|
||||
assert catalog.load(refresh=True)["com.x.good"].origin == "remote"
|
||||
|
||||
|
||||
def test_turned_off(github, monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_NO_CATALOG_UPDATE", "1")
|
||||
assert catalog.refresh_remote(force=True) == 0
|
||||
assert "com.x.good" not in catalog.load(refresh=True)
|
||||
assert json.dumps(catalog.remote_status()) # still reports (never checked)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Right-click menus in the Files and Screenshots tabs: what they offer, and that a right-click on one of several
|
||||
selected items acts on the whole selection (as in a file manager)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.install.files import Entry
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui.views.files import FilesView
|
||||
from frameport.ui.views.screenshots import ALL, ScreenshotsView
|
||||
|
||||
|
||||
class FakeApp(SimpleNamespace):
|
||||
def __init__(self):
|
||||
super().__init__(copied=[], page=SimpleNamespace(run_task=lambda fn, *a, **k: None))
|
||||
|
||||
def copy(self, text):
|
||||
self.copied.append(text)
|
||||
|
||||
|
||||
def labels(actions):
|
||||
return [a[0] if a else "—" for a in actions]
|
||||
|
||||
|
||||
def test_menu_targets_selection_or_single():
|
||||
assert C.menu_targets("a", ["a", "b"]) == ["a", "b"]
|
||||
assert C.menu_targets("c", ["a", "b"]) == ["c"] # outside the selection: only the clicked item
|
||||
assert C.menu_targets("a", ["a"]) == ["a"]
|
||||
|
||||
|
||||
def files_view():
|
||||
v = FilesView(FakeApp())
|
||||
v.loc = {"id": "videos", "label": "Videos", "path": "/home/x/Videos", "android": "/sdcard/Movies"}
|
||||
v.path = "/home/x/Videos/trips"
|
||||
v.entries = [Entry("clips", "/home/x/Videos/trips/clips", True, 0, 0),
|
||||
Entry("a.mp4", "/home/x/Videos/trips/a.mp4", False, 10, 0),
|
||||
Entry("b.mp4", "/home/x/Videos/trips/b.mp4", False, 20, 0)]
|
||||
return v
|
||||
|
||||
|
||||
def test_files_menu_single_entry_and_folder_space():
|
||||
v = files_view()
|
||||
folder = labels(v.menu_actions(v.entries[0]))
|
||||
assert folder[:4] == ["Open", "Download to this PC…", "Rename…", "Copy path"]
|
||||
assert "Select" in folder and folder[-1] == "Delete…"
|
||||
actions = v.menu_actions(v.entries[1])
|
||||
assert "Open" not in labels(actions)
|
||||
dict((a[0], a[2]) for a in actions if a)["Copy path"](None)
|
||||
assert v.app.copied == ["/sdcard/Movies/trips/a.mp4"] # as games see it
|
||||
space = labels(v.menu_actions(None))
|
||||
assert space[:3] == ["Upload files…", "Upload folder…", "New folder…"] and "Refresh" in space
|
||||
|
||||
|
||||
def test_files_menu_on_a_selection_acts_on_all_of_it():
|
||||
v = files_view()
|
||||
v.selected = {v.entries[1].path, v.entries[2].path}
|
||||
multi = labels(v.menu_actions(v.entries[2]))
|
||||
assert "Download 2 items to this PC…" in multi and "Delete 2 items…" in multi
|
||||
assert "Rename…" not in multi and "Clear selection" in multi
|
||||
assert "Download to this PC…" in labels(v.menu_actions(v.entries[0])) # not in the selection: just that one
|
||||
|
||||
|
||||
def test_files_menu_keeps_lepton_links_safe():
|
||||
v = files_view()
|
||||
v.loc = {"id": "app:x", "label": "Game", "path": "/base/external", "android": "/sdcard"}
|
||||
v.path = "/base/external"
|
||||
link = Entry("Movies", "/base/external/Movies", True, 0, 0, link=True)
|
||||
v.entries = [link]
|
||||
got = labels(v.menu_actions(link))
|
||||
assert "Rename…" not in got and "Delete…" not in got and "Select" not in got
|
||||
|
||||
|
||||
def screenshots_view():
|
||||
v = ScreenshotsView(FakeApp())
|
||||
v.shots = [{"path": f"/s/{i}.jpg", "package": "com.x" if i < 2 else "", "title": "X", "time": 1} for i in range(3)]
|
||||
v._fill_dropdown()
|
||||
return v
|
||||
|
||||
|
||||
def test_screenshots_menu():
|
||||
v = screenshots_view()
|
||||
v.games = [{"package": "com.x", "title": "X", "count": 2}, {"package": "", "count": 1}]
|
||||
v._fill_dropdown()
|
||||
one = labels(v.menu_actions(v.shots[0]))
|
||||
assert one[:3] == ["View", "Download…", "Show only this game's screenshots"] and one[-1] == "Delete…"
|
||||
assert "Show only screenshots not from a FramePort game" in labels(v.menu_actions(v.shots[2]))
|
||||
v.filter = "com.x"
|
||||
assert not any("Show only" in x for x in labels(v.menu_actions(v.shots[0])))
|
||||
v.filter = ALL
|
||||
v.selected = {v.shots[0]["path"], v.shots[1]["path"]}
|
||||
multi = labels(v.menu_actions(v.shots[1]))
|
||||
assert "Download 2 screenshots…" in multi and "Delete 2 screenshots…" in multi and "View" not in multi
|
||||
assert labels(v.menu_actions(None))[:2] == ["Download all (3)…", "Select all"]
|
||||
|
||||
|
||||
def test_drag_select_paints_a_selection():
|
||||
sel = set()
|
||||
d = C.DragSelect(lambda k: k in sel, lambda k, on: (sel.add if on else sel.discard)(k),
|
||||
can_select=lambda k: k != "locked")
|
||||
d.hover("a", True) # just hovering: nothing happens
|
||||
assert sel == set()
|
||||
d.start() # press on "a" and drag
|
||||
for k in ("b", "locked", "c"):
|
||||
d.hover(k, True)
|
||||
d.hover("b", True) # passing an item again doesn't flip it back
|
||||
d.end()
|
||||
assert sel == {"a", "b", "c"}
|
||||
d.hover("d", True) # after the drag: hover only
|
||||
assert "d" not in sel
|
||||
|
||||
|
||||
def test_drag_select_from_a_selected_item_deselects():
|
||||
sel = {"a", "b", "c"}
|
||||
d = C.DragSelect(lambda k: k in sel, lambda k, on: (sel.add if on else sel.discard)(k))
|
||||
d.hover("b", True)
|
||||
d.start()
|
||||
d.hover("c", True)
|
||||
d.end()
|
||||
assert sel == {"a"}
|
||||
|
||||
|
||||
def test_drag_select_from_empty_space_selects_and_calls_on_start():
|
||||
sel, started = set(), []
|
||||
d = C.DragSelect(lambda k: k in sel, lambda k, on: (sel.add if on else sel.discard)(k),
|
||||
on_start=lambda: started.append(1))
|
||||
d.hover("a", True)
|
||||
d.hover("a", False) # left the item: the drag starts on empty space
|
||||
d.start()
|
||||
d.hover("x", True)
|
||||
assert sel == {"x"} and started == [1]
|
||||
@@ -78,3 +78,17 @@ def test_bundles_contain_the_translations():
|
||||
"""Both packaging paths ship locales/*.json (flet build packages src/; PyInstaller needs --add-data)."""
|
||||
text = (ROOT / "scripts" / "package.py").read_text(encoding="utf-8")
|
||||
assert "src/frameport/locales" in text and "frameport/locales" in text
|
||||
|
||||
|
||||
def test_tr_n_calls_dont_pass_n_twice():
|
||||
"""tr_n formats with n itself: tr_n(..., n, n=n) raises TypeError at runtime (0.9.2 pre-release check)."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
bad = []
|
||||
for path in (Path(__file__).parents[1] / "src/frameport").rglob("*.py"):
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "tr_n" and \
|
||||
any(k.arg == "n" for k in node.keywords):
|
||||
bad.append(f"{path.name}:{node.lineno}")
|
||||
assert not bad, bad
|
||||
@@ -106,3 +106,15 @@ def test_linux_recipes_dont_follow_the_catalog(tmp_path, monkeypatch):
|
||||
data["settings"]["recipes.app_version"] = "0.0.0" # as after an app update
|
||||
library._follow_catalog(data)
|
||||
assert data["games"][g["package"]]["recipe"]["as_is"] is True
|
||||
|
||||
|
||||
def test_failed_pc_game_on_stable_suggests_experimental_proton():
|
||||
from frameport.core import library
|
||||
|
||||
library.upsert_game("rift.x", kind="rift", recipe={"package": "rift.x", "patches": {}},
|
||||
installs={"frame": {"result": {"proton": "proton_11-arm64"}}})
|
||||
assert pipeline.proton_alternative_worth_trying("rift.x", "fail")
|
||||
assert not pipeline.proton_alternative_worth_trying("rift.x", "pass")
|
||||
recipe = pipeline.apply_suggestions("rift.x", [pipeline.PROTON_TOOL])
|
||||
assert recipe.patches[pipeline.PROTON_TOOL] == {"tool": "proton-experimental"}
|
||||
assert not pipeline.proton_alternative_worth_trying("rift.x", "fail") # already on Experimental
|
||||
@@ -254,3 +254,27 @@ def test_platform_asset_picks_the_arm64_linux_bundle(monkeypatch):
|
||||
assert updates.platform_asset() == "FramePort-linux-arm64.tar.gz"
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "x86_64")
|
||||
assert updates.platform_asset() == "FramePort-linux-x64.tar.gz"
|
||||
|
||||
|
||||
def test_dev_build_versions_sort_between_releases():
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location("dev_version", Path(__file__).parents[1] / "scripts/dev_version.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
assert mod.dev_version("0.9.0", 57) == "0.9.1.dev57"
|
||||
pv = updates.parse_version
|
||||
assert pv("0.9.0") < pv("0.9.1.dev57") < pv("0.9.1.dev58") < pv("0.9.1")
|
||||
assert updates.is_newer("0.9.1", "0.9.1.dev58") and not updates.is_newer("0.9.0", "0.9.1.dev58")
|
||||
|
||||
|
||||
def test_dev_release_is_only_read_on_request():
|
||||
release = {"tag_name": "dev", "prerelease": True, "html_url": "https://example/dev", "body": "try X",
|
||||
"assets": [{"name": "frameport-0.9.1.dev57-py3-none-any.whl", "browser_download_url": "w"},
|
||||
{"name": "FramePort-windows-x64.zip", "browser_download_url": "z"},
|
||||
{"name": "SHA256SUMS.txt", "browser_download_url": "s"}]}
|
||||
assert updates.update_from_release(release, "FramePort-windows-x64.zip") is None # automatic checks skip it
|
||||
up = updates.update_from_release(release, "FramePort-windows-x64.zip", dev=True)
|
||||
assert up.version == "0.9.1.dev57" and up.asset_url == "z" and up.sums_url == "s" and up.notes == "try X"
|
||||
assert updates.update_from_release({**release, "assets": []}, dev=True) is None # no wheel: no version
|
||||
@@ -0,0 +1,43 @@
|
||||
"""USB cable link check (frame/usb.py): finding this PC's address on the Frame's USB network."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.frame import usb
|
||||
|
||||
|
||||
def test_pc_usb_addresses_from_ipconfig(monkeypatch):
|
||||
ipconfig = ("Ethernet adapter Ethernet 3:\n IPv4 Address. . . . . . . . . . . : 10.86.200.234\n"
|
||||
"Wireless LAN adapter Wi-Fi:\n IPv4 Address. . . . . . . . . . . : 192.168.1.20\n")
|
||||
monkeypatch.setattr(usb.subprocess, "run",
|
||||
lambda cmd, **k: SimpleNamespace(stdout=ipconfig if cmd[0] == "ipconfig" else ""))
|
||||
assert usb.pc_usb_addresses("10.86.200.233") == ["10.86.200.234"]
|
||||
|
||||
|
||||
def test_pc_usb_addresses_ignores_broadcast(monkeypatch):
|
||||
ip = "7: eth3 inet 10.86.200.234/29 brd 10.86.200.239 scope global eth3\n"
|
||||
monkeypatch.setattr(usb.subprocess, "run", lambda cmd, **k: SimpleNamespace(stdout=ip if cmd[0] == "ip" else ""))
|
||||
assert usb.pc_usb_addresses("10.86.200.233") == ["10.86.200.234"]
|
||||
|
||||
|
||||
def test_check_reports_missing_usb_link():
|
||||
frame = SimpleNamespace(agent=lambda cmd, **k: {"present": False})
|
||||
out = usb.check(frame)
|
||||
assert out["notes"] and "usb0" in out["notes"][0]
|
||||
|
||||
|
||||
def test_discovered_frames_are_deduplicated_by_host_key():
|
||||
from frameport.frame.discovery import Found, dedupe
|
||||
|
||||
found = [Found("frame", "192.168.1.30", source="devkit", addresses=["frame.local"]),
|
||||
Found("192.168.1.30", "192.168.1.30", source="scan"),
|
||||
Found("My Frame", "10.86.200.233", source="saved"),
|
||||
Found("frame", "10.35.78.1", source="devkit"),
|
||||
Found("other", "192.168.1.40", source="devkit")]
|
||||
keys = {"192.168.1.30": "A", "10.86.200.233": "A", "10.35.78.1": "A", "192.168.1.40": "B"}
|
||||
out = dedupe(found, key_of=lambda f: keys.get(f.host))
|
||||
assert len(out) == 2
|
||||
frame = next(f for f in out if f.host != "192.168.1.40")
|
||||
assert frame.host == "10.86.200.233" and frame.via == "USB" # the fastest link wins
|
||||
assert frame.source == "devkit" and frame.name == "frame" # the best-known source/name is kept
|
||||
assert {"192.168.1.30", "10.35.78.1"} <= set(frame.addresses)
|
||||
# an entry whose key can't be read isn't merged away
|
||||
assert len(dedupe([Found("x", "1.2.3.4")], key_of=lambda f: None)) == 1
|
||||
Reference in new issue
Block a user