Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e8a480ea6 | ||
|
|
ea2cd893b4 | ||
|
|
a90a8c27dc | ||
|
|
9d0d72c40a | ||
|
|
56d2ac117f | ||
|
|
cb8202d891 | ||
|
|
029b0674d1 | ||
|
|
c852473a43 | ||
|
|
1043eb1d1b | ||
|
|
0f444da595 | ||
|
|
fda644d036 | ||
|
|
fa0fbbd29a | ||
|
|
3446999109 |
No files matched your search
@@ -10,20 +10,8 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
## Layout
|
||||
- `src/frameport/` — Python package. `pipeline.py` is the API the CLI (`cli.py`) and GUI (`ui/`, Flet 1.0) share.
|
||||
- `ui/` — `app.py` shell (sidebar with Frame connection + activity cards, routing, actions, 30 s connection poll),
|
||||
`theme.py` (design tokens; change colours/spacing only there. Dark-only colour themes (owner: no light mode):
|
||||
built-ins `portal` (default), `portal_oled` (true black) and `original` (the old violet; alias `classic`). Portal =
|
||||
the logo's dichotomy: orange ACCENT = main action + selection, blue SECONDARY = secondary (outlined) buttons,
|
||||
progress, switch tracks, PC; `dual` themes add the blue→orange sidebar edge, two-colour wordmark (`C.wordmark`) and
|
||||
selected-tab fade (`C.portal_gradient`). Installable theme files `<data>/themes/*.json` (docs/THEMES.md: name,
|
||||
base, dual, any subset of `T.TOKENS`; refused if light/unreadable, `T.ThemeError`), Settings → Appearance: cards
|
||||
switch live via `app.restyle()` (rebuilds the shell, drops cached views; setting `ui.theme`), "Install theme
|
||||
file…", "Copy this theme as a file", bin icon removes. Read tokens as `T.NAME` when building — never as a default argument or module-level constant (they
|
||||
keep the import-time theme; module maps refill through `T.on_change`, `tests/test_theme.py` checks defaults)),
|
||||
`glyphs.py` + `icons/` (the logo `logo.svg`/`logo-solid.svg` = a monitor that comes out of a tilted portal as the
|
||||
Frame, and 24 px line glyphs `fp:frame|port|patch|container|test|pc|sync|live|shot|keys|recipe|link`, copied into
|
||||
the assets dir and shown as tinted `ft.Image`s: pass `G.FRAME` … wherever an icon goes, `C.as_icon` / the button
|
||||
helpers handle both kinds; `src/assets/icon.png` = the bundles' app icon), `components.py` (pill, card, callout,
|
||||
checklist, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
|
||||
`theme.py` (dark design tokens; change colours/spacing only there), `components.py` (pill, card, callout,
|
||||
status_row, art_fill, confirm, `update()` = safe update: in Flet 1.0 reading `.page` of an unmounted control
|
||||
raises), `jobs.py` (background FIFO job queue, one at a time, cancel via Reporter; no Flet), `views/`
|
||||
(library: search/filters/tags/sort as pure tested helpers; game: hero + one-click install, patches under
|
||||
"Customize"; frame: device + readiness + installed, or connect wizard; files: the Frame's file manager (persistent
|
||||
@@ -65,14 +53,6 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
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.
|
||||
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
|
||||
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
|
||||
needs agent v63), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
|
||||
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
|
||||
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
|
||||
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
|
||||
`ui.live_frame_card` (Settings → Appearance, default on) is on; else the battery comes from the 30 s poll).
|
||||
ui_smoke injects `FakeMonitorSession` as the hub's `session_factory`.
|
||||
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
|
||||
@@ -81,18 +61,8 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
**Never recreate clickable controls on progress ticks** (sidebar, activity tiles): update their properties —
|
||||
replacing them 5×/s swallowed clicks (couldn't leave the Library during an upload).
|
||||
Labelled switches: `C.switch(label, …)` (Material's default label colour is dark on our dark theme).
|
||||
Setup checklists (Frame → Ready to play, Settings → Tools / This PC) = `C.checklist([C.Check(ok, title, detail,
|
||||
help, fix=(label, icon, handler), extra=control)])`: ok True/False/"warn"/None (= spinner); the fix button shows
|
||||
inline only while an item isn't ready — wire existing actions only. Motion: `app.body` holds an
|
||||
`ft.AnimatedSwitcher` (`app._show_view`): a new route gets a new wrapper (150 ms fade), a same-route redraw swaps
|
||||
the wrapper's content (no animation; persistent views just remount); the wordmark's portal (`wordmark().data`)
|
||||
pulses once when frame_state turns "connected"; both off with setting `ui.reduce_motion` (Settings → Appearance
|
||||
"Reduce motion", `app.reduce_motion`). Settings has a fixed index column (`settings.SECTIONS` order, Appearance
|
||||
first, Remove FramePort last): entries scroll the section column via `scroll_to(scroll_key=…)` (sections wrapped
|
||||
in Containers with `ft.ScrollKey`), the header stays; `app.settings_view.show_section(key)` (ui_smoke
|
||||
`settings-index`).
|
||||
Help hints: wording for non-obvious terms lives in `ui/help.py` (`HELP`); show it with `C.help_icon(key)` or the
|
||||
`help=` argument of `section`/`kv` (`C.Check` help key), tooltips via `C.tip()` (wraps). Game actions for the Library
|
||||
`help=` argument of `section`/`status_row`/`kv`, tooltips via `C.tip()` (wraps). Game actions for the Library
|
||||
right-click menu (one `ft.ContextMenu` around the grid, filled on right-click) and the game page's "…" menu come
|
||||
from `app.game_actions()`. Picking art (`sources.apply_choice`) downloads into a staging dir and keeps the old
|
||||
art if nothing came back; "Update Steam art on Frame" re-sends the art set to the game's anchor (agent ≥ 12).
|
||||
@@ -101,6 +71,14 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
|
||||
uploads are interruptible (Cancel checked per MiB) and resumable (big files via SFTP `.part` append, small files
|
||||
streamed in tar batches; the agent counts files already in `incoming/`). Multi-select in the Library queues
|
||||
installs after asking every needed question up front. Failures end in one pop-up (Resume / Uninstall / log).
|
||||
- Other drives (GitHub #90, agent v63): anchors stay in ~/Applications/quest-frame, a game's files may live in
|
||||
`<mount>/FramePort/<pkg>` (`deployment.json` base). Agent `drives` (/proc/mounts, /run/media + Steam library
|
||||
drives; vfat/exfat/ntfs/read-only refused), prepare*/finalize_linux `dest` (unmounted = error, never a fallback;
|
||||
installed games keep their base), `move` (detached systemd-run + `move_status`; cp -a under podman unshare,
|
||||
count+bytes check, symlink retarget, launch.sh: Quest app_dir line / Linux+PC VR rewritten from the record's
|
||||
`launcher` field, else text swap), list_installed `drive`/`drive_missing` (install_state keeps such games
|
||||
"installed"). PC: `install/drives.py`, library setting `install.drive` (Frame page → Storage), game menu
|
||||
"Move to…" (job kind tool-frame), CLI `frame drives`/`frame move`/`install --dest`. Untested on the device.
|
||||
- **PC VR repacks are pre-patched to run directly** (proven: Rick and Morty, Vader Immortal run when the exe is
|
||||
launched directly; Revive breaks them). So Rift games default to `as_is` = install the copy unchanged and launch
|
||||
the exe directly (`pcvr.xr_timefix` for the Frame OpenXR-1.1→1.0 fix, `pcvr.no_crash_reporter` for Unreal).
|
||||
@@ -161,6 +139,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
OpenXR, source) instead of recipe/patches, `C.missing_libraries` callout (Frame deployment, else last install);
|
||||
no Analyze/Rebuild/recipe/share/Game settings actions, Frame only; platform "Linux" badge + library filter; Steam
|
||||
tags "Linux app on Frame"/"Linux"; `_follow_catalog` skips them; local files of a lone AppImage = the file only.
|
||||
Desktop Mode entries (GitHub #84, agent v63): finalize_linux writes `frameport-<slug>.desktop` to
|
||||
~/.local/share/applications (+ ~/Desktop if it exists; `X-FramePort-Package` marks ours), launch.sh with
|
||||
`FRAMEPORT_DESKTOP=1` skips the Steam-parent watchdog and Steam's display; ensure_host_fixes refreshes entries
|
||||
(older installs, stale ones removed), uninstall/purge remove them. Per app: library entry field `desktop_entry`
|
||||
(default on; patches don't apply to Linux apps) → game page switch → agent `desktop_entry`. Untested on device.
|
||||
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
@@ -176,6 +159,10 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
|
||||
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
|
||||
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
|
||||
`upstream.py`: upstream fixes that replace a workaround per build (a probe finds the fix in OVRPort's output →
|
||||
the build leaves the workaround out, `build.superseded`; recipes unchanged). Registered: `ovrport.haptic_envelope`
|
||||
(→ `adapter.haptic_fix`, `frame/haptic_envelope.py`) and `ovrport.microphone_stream` (→ `frame.ovr_microphone`),
|
||||
both fixed in OVRPort runtime 3.4.3-aa54c3f (ovrport/app#73; haptics owner-verified with Lucky's Tale 2026-10-07).
|
||||
- `analysis/` — APK/ELF inspection (`detect.py`), `elf.py` (pyelftools reads; own DT_NEEDED writer), `stubgen.py`
|
||||
(generates the ovr_* stub .so without a compiler).
|
||||
- `apk/` — `axml.py` (binary manifest editor), `workspace.py` (staged zip edits), `sign.py` (apksigner; it aligns too).
|
||||
|
||||
@@ -109,7 +109,7 @@ will attempt to guess key patches. If you find a new config that works for an ap
|
||||
|
||||
**[List of tested games](docs/GAMES.md)**
|
||||
|
||||
**Tested something? Share it.** In FramePort open the game → **…** → **Share working recipe…** (it fills in the
|
||||
**Tested something? Share it.** In FramePort open the game → **…** → **Share working config…** (it fills in the
|
||||
recipe for you) or **Report a problem…** (attaches a diagnostics zip with personal data removed). Without the app:
|
||||
[share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) · [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml). Shared configs become built-in recipes for everyone.
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.CortopiaStudios.Graves
|
||||
title: GORN2
|
||||
status: works
|
||||
tested_version: 1.10.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: d8a7949edc90c19902eb89f888ad6a350475387d630991767b3097667ad16d4c
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 88
|
||||
source_hint: GORN 2
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.ToastVR.RichiesPlankExperience
|
||||
title: Richie's Plank Experience
|
||||
status: works
|
||||
tested_version: 2.3.585
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.oculusos
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: 7eb1366c2398e554624a5d66aefb0c731c64b1b6944bc1726d9187f123c37fb7
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260922.6101926'
|
||||
agent: 59
|
||||
issue: 95
|
||||
source_hint: apks
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.characterBank.ruinsmagus
|
||||
title: RUINSMAGUS
|
||||
status: works
|
||||
tested_version: 1.2.3
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-07'
|
||||
known_good_sha256: 230e5625fbdfebb47c4d96aeb87f45270e4e6c79a33c08315c90626ab7465fe5
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261006.6173745'
|
||||
agent: 59
|
||||
issue: 89
|
||||
source_hint: RUINSMAGUS
|
||||
@@ -0,0 +1,18 @@
|
||||
package: org.timecrisis.quest
|
||||
title: Time Crisis VR (Experimental)
|
||||
status: works
|
||||
notes: Fully playable at 120hz
|
||||
tested_version: 0.8.3
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
verified:
|
||||
date: '2026-10-06'
|
||||
known_good_sha256: 4e67411a8e9c0b5fbb19ca7908c4222aa5ad1b2d47a8f4b5018b4fe9c2f5324e
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.3
|
||||
frame_build: '20261005.6135832'
|
||||
agent: 59
|
||||
issue: 80
|
||||
source_hint: TimeCrisisVR-v0.8.3-quest
|
||||
@@ -18,6 +18,16 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: 32-bit-only APK. The Steam Frame has no AArch32; this cannot run. Use the PC (Rift) version via Revive.
|
||||
suggest: []
|
||||
- id: android-too-new
|
||||
pattern: '(NoClassDefFoundError|ClassNotFoundException|NoSuchMethodError)\b.*(Landroid/window/|android\.window\.|(storeStoreFence|loadLoadFence)\(\)V in class Ljava/lang/invoke/VarHandle)'
|
||||
severity: fatal
|
||||
diagnosis: The app calls Android 12/13+ classes (android.window.*, VarHandle fences) that the Frame's Android 11 (API 30) container doesn't have; it needs a newer Android (e.g. minSdk 34 apps). Nothing to patch - it can't run until Valve updates the Frame's Android.
|
||||
suggest: []
|
||||
- id: web-wrapper
|
||||
pattern: '(Creating TwaLauncher for com\.oculus\.browser|NameNotFoundException:? com\.oculus\.browser)'
|
||||
severity: fatal
|
||||
diagnosis: The app is a website in an Android wrapper (Trusted Web Activity) that opens Meta's browser (com.oculus.browser), which the Frame doesn't have, so nothing opens. There's no game to port - open the website (TWALauncherActivity's "Using URL" line) in a browser instead.
|
||||
suggest: []
|
||||
- id: unity-render-crash
|
||||
pattern: 'CRASH\s*:.*#\d+\s+pc [0-9a-f]+\s+\S*libgallium_dri\.so'
|
||||
severity: fatal
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
## Extending
|
||||
- **New fix**: `patches/frame/<name>.py` with a `Patch` subclass (`detect`, `apply`, optional `validate`), plus a
|
||||
signature in `catalog/triage.yaml` and a PLAYBOOK row. Stages: `overport` | `apk` | `install`.
|
||||
- **Upstream fixed a bug we work around**: register an `UpstreamFix` (`patches/upstream.py`) in the workaround's
|
||||
module: a probe that finds the fix in OVRPort's output (True / False / None = can't tell). Each build runs the probes
|
||||
on the converted APK and leaves out the workarounds whose fix is there (log "not needed: …", `build.superseded`,
|
||||
left out of settings.conf at install; game page and `frameport show` say so). The recipe keeps asking for the fix,
|
||||
so builds made with an older runtime keep the workaround. `FRAMEPORT_KEEP_WORKAROUNDS=1` turns it off.
|
||||
- **New heuristic**: put it in the patch's `detect()` (and `applies()` for visibility), with the evidence in the
|
||||
reason text; check `scripts/eval_heuristics.py` still reproduces the catalog and add a test in
|
||||
`tests/test_heuristics.py`.
|
||||
|
||||
@@ -6,7 +6,7 @@ Other games get suggested patches from detection rules; each suggestion states i
|
||||
switched on or off under **Customize**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
|
||||
Tried an untested game? Its page asks how it runs; **Share working recipe…** opens a prefilled GitHub issue so your
|
||||
Tried an untested game? Its page asks how it runs; **Share working config…** opens a prefilled GitHub issue so your
|
||||
recipe can join the built-in catalog for everyone.
|
||||
|
||||

|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Two ways users feed results back, both without a GitHub token:
|
||||
|
||||
- **Share working recipe** (game menu, `frameport share-recipe <pkg>`): saves the recipe as a user catalog entry and
|
||||
- **Share working config** (game menu, `frameport share-recipe <pkg>`): saves the recipe as a user catalog entry and
|
||||
opens a prefilled issue from `.github/ISSUE_TEMPLATE/working-config.yml`. A maintainer checks it and adds the label
|
||||
`catalog-accepted`. Then `.github/workflows/catalog-from-issue.yml` runs `scripts/catalog_from_issue.py`, which reads
|
||||
only the YAML block, validates it, and writes `catalog/games/<pkg>.yaml`, and the workflow opens a PR.
|
||||
|
||||
@@ -24,7 +24,7 @@ Games/ ← scan this folder (Add games → Scan
|
||||
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… works with a lone APK too. Its data is found when the data folder sits next
|
||||
- **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
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
work on Android 11: `policy_control` is gone, `cmd statusbar send-disable-flag home recents` has no `back` and
|
||||
moves the back button onto the app's controls, the `sysui_nav_bar` layout and disabling SystemUI had no effect.
|
||||
|
||||
## Where FramePort keeps games (internal storage, microSD)
|
||||
- Every game has an **anchor** on internal storage: `~/Applications/quest-frame/<pkg>/` with `launch.sh`,
|
||||
`deployment.json`, `artwork/` and `plays.log`. Steam's shortcut points at the anchor's `launch.sh`, so it never
|
||||
changes when the files move.
|
||||
- The game's files (`lepton-app/`, `lepton-data/` = saves, `lepton-shaders/`, `settings.conf`, `launch.log`; PC VR:
|
||||
`game/`, `revive/`, `compatdata/` = Proton prefix; Linux: `app/`) live in `deployment.json["base"]`: the anchor
|
||||
itself on internal storage, or `<mount>/FramePort/<pkg>` on another drive (GitHub #90, agent v63).
|
||||
- SteamOS mounts removable drives (microSD) under `/run/media/<user>/<label or uuid>`; the agent's `drives` reads
|
||||
`/proc/mounts` (plus the drives of Steam library folders) and refuses vfat/exfat/ntfs (Lepton's data and Proton
|
||||
prefixes need Unix owners, permissions and symlinks) and read-only mounts. A drive that isn't mounted is an error for
|
||||
new installs (never a silent fallback to internal storage); `list_installed` marks games on it `drive_missing` and
|
||||
their launchers stop with "storage not mounted?".
|
||||
- `move` copies with `cp -a` inside `podman unshare` (files Lepton's containers own belong to subordinate user ids),
|
||||
compares file count + bytes, retargets absolute symlinks into the old folder (the LibOVRRT → Revive redirect), points
|
||||
`launch.sh` at the new folder and only then deletes the old copy; on the same filesystem it renames instead.
|
||||
Moving a game whose Lepton data holds absolute paths, or a PC VR prefix, is not yet verified on the device.
|
||||
|
||||
## OpenXR runtime (as seen by games through overport's loader)
|
||||
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
|
||||
KHR_vulkan_enable(2), KHR_opengl_es_enable, KHR_composition_layer_depth, FB_display_refresh_rate, EXT_hand_tracking
|
||||
@@ -120,6 +137,12 @@
|
||||
- Processes started from Steam (Konsole, SSH sessions?) share steam.service's cgroup: use `systemd-run --user`.
|
||||
- SSH: `sshd` must be enabled (`sudo systemctl enable --now sshd`), which needs a user password (`passwd`).
|
||||
- mDNS: avahi-daemon runs by default; hostname `frame` → `frame.local`.
|
||||
- Desktop Mode menu entries (GitHub #84, agent v63): Linux apps get `~/.local/share/applications/frameport-<slug>.desktop`
|
||||
(+ an executable copy in `~/Desktop` when that folder exists; Plasma starts executable `.desktop` files there
|
||||
without a trust prompt), `Exec=env FRAMEPORT_DESKTOP=1 "<anchor>/launch.sh"`, marked `X-FramePort-Package=<pkg>`.
|
||||
Plasma's launcher exits right after starting the program, so with `FRAMEPORT_DESKTOP=1` the Linux launcher skips
|
||||
its "Steam parent gone → end the app" watchdog and keeps the desktop's DISPLAY/WAYLAND_DISPLAY instead of taking
|
||||
gamescope's from Steam. Not yet tried from the Frame's Desktop Mode.
|
||||
|
||||
## Video of the headset view (surveyed 2026-10-05; used by the Live view tab)
|
||||
- `steamvr-v4l2cam.service` (user unit, part of gamescope-session.target, `Restart=always`) runs SteamVR's
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tested games
|
||||
|
||||
Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort suggests patches for them, and a working recipe can be shared from the app (**Share working recipe…**).
|
||||
Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort suggests patches for them, and a working config can be shared from the app (**Share working config…**).
|
||||
Tested a game? [Share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) or [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml) (in the app: the game's **…** menu does both and fills in the details).
|
||||
Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
|
||||
@@ -27,6 +27,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| H.U.N.T | Quest | ✅ Works | |
|
||||
| I Am Cat | Quest | ✅ Works | |
|
||||
| I Am Monkey | Quest | ✅ Works | |
|
||||
@@ -50,9 +51,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| PowerWash Simulator VR | Quest | ✅ Works | |
|
||||
| QuestCraft | Quest | ✅ Works | |
|
||||
| Richie's Plank Experience | Quest | ✅ Works | |
|
||||
| Rick and Morty: Virtual Rick-ality | PC VR | ✅ Works | |
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
| Stremio | Quest | ✅ Works | |
|
||||
@@ -62,6 +65,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| Time Crisis VR (Experimental) | Quest | ✅ Works | |
|
||||
| Toy Master | Quest | ✅ Works | |
|
||||
| Under Cover | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
@@ -71,6 +75,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. |
|
||||
| BONELAB | Quest | ⚠️ Works with issues | Build 1.2068 plays (head tracking and controls work with frame.unity_user_presence); the newer build 1.2974 crashes at start (Vulkan), no fix yet. |
|
||||
| Doom3Quest | Quest | ⚠️ Works with issues | PDA shows black screen. |
|
||||
| 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). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
|
||||
@@ -85,6 +85,11 @@ which would end Desktop Mode). This is new: please report anything odd with **Re
|
||||
- If the Frame goes to sleep, turns off or leaves the Wi-Fi, the queue **waits** and continues once it's back; uploads
|
||||
pick up where they stopped. While installs run, FramePort keeps the Frame from going to sleep. Before a large batch
|
||||
it checks the Frame has enough free space.
|
||||
- **microSD card / other drives:** the **Steam Frame** page's **Storage** section lists the Frame's drives and sets
|
||||
where new games go (**Install new games to**). Games go into a `FramePort` folder on the card. To move a game that's
|
||||
installed already, right-click it → **Move to…** (the game must be closed; saves, settings and the Steam entry stay).
|
||||
A game on the card only starts while the card is inserted (FramePort then says "SD Card not inserted"). Cards
|
||||
formatted as FAT, exFAT or NTFS can't hold games: format the card in SteamOS first.
|
||||
- Your own game files are never changed. The converted copy is temporary: it's removed once the game is on the Frame
|
||||
(Settings → Installing: keep them, or remove all now).
|
||||
- **Game settings…** (game menu or the Steam Frame page): sharpness, refresh rate, controllers, menus, 360° video and
|
||||
@@ -94,7 +99,7 @@ which would end Desktop Mode). This is new: please report anything odd with **Re
|
||||
back/home/recents buttons are hidden by default (patch **Hide Android's navigation bar**). If FramePort guesses
|
||||
wrong (a phone app shows nothing in the headset, or a VR app opens as a flat window), choose **VR** or **Flat
|
||||
window** under **Show as VR or as a flat window** in the game's **Customize** section, then **Update on Frame**.
|
||||
- **Add games → Add a Windows program…** adds a single Windows program; the Frame runs it through Proton (as a
|
||||
- **Add games → Add a Windows program (.exe)…** adds a single Windows program; the Frame runs it through Proton (as a
|
||||
window unless it is a VR game). A program sitting in Downloads, the home folder or a drive root is copied on its
|
||||
own first, so the install doesn't upload everything next to it.
|
||||
- In the packaged app you can also **drag files onto the Library**: APKs, Linux apps (AppImage, `.zip`/`.tar.gz`),
|
||||
@@ -109,7 +114,7 @@ sideloader, documented at framedropvr.com/docs). FramePort understands the same
|
||||
the title, the files, their size and whether a checksum is given, and asks before it downloads anything. Then it
|
||||
downloads the build, adds it to your library and starts the usual install on the Frame. If no Frame is connected,
|
||||
the game is added now and installs once the Frame is back.
|
||||
- **Add games → Add from a link…** takes the button's address (right-click → Copy link), a `framedrop://` or
|
||||
- **Add games → Install from a link…** takes the button's address (right-click → Copy link), a `framedrop://` or
|
||||
`frameport://` link, a manifest (`.json`) or a direct link to an APK, a Linux build or a Windows program. Use it on
|
||||
macOS, where web pages can't hand links to FramePort yet.
|
||||
- **Settings → Install links** has one switch for `framedrop://` links (the buttons) and one for FramePort's own
|
||||
@@ -134,7 +139,7 @@ reality), showing only what matters for that game. Changes are kept with the gam
|
||||
|
||||
## Typing on the Frame
|
||||
|
||||
- **Type on Frame** (its own tab in the sidebar; also on the Steam Frame page): while the tab is
|
||||
- **Type on Frame** (its own tab in the sidebar; also on the Steam Frame page and in a game's menu): while the tab is
|
||||
open, this computer's keyboard works as a keyboard plugged into the Frame. Select a text field in the headset (in
|
||||
an app, Steam or the desktop) and type; Esc and shortcuts go to the Frame too. Paste longer text into the box to
|
||||
type it in one go (US keyboard layout). Opening another tab disconnects the keyboard.
|
||||
@@ -185,7 +190,7 @@ Command line: `frameport update` (`--check` only checks, exit code 10 = update a
|
||||
## PC VR games
|
||||
|
||||
PC VR games are Windows VR games (OpenXR, SteamVR or Oculus). Scan a folder of them (one folder per game) or use
|
||||
**Add games → Add a PC game folder…**. FramePort finds the game's program and asks when there is more than one
|
||||
**Add games → Add one game folder…**. FramePort finds the game's program and asks when there is more than one
|
||||
candidate. **Already patched** on a game page installs a copy unchanged. Oculus-only games need Revive: FramePort uses
|
||||
an installed Revive, or downloads a portable copy.
|
||||
|
||||
@@ -196,7 +201,7 @@ an installed Revive, or downloads a portable copy.
|
||||
on the game page.
|
||||
- Games that use the Oculus Platform SDK check the licence through the Oculus app, so they run on the PC only.
|
||||
|
||||
**Windows games without VR:** **Add games → Add a PC game folder…** with the game's folder (pick the program that
|
||||
**Windows games without VR:** **Add games → Add one game folder…** with the game's folder (pick the program that
|
||||
starts it if asked). FramePort installs it on the Frame and Proton runs it as a window, like Steam's own Windows games;
|
||||
it shows up in the Frame's Steam library tagged "Windows game on Frame". Whether a game runs depends on Proton on ARM
|
||||
(x86 games run through emulation).
|
||||
@@ -217,6 +222,9 @@ work like for other games.
|
||||
- The app must bring the libraries SteamOS doesn't have (checked for arm64 builds; x86_64 builds use FEX's x86
|
||||
system, which has glibc and Mesa, and aren't checked ahead). If some are missing, the install reports them and the game
|
||||
page lists them: look for a build that includes them.
|
||||
- **Desktop Mode:** Linux apps also appear in Desktop Mode's application menu and as an icon on its desktop. Some
|
||||
apps work better there, with a mouse and keyboard, than in Gaming Mode (where Steam Input turns the controllers into
|
||||
a gamepad). Switch it off per app on the game page (**Desktop Mode**).
|
||||
- From the command line: `frameport add-linux <AppImage, folder or archive> [--exe <program>]`.
|
||||
|
||||
## Files on the Frame (videos, documents, mods, saves)
|
||||
@@ -241,7 +249,7 @@ Command line: `frameport frame send <files> --dest videos` (`frameport frame sto
|
||||
|
||||
## Sharing a working game, reporting a problem
|
||||
|
||||
- **Share working recipe…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
|
||||
- **Share working config…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
|
||||
configs become built-in recipes. Untested games ask on their page once they've been installed or tested: **It
|
||||
works**, **It has issues** or **It doesn't run**.
|
||||
- **Report a problem…** (game menu, or Settings → Problems and feedback): saves a diagnostics zip to Documents (logs,
|
||||
@@ -261,6 +269,7 @@ and `frameport <command> --help` describe every option. The main ones:
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for PC VR on this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, pair with and describe the Frame |
|
||||
| `frame send` / `frame storage` / `frame cleanup` | copy files to the Frame, show where they go, free space |
|
||||
| `frame drives` / `frame move <game> --to <drive>` / `install --dest <drive>` | the Frame's drives (microSD), move a game, install to a drive |
|
||||
| `tools status` / `tools install` | the tools FramePort downloads |
|
||||
| `open-link "<link>"` | install from an "Install with FrameDrop" button's address or a manifest/APK/zip link (`--yes`, `--no-install`) |
|
||||
| `diag report <game>` / `share-recipe <game>` | report a problem / share a working recipe |
|
||||
|
||||
@@ -21,7 +21,10 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| `cannot locate symbol "ovrPeerConnectionState_ToString"` (or another `ovr<Enum>_ToString`) at start (BlazeRush, GitHub #57) | OVRPort's platform loader lacks several Platform SDK enum helpers | `frame.ovrstubs` now stubs them too; `*_ToString` stubs return "" (not NULL) |
|
||||
| Exits right after the OpenXR instance is created: `Failed to create XR session: -50` (`XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING`, Lambda1VR) | the game skips `xrGet*GraphicsRequirementsKHR`; Meta's runtime tolerates it, SteamVR's doesn't. Lambda1VR's TBXR doesn't even enable `XR_KHR_opengl_es_enable` (only `XR_EXT_local_floor`) | FrameBridge adds `XR_KHR_opengl_es_enable` when an app enables no graphics API extension, asks for the requirements on the game's behalf and retries xrCreateSession once (logs `added XR_KHR_opengl_es_enable …`, `the app skipped …; asked for it`; every launch logs the final extension list `xrCreateInstance with N extension(s)`); triage `graphics-requirements-missing`. Verified headless: Lambda1VR runs at 72 fps |
|
||||
| Exits at start: `OVRAvatar-Loader: DisplayErrorAndExit: Failed to launch SystemActivities` after `ovrAvatar_Initialize: Failed to load AvatarSDK driver` (BlazeRush, GitHub #57) | Meta's avatar loader needs Horizon; it then tries Meta's error screen and aborts | `frame.avatar_stub` (same exports, do nothing); triage `avatar-driver-missing`. Games that ship the library but don't start avatars at launch (Lucky's Tale, BattleSisters, Arcsmith) don't need it |
|
||||
| Frame freezes or the game is killed (OOM) on the first controller vibration (e.g. Lucky's Tale's save slots, BattleSisters) | OVRPort runtimes up to 3.4.3-23204ea read the haptic envelope's duration (ns) as seconds and allocate gigabytes of samples (GitHub #9, ovrport/app#73) | `adapter.haptic_fix` (envelope → one plain vibration in `native/xrshim`). Fixed in runtime 3.4.3-aa54c3f: builds made with it leave the workaround out by themselves (log "not needed: adapter.haptic_fix", `frameport show` "last build left out"); rebuild an older build to switch |
|
||||
| Controller vibrations much stronger than on a Quest (The Boys VR, Jurassic World, BONELAB, …) | OVRPlugin games send amplitude envelopes; `haptic_fix` turned each into one vibration at the envelope's *peak* for its whole duration (a short fading pulse became a long full-strength buzz) | `haptic_fix` now uses the envelope's RMS; Game settings → "Vibration strength" (`haptic_scale`, 0-1) scales every vibration in FrameBridge; launch.log shows the first requests (`haptic: …`). OVRPlugin stops a vibration with amplitude 0 (2 s duration): FrameBridge turns that into xrStopHapticFeedback (without it the last buzz ran its full 2 s) |
|
||||
| Play or a launch test fails at once: `Game files missing at <path> (storage not mounted?)` in launch.log, or "… files are on a drive that isn't inserted" (GitHub #90) | the game was installed or moved to a microSD card (`<mount>/FramePort/<pkg>`) that isn't inserted or mounted now | insert the card; or move the game back (game menu → Move to…, `frameport frame move <pkg> --to internal`) once the card is back. `frameport frame drives` shows what the agent sees (exFAT/NTFS cards are refused: format in SteamOS) |
|
||||
| A Linux app started from Desktop Mode's menu closes after ~2 s, or opens on the wrong display (GitHub #84) | its launcher predates agent v63: the "Steam parent gone" watchdog ended it when Plasma's launcher exited, and it took gamescope's DISPLAY from Steam | connect FramePort once (agent v63's `upgrade_launchers` adds the `FRAMEPORT_DESKTOP` checks; the menu entry runs `env FRAMEPORT_DESKTOP=1 <anchor>/launch.sh`). Game Mode controller problems in such apps are Steam Input's (a separate issue) |
|
||||
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
|
||||
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (e.g. Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
|
||||
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
|
||||
@@ -42,14 +45,18 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Uploads to the Frame are slow (~15 MB/s) | PC and Frame both on Wi-Fi through the home router | Connect the PC to the Frame's own hotspot (or a USB cable): FramePort uses the direct link automatically (~80-100 MB/s); job log line "Transfer link" |
|
||||
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
|
||||
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
|
||||
| A backup's game was added without its data (library `data_dir` null, `data_bytes` 0) although the folder has .obb files | The OBBs sit in a layout the scan didn't know: `<game>/obb/<package>/`, `Android/obb/<package>/`, or SideQuest-style `<game>/apk/x.apk` + `<game>/obb/<package>/` | `sources/quest_dump.find_data_dir` finds a `<package>` folder with .obb files ≤3 levels below the APK's folder or ≤2 below its parent (skipping neighbouring folders with APKs: other games); an `apk`/`apks` folder counts as part of its game folder. Add the folder again (Analyze again doesn't look for data) |
|
||||
| Unreal game: `JNI_OnLoad` (OVRPlugin), then silence: no OpenXR instance, no crash, no frames (GitHub #85 TRIANGLE STRATEGY; launch-test finding `missing-obb`) | Installed without its OBB: the manifest says `com.epicgames.ue4.GameActivity.bHasOBBFiles` = true (UE5: `com.epicgames.unreal.…`) but the library has no data folder (`data_dir` null, `data_bytes` 0) | Put the .obb files in a `<package>` (or `obb/`) folder next to the APK, add the folder again, reinstall. Analysis `extra.expects_obb`; the game page, the install question and `frameport scan`/`install` warn; launch tests that sent no frames get the `missing-obb` finding from the library (the game logs nothing). Unity split-binary builds have no reliable sign in the APK |
|
||||
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK; Frame has no AArch32 | None. PC/Rift version via Revive |
|
||||
| Crash at start: `NoClassDefFoundError: … Landroid/window/OnBackInvokedCallback;` or `NoSuchMethodError: No static method storeStoreFence()V in class Ljava/lang/invoke/VarHandle;` (GitHub #71/#72, triage `android-too-new`) | The APK needs Android 13+ (minSdk 33 or more; those were 34) and calls its classes at start; Lepton's Android is 11 (API 30). OVRPort lowers minSdk to 29, so it installs anyway. minSdk ≤ 32 (Quest's Android 12L) is fine: 16 working catalog games declare 32 | None (analysis `extra.min_sdk` > 32 → a warning on the game page/CLI and in the recipe notes; never unsupported from the manifest alone, the launch test's triage decides). Waits for Valve to update Lepton's Android |
|
||||
| Nothing opens; `TWALauncherActivity: Using URL from Manifest (https://…)`, `Creating TwaLauncher for com.oculus.browser`, `NameNotFoundException: com.oculus.browser` (GitHub #86 Mahjong Table VR, triage `web-wrapper`) | The APK is a Trusted Web Activity (Bubblewrap / Meta's PWA packaging): a website that opens in Meta's browser, which Lepton doesn't have | None: open the URL in a browser. Analysis `extra.web_wrapper` (manifest meta-data `android.support.customtabs.trusted.DEFAULT_URL`, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) → a warning with the URL (not unsupported: the manifest alone never decides) |
|
||||
| `UnsatisfiedLinkError` / `cannot locate symbol "ovr_…"` | overport's platform loader lacks Meta platform functions | `frame.ovrstubs` (automatic, generated stubs) |
|
||||
| missing `ovrMessageType_ToString` | same, but the game needs a real string | `frame.ovrplatformcompat` (automatic) |
|
||||
| The game stays in its built-in language or waits at start although its OBB / files hold language packs (`<tag>.lang`, e.g. `de.lang`) | overport's platform loader answers `ovr_LanguagePack_GetCurrent/SetCurrent` with request id 0, so the game never gets the pack's path | `frame.langpacks` (opt-in, experimental, shown for games whose data has `*.lang`; `native/langpack`, build it with `python native/build.py --only langpack`). Default pack: the one the game applies, else env `FRAMEPORT_LANGPACK=<tag>`, else the only pack; search path override `FRAMEPORT_LANGPACK_DIRS`. Unreal games (Deadpool VR) grey out a pack whose asset `Metadata` differs from the game's version string: the patch writes the APK's versionName as Metadata (env `FRAMEPORT_LANGPACK_META` overrides) and reports the pack under the `/storage/emulated/0/Android/obb/<pkg>/` spelling of its path; with `/sdcard/…` the text switched but the dialogue stayed silent (verified in the headset with Deadpool VR) |
|
||||
| `ClassNotFoundException com.oculus.os.AnalyticsEvent` → abort | Quest telemetry lookup in Meta XR Audio (Unreal build) or native code | `frame.metaxr_telemetry` + `frame.oculusos` (automatic) |
|
||||
| `JNI DETECTED ERROR`, `GetStringUTFChars … NULL` | CheckJNI is on because overport marks the app debuggable | `frame.nodebug` |
|
||||
| Unreal game quits a few seconds after start (`System.exit`) | ForceQuit after a failed Quest platform check | alternate build with `patch_remove_unreal_force_quit` (`use_alt`) |
|
||||
| Unreal game crashes a few seconds after the logo with no backtrace (Unreal's own signal handler hides it: logcat-crash.log empty, Zygote `exited due to signal 11`); the crashing thread is `GameThread`, pc in `libaaudio.so`, lr `libovrplatformloader.so (ovr_Microphone_GetOutputBufferMaxSize+0x10)`; the last platform call is `ovr_Microphone_Create` (e.g. The Walking Dead: Saints & Sinners Ch. 2) | OVRPort's platform loader opens the microphone's AAudio stream only in `ovr_Microphone_Start`, but `GetOutputBufferMaxSize` reads that stream unchecked; Unreal's Oculus voice asks for the size right after `Create` | `frame.ovr_microphone` (NULL check rewritten in place; size 0 until the microphone starts) |
|
||||
| Unreal game crashes a few seconds after the logo with no backtrace (Unreal's own signal handler hides it: logcat-crash.log empty, Zygote `exited due to signal 11`); the crashing thread is `GameThread`, pc in `libaaudio.so`, lr `libovrplatformloader.so (ovr_Microphone_GetOutputBufferMaxSize+0x10)`; the last platform call is `ovr_Microphone_Create` (e.g. The Walking Dead: Saints & Sinners Ch. 2) | OVRPort's platform loader opens the microphone's AAudio stream only in `ovr_Microphone_Start`, but `GetOutputBufferMaxSize` reads that stream unchecked; Unreal's Oculus voice asks for the size right after `Create` | `frame.ovr_microphone` (NULL check rewritten in place; size 0 until the microphone starts). OVRPort runtime 3.4.3-aa54c3f has the check itself: builds made with it leave the patch out (log "not needed: frame.ovr_microphone", `patches/upstream.py`) |
|
||||
| `xrCreateSwapchain` -26 / format unsupported (GLES) | Frame takes only sRGB formats, no MSAA | adapter `swapchain_fix` (default on) |
|
||||
| Frames rejected, "Waiting…" forever | a layer uses a failed swapchain or an extension that isn't enabled (e.g. equirect2) | adapter `layer_fix` (default on) |
|
||||
| `xrConvert…TimeKHR` FUNCTION_UNSUPPORTED spam; VrApi bridge stalls before recenter | runtime lacks timespec conversion | current adapter emulates it |
|
||||
@@ -88,7 +95,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
|---|---|---|
|
||||
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
|
||||
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Add from a link… (paste the button's address) |
|
||||
| "Install with FrameDrop" button opens FrameDrop (or nothing) instead of FramePort | another program owns `framedrop://`, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
|
||||
| 2D Android app: back/home/recents buttons cover the app's own buttons | Android's navigation bar | patch `device.hide_navbar` (on by default for apps without VR) sets `qemu.hw.mainkeys=1`; reinstall to apply |
|
||||
| Black screen, audio works, GLES engine with direct VrApi | Mesa rejects Quest-style GLSL | `frame.gl_shim` (logs `GLShim: SHADER COMPILE FAILED` + source lines) |
|
||||
| Black screen, audio works, log `Unsupported VrApi layer type N` | bridge drops frames containing that layer | extend the bridge (cylinder=3 is converted to a quad already) |
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Themes
|
||||
|
||||
FramePort comes with three colour themes, all dark:
|
||||
|
||||
| Theme | Look |
|
||||
|---|---|
|
||||
| **Portal** (default) | The logo's two portals: orange for actions and the current selection, blue for secondary actions, progress and the PC side. |
|
||||
| **Portal (OLED)** | Portal on true black, for OLED screens. |
|
||||
| **Original** | FramePort's first look, with a violet accent. |
|
||||
|
||||
Switch in **Settings → Appearance**. The change applies at once.
|
||||
|
||||
## Your own theme
|
||||
|
||||
A theme is a small JSON file. To make one:
|
||||
|
||||
1. In **Settings → Appearance**, pick the theme closest to what you want and click **Copy this theme as a file**.
|
||||
2. Paste it into a text editor and save it as `something.json`.
|
||||
3. Change the name and the colours you want different. You can delete every colour you keep: missing colours come
|
||||
from the `base` theme.
|
||||
4. Back in **Settings → Appearance**, click **Install theme file…** and choose the file. FramePort checks it,
|
||||
switches to it and keeps a copy in its data folder (`themes/`), so you can delete the original.
|
||||
|
||||
To remove an installed theme, click the bin icon on its card.
|
||||
|
||||
### Format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ember & Ice",
|
||||
"base": "portal",
|
||||
"dual": true,
|
||||
"colors": {
|
||||
"ACCENT": "#FF5A36",
|
||||
"SECONDARY": "#5CE1E6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `name` | Shown on the theme's card (up to 40 characters). |
|
||||
| `base` | The built-in theme that fills in every colour the file leaves out: `portal` (default), `portal_oled` or `original`. |
|
||||
| `dual` | `true`: two-colour touches like Portal's (blue-to-orange sidebar edge, two-colour "FramePort", the selected tab's fade, blue secondary buttons). `false`: one accent, like Original. Default: the base's. |
|
||||
| `colors` | Any of the colours below, as `#RRGGBB` or `#RGB`. Names may be upper or lower case. |
|
||||
|
||||
| Colour | Used for |
|
||||
|---|---|
|
||||
| `BG` | Window background |
|
||||
| `SIDEBAR` | Sidebar and activity panel |
|
||||
| `SURFACE`, `SURFACE_2`, `SURFACE_3` | Cards; raised and hovered parts; inputs and chips |
|
||||
| `BORDER`, `BORDER_STRONG` | Outlines |
|
||||
| `TEXT`, `TEXT_2`, `TEXT_3` | Main text; secondary text; captions and disabled text |
|
||||
| `ACCENT`, `ACCENT_SOFT`, `ON_ACCENT` | Main buttons and the current selection; its tint; text on it |
|
||||
| `SECONDARY`, `SECONDARY_SOFT` | Secondary buttons, progress, switch tracks; its tint |
|
||||
| `OK`, `WARN`, `ERROR`, `INFO` | Status: works, needs attention, failed, information |
|
||||
| `PC` | PC VR games and "on this PC" |
|
||||
|
||||
FramePort refuses a theme file it can't show well, and says why: a light window background (FramePort is dark only),
|
||||
text that's hard to read on cards, an unknown colour name or a value that isn't a colour. An example is in
|
||||
[`themes/example-theme.json`](themes/example-theme.json).
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "Ember & Ice",
|
||||
"base": "portal",
|
||||
"dual": true,
|
||||
"colors": {
|
||||
"ACCENT": "#FF5A36",
|
||||
"ACCENT_SOFT": "#3A1710",
|
||||
"SECONDARY": "#5CE1E6",
|
||||
"SECONDARY_SOFT": "#0E3033",
|
||||
"PC": "#5CE1E6",
|
||||
"INFO": "#5CE1E6"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ def render() -> str:
|
||||
rows.append(f"| {e.title} | {platform} | {STATUS[e.status]} | {note} |")
|
||||
return ("# Tested games\n\n"
|
||||
"Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort "
|
||||
"suggests patches for them, and a working recipe can be shared from the app (**Share working recipe…**).\n"
|
||||
"suggests patches for them, and a working config can be shared from the app (**Share working config…**).\n"
|
||||
f"Tested a game? [Share a working config]({SHARE}) or [report a problem]({REPORT}) (in the app: the game's "
|
||||
"**…** menu does both and fills in the details).\n"
|
||||
"Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.\n\n"
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record short clips of the real GUI's animations (web mode, a pretend Frame) for a showcase page: the install
|
||||
transit, the live Frame card, navigation, selection and theme switching. One browser context per clip (Playwright's
|
||||
video recorder), converted to MP4 + a poster JPG with ffmpeg.
|
||||
|
||||
FRAMEPORT_HOME=<a test data dir> python scripts/demo_record.py --out /tmp/clips [--game <package>] [--only transit]
|
||||
|
||||
Reuses ui_smoke's fakes (FakeTarget, FakeMonitorSession, …): nothing reaches a real Frame. Mouse positions are for
|
||||
the 1440x900 window at UI scale 1.0 (the same as ui_smoke --gestures).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import queue
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
import flet as ft # noqa: E402
|
||||
import ui_smoke # noqa: E402
|
||||
from ui_smoke import FakeMonitorSession, attach_fake_frame, install_fakes # noqa: E402
|
||||
|
||||
from frameport.core import library # noqa: E402
|
||||
from frameport.ui.app import FramePortApp # noqa: E402
|
||||
|
||||
PORT = ui_smoke.PORT
|
||||
W, H = 1440, 900
|
||||
FFMPEG = shutil.which("ffmpeg") or "/usr/bin/ffmpeg"
|
||||
ERRORS: list[str] = []
|
||||
APPS: queue.Queue = queue.Queue() # one FramePortApp per browser session (= per clip)
|
||||
|
||||
# sidebar tabs (x, y) at 1440x900
|
||||
TAB = {"library": (100, 110), "frame": (100, 152), "files": (100, 194), "screenshots": (100, 236),
|
||||
"live": (100, 278), "keyboard": (100, 320), "monitor": (100, 362), "settings": (100, 404)}
|
||||
NOWHERE = (130, 520) # the sidebar's empty part: nothing hovered
|
||||
|
||||
|
||||
class LiveMonitorSession(FakeMonitorSession):
|
||||
"""The fake stream, livelier for the live-card clip: a sample every 0.6 s and a frame rate with a few-second
|
||||
stutter now and then (also in the two minutes sent first), so the card's sparkline (5 s points) has a shape and
|
||||
visibly moves."""
|
||||
|
||||
def _sample(self) -> dict:
|
||||
s = super()._sample()
|
||||
t = self.t
|
||||
fps = 72 - abs(random.gauss(0, 0.8))
|
||||
if (t // 8) % 3 == 1: # 8 samples of stutter in every 24
|
||||
fps = 56 + 6 * abs(((t % 8) - 4) / 4) + random.uniform(-1.5, 1.5)
|
||||
for g in s["games"]:
|
||||
g["fps"] = round(fps, 1)
|
||||
return s
|
||||
|
||||
def _run(self) -> None:
|
||||
self.t = 0
|
||||
while not self.closed and self.t < 100000:
|
||||
self.on_sample(self._sample())
|
||||
if self.t >= self.BACKFILL:
|
||||
time.sleep(0.6)
|
||||
self.t += 1
|
||||
|
||||
|
||||
def ui(app: FramePortApp, fn, *args) -> None:
|
||||
"""Run an app action, logging (not raising) errors so one broken step doesn't end the recording."""
|
||||
try:
|
||||
fn(*args)
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append(traceback.format_exc())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ scenarios
|
||||
# each: (setup(app) before the clip starts, play(app, page) = the clip, poster time in s)
|
||||
|
||||
def transit_setup(game):
|
||||
def run(app):
|
||||
app.open_game(game)
|
||||
time.sleep(1)
|
||||
app.show_activity(True)
|
||||
return run
|
||||
|
||||
|
||||
def transit_play(game):
|
||||
"""A pretend install through the real JobManager: every stage the real one reports, with an upload ticking."""
|
||||
def run(job):
|
||||
rep = job.reporter
|
||||
|
||||
def stage(name, secs, logs=()):
|
||||
rep.stage(name)
|
||||
for line in logs:
|
||||
rep.log(line)
|
||||
time.sleep(secs)
|
||||
stage("Patching the game", 0.9)
|
||||
stage("OVRPort (primary)", 1.2, ["overport patch --version=latest"])
|
||||
stage("Frame fixes (primary)", 1.0, ["frame.ovrstubs", "frame.vk_sanitize"])
|
||||
stage("sign (primary)", 0.7)
|
||||
rep.stage("validate (primary)")
|
||||
for name in ("APK signature", "64-bit libraries", "OpenXR loader"):
|
||||
rep.check(name, True)
|
||||
time.sleep(0.25)
|
||||
time.sleep(0.2)
|
||||
stage("Prepare Frame", 0.7)
|
||||
total = 1.42e9
|
||||
rep.stage("Upload APK")
|
||||
apk_part = 0.42
|
||||
steps = 14
|
||||
for i in range(1, steps + 1):
|
||||
f = apk_part * i / steps
|
||||
speed = 34.0 + random.uniform(0, 4)
|
||||
left = (1 - f) * total / (speed * 1e6)
|
||||
rep.progress(f, "com.BithellGames.Arcsmith.apk", speed=f"{speed:.1f} MB/s · ~{left:.0f} s left")
|
||||
time.sleep(0.22)
|
||||
rep.stage("Upload data (3 files)")
|
||||
for i in range(1, steps + 1):
|
||||
f = apk_part + (1 - apk_part) * i / steps
|
||||
speed = 34.0 + random.uniform(0, 4)
|
||||
left = (1 - f) * total / (speed * 1e6)
|
||||
rep.progress(f, f"main.{120 + i // 5}.com.BithellGames.Arcsmith.obb",
|
||||
speed=f"{speed:.1f} MB/s · ~{left:.0f} s left" if f < 1 else f"{speed:.1f} MB/s")
|
||||
time.sleep(0.22)
|
||||
stage("Artwork for the Steam library", 0.8)
|
||||
stage("Finalize install", 0.9)
|
||||
stage("Add to Steam library", 0.8)
|
||||
stage("Launch test (headless)", 1.8, ["process alive", "OpenXR session created", "frames paced: 72 fps"])
|
||||
job.summary = {"verdict": "pass", "findings": []}
|
||||
rep.stage("Launch test: passed")
|
||||
time.sleep(0.5)
|
||||
return "Arcsmith is ready on your Frame: put the headset on and launch it from your Steam library"
|
||||
|
||||
def play(app, page):
|
||||
time.sleep(1.0)
|
||||
app.submit(f"Install {app._title(game)} on Frame", run, game, "install", to="frame")
|
||||
deadline = time.time() + 40
|
||||
while time.time() < deadline and any(j.active for j in app.jobs.jobs):
|
||||
time.sleep(0.3)
|
||||
time.sleep(1.8) # done: the cover arrives, the toast
|
||||
return play
|
||||
|
||||
|
||||
def live_setup(app):
|
||||
# disconnected-looking: no target, state "none" (the fake isn't attached yet)
|
||||
app.target, app.frame_info, app.frame_state = None, None, "none"
|
||||
app.go("library")
|
||||
|
||||
|
||||
def live_play(app, page):
|
||||
time.sleep(1.2)
|
||||
app.frame_state = "connecting"
|
||||
app._refresh_sidebar()
|
||||
time.sleep(1.0)
|
||||
attach_fake_frame(app, LiveMonitorSession)
|
||||
app.refresh_view()
|
||||
app._refresh_sidebar()
|
||||
time.sleep(14)
|
||||
|
||||
|
||||
def navigation_setup(app):
|
||||
app.go("library")
|
||||
|
||||
|
||||
def navigation_play(app, page):
|
||||
m = page.mouse
|
||||
time.sleep(0.8)
|
||||
for tab in ("frame", "files", "monitor", "settings", "library"):
|
||||
m.move(*TAB[tab], steps=8)
|
||||
time.sleep(0.25)
|
||||
m.click(*TAB[tab])
|
||||
time.sleep(1.3)
|
||||
# type to search: an empty part of the header, then the keyboard
|
||||
m.move(480, 60, steps=6)
|
||||
m.click(480, 60)
|
||||
time.sleep(0.4)
|
||||
page.keyboard.type("arc", delay=160)
|
||||
time.sleep(1.6)
|
||||
page.keyboard.press("Escape")
|
||||
time.sleep(1.2)
|
||||
# the "On your Frame" shelf's first card: lift + Play
|
||||
m.move(403, 268, steps=12)
|
||||
time.sleep(1.6)
|
||||
# a grid card's menu
|
||||
m.move(360, 540, steps=10)
|
||||
time.sleep(0.6)
|
||||
m.click(360, 540, button="right")
|
||||
time.sleep(0.9)
|
||||
for dy in (20, 60, 100, 140, 180):
|
||||
m.move(400, 540 + dy, steps=5)
|
||||
time.sleep(0.35)
|
||||
page.keyboard.press("Escape")
|
||||
time.sleep(0.4)
|
||||
m.move(*NOWHERE, steps=8)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def selection_setup(app):
|
||||
app.go("files")
|
||||
|
||||
|
||||
def selection_play(app, page):
|
||||
m = page.mouse
|
||||
time.sleep(0.8)
|
||||
m.move(900, 320, steps=10) # a row: its checkbox and actions fade in
|
||||
time.sleep(1.4)
|
||||
m.move(900, 268, steps=6)
|
||||
time.sleep(0.5)
|
||||
m.down()
|
||||
for pt in ((900, 320), (900, 372)):
|
||||
m.move(*pt, steps=20)
|
||||
time.sleep(0.4)
|
||||
m.up()
|
||||
time.sleep(1.2)
|
||||
m.move(900, 320, steps=5)
|
||||
time.sleep(0.3)
|
||||
m.click(900, 320, button="right")
|
||||
time.sleep(0.8)
|
||||
for dy in (20, 68, 116):
|
||||
m.move(960, 320 + dy, steps=5)
|
||||
time.sleep(0.4)
|
||||
page.keyboard.press("Escape")
|
||||
time.sleep(0.6)
|
||||
m.move(*TAB["screenshots"], steps=10)
|
||||
m.click(*TAB["screenshots"])
|
||||
time.sleep(2.2)
|
||||
for x in (410, 680, 946): # hover the first row
|
||||
m.move(x, 275, steps=10)
|
||||
time.sleep(0.6)
|
||||
for x in (305, 573): # select two
|
||||
m.move(x, 227, steps=10)
|
||||
time.sleep(0.4)
|
||||
m.click(x, 227)
|
||||
time.sleep(0.8)
|
||||
m.move(700, 760, steps=10)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def themes_setup(app):
|
||||
app.go("settings")
|
||||
|
||||
|
||||
def themes_play(app, page):
|
||||
m = page.mouse
|
||||
time.sleep(1.0)
|
||||
for x, y in ((838, 268), (1090, 268), (587, 420), (587, 275)): # Portal (OLED), Original, Ember & Ice, Portal
|
||||
m.move(x, y, steps=12)
|
||||
time.sleep(0.3)
|
||||
m.click(x, y)
|
||||
time.sleep(2.4)
|
||||
m.move(*TAB["library"], steps=12)
|
||||
m.click(*TAB["library"])
|
||||
time.sleep(3.0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", type=Path, required=True)
|
||||
ap.add_argument("--game", default=None)
|
||||
ap.add_argument("--only", default=None, help="comma-separated clip names (transit,live-card,navigation,…)")
|
||||
ap.add_argument("--crf", type=int, default=26)
|
||||
ap.add_argument("--width", type=int, default=W, help="scale the MP4 to this width (e.g. 1280)")
|
||||
ap.add_argument("--keep-raw", action="store_true", help="keep Playwright's .webm recordings in <out>/raw")
|
||||
args = ap.parse_args()
|
||||
game = args.game or (library.games()[0]["package"] if library.games() else None)
|
||||
if not game:
|
||||
print("the library is empty: nothing to show", file=sys.stderr)
|
||||
return 2
|
||||
library.set_setting("ui.theme", "portal")
|
||||
from frameport.ui import theme as T
|
||||
from frameport.ui.app import assets_dir, themes_dir
|
||||
|
||||
T.set_scale(1.0)
|
||||
T.load_user_themes(themes_dir())
|
||||
install_fakes(game)
|
||||
# the transit last: the job queue is shared by every session, and its finished install would show in the
|
||||
# sidebar's activity card of the clips after it
|
||||
scenarios = [("live-card", live_setup, live_play, 4.0),
|
||||
("navigation", navigation_setup, navigation_play, 0.5),
|
||||
("selection", selection_setup, selection_play, 3.5),
|
||||
("themes", themes_setup, themes_play, 3.0),
|
||||
("transit", transit_setup(game), transit_play(game), 9.0)]
|
||||
if args.only:
|
||||
keep = args.only.split(",")
|
||||
scenarios = [s for s in scenarios if s[0] in keep]
|
||||
out, raw = args.out, args.out / "raw"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
raw.mkdir(exist_ok=True)
|
||||
|
||||
def app_main(page: ft.Page):
|
||||
try:
|
||||
app = FramePortApp(page)
|
||||
attach_fake_frame(app)
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("startup: " + traceback.format_exc())
|
||||
return
|
||||
APPS.put(app)
|
||||
|
||||
def convert(webm: Path, name: str, start: float, length: float, poster_at: float) -> None:
|
||||
mp4, jpg = out / f"{name}.mp4", out / f"{name}.jpg"
|
||||
scale = [] if args.width == W else ["-vf", f"scale={args.width}:-2:flags=lanczos"]
|
||||
subprocess.run([FFMPEG, "-y", "-loglevel", "error", "-ss", f"{start:.2f}", "-i", str(webm),
|
||||
"-t", f"{length:.2f}", *scale, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf",
|
||||
str(args.crf), "-preset", "slow", "-movflags", "+faststart", "-an", str(mp4)], check=True)
|
||||
subprocess.run([FFMPEG, "-y", "-loglevel", "error", "-ss", f"{min(poster_at, length - 0.2):.2f}", "-i",
|
||||
str(mp4), "-frames:v", "1", "-q:v", "3", str(jpg)], check=True)
|
||||
print(f"CLIP {name}: {length:.1f} s, {mp4.stat().st_size / 1e6:.2f} MB", flush=True)
|
||||
|
||||
def recorder():
|
||||
import socket
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
for _ in range(90):
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", PORT), timeout=1).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
time.sleep(2)
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--use-gl=swiftshader", "--enable-unsafe-swiftshader"])
|
||||
for name, setup, play, poster in scenarios:
|
||||
while not APPS.empty(): # sessions from elsewhere (a browser the web mode opened)
|
||||
APPS.get_nowait()
|
||||
ctx = browser.new_context(viewport={"width": W, "height": H}, record_video_dir=str(raw),
|
||||
record_video_size={"width": W, "height": H})
|
||||
page = ctx.new_page()
|
||||
t0 = time.monotonic()
|
||||
page.goto(f"http://127.0.0.1:{PORT}", wait_until="networkidle", timeout=120_000)
|
||||
app = APPS.get(timeout=120)
|
||||
time.sleep(2) # the first screen + web fonts
|
||||
ui(app, setup, app)
|
||||
time.sleep(3.5) # the scenario's first view painted, cards loaded
|
||||
page.mouse.move(*NOWHERE)
|
||||
start = time.monotonic() - t0
|
||||
try:
|
||||
play(app, page)
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append(f"{name}: " + traceback.format_exc())
|
||||
length = time.monotonic() - t0 - start
|
||||
video = page.video
|
||||
ctx.close() # flushes the video
|
||||
webm = Path(video.path())
|
||||
target = raw / f"{name}.webm"
|
||||
webm.replace(target)
|
||||
convert(target, name, start + 0.2, length, poster)
|
||||
library.set_setting("ui.theme", "portal") # the next session starts in Portal again
|
||||
browser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("browser: " + traceback.format_exc())
|
||||
if not args.keep_raw:
|
||||
shutil.rmtree(raw, ignore_errors=True)
|
||||
for e in ERRORS:
|
||||
print("ERROR", e, flush=True)
|
||||
os._exit(1 if ERRORS else 0)
|
||||
|
||||
threading.Thread(target=recorder, daemon=True).start()
|
||||
ft.run(app_main, view=ft.AppView.WEB_BROWSER, port=PORT, assets_dir=assets_dir())
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -69,9 +69,6 @@ def main() -> int:
|
||||
"--add-data", f"{DATA}{sep}frameport/_data",
|
||||
# translations (data files PyInstaller doesn't pick up from the imports)
|
||||
"--add-data", f"{ROOT / 'src/frameport/locales'}{sep}frameport/locales",
|
||||
# the logo and the GUI's own glyphs (SVG)
|
||||
"--add-data", f"{ROOT / 'src/frameport/ui/icons'}{sep}frameport/ui/icons",
|
||||
"--icon", str(ROOT / "src/assets/icon.png"),
|
||||
"--distpath", str(ROOT / "dist"), "--yes"]
|
||||
if TARGET == "linux":
|
||||
cmd.append("--onedir") # a folder: the bundle check sees the agent source, the archive the app
|
||||
|
||||
@@ -70,11 +70,7 @@ class FakeFS:
|
||||
owners = [(g["package"], g.get("title") or g["package"], arts) for g, arts, _real in games[:3]]
|
||||
spare = games[3][1] if len(games) > 3 else [] # SteamVR's own shots (taken outside a FramePort game)
|
||||
owners.append((None, "SteamVR", spare))
|
||||
# the same days layout at any time of day (6 shots on the first day, 4 on the one before; --gestures drags
|
||||
# across the first row): shortly after midnight the shots start the evening before
|
||||
now, shots = time.time(), []
|
||||
if time.localtime(now).tm_hour < 5:
|
||||
now -= (time.localtime(now).tm_hour + 1) * 3600
|
||||
for i in range(10):
|
||||
t = now - i * 2400 - (86400 if i >= 6 else 0)
|
||||
name = time.strftime("%Y%m%d%H%M%S", time.localtime(t)) + "_1.jpg"
|
||||
@@ -156,6 +152,40 @@ class FakeTarget:
|
||||
"apk_present": True, "recipe": None, "exe": g.get("exe"),
|
||||
"missing_libraries": ["libwebkit2gtk-4.1.so.0"]}
|
||||
for g in library.games() if g.get("kind") == "linux" and g["package"] == LINUX_APPIMAGE]
|
||||
# GitHub #90: most games on internal storage, the second one on a microSD card
|
||||
sd = {"internal": False, "path": "/run/media/steamos/SD Card", "label": "SD Card"}
|
||||
for i, g in enumerate(self.games):
|
||||
g["drive"] = sd if i == 1 else {"internal": True, "path": "/home/steamos", "label": "Internal storage"}
|
||||
g["drive_missing"] = False
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
sd_games = sum(1 for g in self.games if not g["drive"]["internal"])
|
||||
return [{"id": "internal", "path": "/home/steamos", "install_dir": "/home/steamos/Applications/quest-frame",
|
||||
"label": "Internal storage", "fstype": "ext4", "internal": True, "removable": False,
|
||||
"free_bytes": 312 * 2**30, "total_bytes": 460 * 2**30, "usable": True, "reason": "",
|
||||
"games": len(self.games) - sd_games},
|
||||
{"id": "/run/media/steamos/SD Card", "path": "/run/media/steamos/SD Card",
|
||||
"install_dir": "/run/media/steamos/SD Card/FramePort", "label": "SD Card", "fstype": "ext4",
|
||||
"internal": False, "removable": True, "free_bytes": 187 * 2**30, "total_bytes": 238 * 2**30,
|
||||
"usable": True, "reason": "", "games": sd_games},
|
||||
{"id": "/run/media/steamos/USB", "path": "/run/media/steamos/USB", "install_dir":
|
||||
"/run/media/steamos/USB/FramePort", "label": "USB", "fstype": "exfat", "internal": False,
|
||||
"removable": True, "free_bytes": 50 * 2**30, "total_bytes": 64 * 2**30, "usable": False,
|
||||
"reason": "exfat can't hold game data (no Unix permissions or symlinks); format the drive in "
|
||||
"SteamOS to install games on it", "games": 0}]
|
||||
|
||||
def move(self, package, dest, reporter) -> dict:
|
||||
reporter.stage("Move game files")
|
||||
for i in range(5):
|
||||
reporter.progress(i / 4, f"Copying {i / 2:.1f}/2.0 GiB")
|
||||
time.sleep(0.5)
|
||||
return {"state": "done", "package": package}
|
||||
|
||||
def set_desktop_entry(self, package, enabled) -> dict:
|
||||
for g in self.games:
|
||||
if g["package"] == package:
|
||||
g["desktop_entry"] = enabled
|
||||
return {"enabled": enabled}
|
||||
|
||||
def describe(self) -> dict:
|
||||
return {"hostname": "steamframe", "os": "SteamOS", "os_version": "3.8", "build_id": "20260922",
|
||||
@@ -253,19 +283,12 @@ class FakeMonitorSession:
|
||||
"procs": procs, "filter": "game"}
|
||||
|
||||
def _run(self) -> None:
|
||||
self.t = 0
|
||||
while not self.closed and self.t < 100000:
|
||||
for self.t in range(100000):
|
||||
if self.closed:
|
||||
return
|
||||
self.on_sample(self._sample())
|
||||
if self.t >= self.BACKFILL: # the first two minutes at once (full charts), then one per second
|
||||
time.sleep(1)
|
||||
self.t += 1
|
||||
|
||||
def set_modules(self, m):
|
||||
# the hub asks for everything when the Monitor tab subscribes (the Frame card started the stream): send the
|
||||
# two minutes again so its charts are full in screenshots too
|
||||
if m == "all":
|
||||
self.t = 0
|
||||
return True
|
||||
|
||||
def set_interval(self, s): pass # noqa: E704
|
||||
def set_filter(self, w): pass # noqa: E704
|
||||
@@ -281,29 +304,6 @@ class FakeMonitorSession:
|
||||
self.closed = True
|
||||
|
||||
|
||||
FAKE_INSTALL_STAGES = ["Patching the game", "OVRPort (primary)", "Frame fixes (primary)", "sign (primary)",
|
||||
"validate (primary)", "Prepare Frame", "Upload APK"]
|
||||
|
||||
|
||||
def start_fake_install(app: FramePortApp, package: str) -> None:
|
||||
"""A running install job of `package` that reports the real stage names up to the upload (62 %) and then waits
|
||||
(it never ends: the smoke run exits with it)."""
|
||||
from frameport.ui.jobs import Job
|
||||
|
||||
hold, reached = threading.Event(), threading.Event()
|
||||
|
||||
def run(job):
|
||||
for stage in FAKE_INSTALL_STAGES:
|
||||
job.reporter.stage(stage)
|
||||
time.sleep(0.3) # past the job queue's notification throttle
|
||||
job.reporter.progress(0.62, "com.example.apk", speed="36.4 MB/s · ~1 min left")
|
||||
reached.set()
|
||||
hold.wait()
|
||||
|
||||
app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install"))
|
||||
reached.wait(10)
|
||||
|
||||
|
||||
def open_type_tab(app: FramePortApp) -> None:
|
||||
"""The Type on Frame tab, with one key 'pressed' so the screenshot shows it working."""
|
||||
app.type_on_frame()
|
||||
@@ -349,44 +349,18 @@ def add_fake_linux_apps() -> None:
|
||||
pipeline.add_linux_app(app)
|
||||
|
||||
|
||||
def install_fakes(game: str | None, monitor_session=None) -> None:
|
||||
"""--fake-frame: never reach a real Frame (start-up auto-connect, discovery, the 30 s poll); Type on Frame and
|
||||
the Monitor stream are fakes. `game` = the Monitor's game card (with its artwork)."""
|
||||
from frameport.frame import keyboard, monitor
|
||||
|
||||
keyboard.KeyboardSession = FakeKeyboardSession
|
||||
monitor.MonitorSession = monitor_session or FakeMonitorSession
|
||||
FakeMonitorSession.GAME = game
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
||||
|
||||
|
||||
def attach_fake_frame(app: FramePortApp, monitor_session=None) -> None:
|
||||
"""Connect `app` to a FakeTarget (the shared monitor stream, used by the Monitor tab and the Frame card, too)."""
|
||||
app.monitor_hub.session_factory = monitor_session or FakeMonitorSession
|
||||
app.target = FakeTarget()
|
||||
app.frame_info, app.frame_state = app.target.describe(), "connected"
|
||||
|
||||
|
||||
PAINT_SECONDS = 3 # after a step, before its screenshot: Flutter paints (and a view's content loads)
|
||||
STEP_TIMEOUT = 240 # the longest a step may wait for its screenshot (a crashed browser mustn't hang the run)
|
||||
|
||||
|
||||
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, shot_done: threading.Event,
|
||||
done_step: list):
|
||||
"""Runs one step, hands it to the shooter ("ready") and waits until its screenshot and mouse actions are done
|
||||
("shot_done") before the next one: so a screenshot always shows its own step, however long the shooter takes."""
|
||||
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, done_step: list):
|
||||
for name, action in steps:
|
||||
try:
|
||||
action(app)
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append(f"{name}: {traceback.format_exc()}")
|
||||
done_step.append(name)
|
||||
shot_done.clear()
|
||||
ready.set()
|
||||
if not shot_done.wait(STEP_TIMEOUT):
|
||||
ERRORS.append(f"{name}: no screenshot within {STEP_TIMEOUT} s, stopping")
|
||||
return
|
||||
time.sleep(STEP_SECONDS)
|
||||
|
||||
|
||||
def find_button(root, text: str):
|
||||
@@ -436,18 +410,18 @@ def main() -> int:
|
||||
"(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 "
|
||||
"(positions for --viewport 1440x900)")
|
||||
"right-clicks (menus) in Files, Screenshots and the Library; prints what got selected")
|
||||
ap.add_argument("--only", default=None, help="comma-separated step names to keep (e.g. monitor,monitor-details)")
|
||||
ap.add_argument("--linux", action="store_true", help="add two pretend arm64 Linux apps and render their pages")
|
||||
ap.add_argument("--links", action="store_true", help="install links: the Add games menu, the paste dialog, the "
|
||||
"confirmation for a pretend FrameDrop manifest, Settings → Install links and (with --game) the "
|
||||
"VR / flat window choice")
|
||||
ap.add_argument("--themes", action="store_true", help="Settings' theme picker, then switch every theme while "
|
||||
"running (Library and --game in each)")
|
||||
args = ap.parse_args()
|
||||
if args.linux:
|
||||
add_fake_linux_apps()
|
||||
global STEP_SECONDS
|
||||
if args.hover:
|
||||
STEP_SECONDS = 7
|
||||
from frameport.ui import theme
|
||||
|
||||
theme.set_scale(args.scale)
|
||||
@@ -458,20 +432,17 @@ def main() -> int:
|
||||
("files", lambda a: a.go("files")), ("screenshots", lambda a: a.go("screenshots")),
|
||||
("live", lambda a: a.go("live")), ("keyboard", lambda a: a.go("keyboard")),
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
("tools", lambda a: a.go("settings")),
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc"))]
|
||||
("tools", lambda a: a.go("settings"))]
|
||||
if game:
|
||||
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
|
||||
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game))),
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game)))]
|
||||
# a pretend install of --game, mid-upload: the transit in the sidebar, the game page and Activity
|
||||
steps += [("transit-game", lambda a: (a.page.pop_dialog(), start_fake_install(a, game), a.open_game(game))),
|
||||
("transit-activity", lambda a: a.show_activity(True))]
|
||||
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
|
||||
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
if args.docs:
|
||||
steps = [("library", lambda a: a.navigate(0))]
|
||||
@@ -487,33 +458,10 @@ def main() -> int:
|
||||
steps.append(("type-on-frame", lambda a: (a.page.pop_dialog(), open_type_tab(a))))
|
||||
steps.append(("monitor", lambda a: a.go("monitor")))
|
||||
steps.append(("power-confirm", lambda a: (a.page.pop_dialog(), a.frame_power("restart"))))
|
||||
# step name -> mouse actions (Playwright page) after its screenshot, then a "-mouse" screenshot unless the
|
||||
# actions return True (they saved their own picture, e.g. an opened menu)
|
||||
mouse: dict[str, callable] = {}
|
||||
hover: dict[str, tuple[float, float]] = {} # step name -> where the mouse rests for its screenshot
|
||||
if args.hover:
|
||||
hover["library"] = tuple(float(v) for v in args.hover.split(","))
|
||||
if not args.docs and args.viewport == "1440x900": # menus that open from buttons (positions at this size)
|
||||
def open_and_shoot(x, y, name):
|
||||
def run(page):
|
||||
page.mouse.click(x, y)
|
||||
time.sleep(2)
|
||||
page.screenshot(path=str(args.out / f"{name}.png"))
|
||||
page.mouse.click(1420, 880) # outside the menu (Flutter's popup ignores Escape)
|
||||
return True
|
||||
return run
|
||||
mouse["library"] = open_and_shoot(1354, 54, "library-add-menu")
|
||||
# type to search: click an empty part of the header, then type (the "-mouse" shot shows "arc" + the matches)
|
||||
steps.insert(1, ("library-typed", lambda a: a.library_view.clear_search()))
|
||||
mouse["library-typed"] = lambda page: (page.mouse.click(480, 60), time.sleep(0.5),
|
||||
page.keyboard.type("arc", delay=120))
|
||||
steps.insert(2, ("library-cleared", lambda a: a.library_view.clear_search()))
|
||||
if args.fake_frame: # the "On your Frame" shelf's first card, hovered: its Play button shows
|
||||
steps.insert(3, ("library-shelf-hover", lambda a: None))
|
||||
hover["library-shelf-hover"] = (403, 268)
|
||||
if game:
|
||||
mouse["game"] = open_and_shoot(491, 306, "game-more-menu") # the hero's "…"
|
||||
if args.gestures: # the mouse actions run after each step's screenshot, before the next step
|
||||
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])
|
||||
@@ -550,12 +498,11 @@ def main() -> int:
|
||||
"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((400, 300), (680, 300), (950, 300)), # 3 shots (the first row)
|
||||
"screenshots-dragged": right_click(950, 300),
|
||||
"screenshots": drag((680, 280), (950, 280), (950, 430)), # 3 shots
|
||||
"screenshots-dragged": right_click(950, 430),
|
||||
"screenshots-menu": escape,
|
||||
# the grid's first row (below the "On your Frame" shelf)
|
||||
"library": drag((360, 560), (560, 560), (760, 560)), # 3 cards, select mode on
|
||||
"library-dragged": drag((560, 560), (760, 560))} # from a selected card: deselects 2
|
||||
"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)
|
||||
@@ -567,7 +514,9 @@ def main() -> int:
|
||||
("linux-folder-customize", lambda a: a.open_game(LINUX_FOLDER, advanced=True)),
|
||||
("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)))]
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
from frameport import deeplink
|
||||
from frameport.ui.views import link_dialog
|
||||
@@ -591,7 +540,10 @@ def main() -> int:
|
||||
ImageDraw.Draw(im).ellipse((72, 72, 184, 184), fill=(255, 214, 102, 255))
|
||||
im.save(icon)
|
||||
|
||||
steps = [("library", lambda a: a.navigate(0)),
|
||||
def add_menu(a):
|
||||
a.navigate(0)
|
||||
time.sleep(2)
|
||||
steps = [("library", add_menu),
|
||||
("links-paste", lambda a: a.pick_link()),
|
||||
("links-confirm", lambda a: (a.page.pop_dialog(), link_dialog._confirm(a, fake, sizes, False, icon))),
|
||||
("links-settings", lambda a: (a.page.pop_dialog(), a.go("settings")))]
|
||||
@@ -602,31 +554,7 @@ def main() -> int:
|
||||
time.sleep(2)
|
||||
page.screenshot(path=str(args.out / "library-add-menu.png"))
|
||||
page.mouse.click(120, 520) # outside the menu (Flutter's popup ignores Escape)
|
||||
return True
|
||||
# tall pages: run with e.g. --viewport 1280x3200 (at 1440x900 the tour's own Add games click is kept)
|
||||
mouse.setdefault("library", add_menu_open)
|
||||
if args.themes:
|
||||
from frameport.ui import theme as T
|
||||
from frameport.ui.app import themes_dir
|
||||
|
||||
T.load_user_themes(themes_dir()) # installed theme files are switched through too
|
||||
|
||||
def switch(name):
|
||||
def run(a):
|
||||
library.set_setting("ui.theme", name) # what the picker does
|
||||
a.restyle(name)
|
||||
return run
|
||||
|
||||
def appearance(a):
|
||||
"""Settings: Appearance is its first section."""
|
||||
a.go("settings")
|
||||
steps = [("appearance", appearance)]
|
||||
for name in [n for n in T.THEMES if n != T.THEME] + [T.THEME]:
|
||||
steps.append((f"{name}-switched", switch(name)))
|
||||
steps.append((f"{name}-appearance", appearance))
|
||||
steps.append((f"{name}-library", lambda a: a.navigate(0)))
|
||||
if game:
|
||||
steps.append((f"{name}-game", lambda a: a.open_game(game)))
|
||||
mouse["library"] = add_menu_open # tall pages: run with e.g. --viewport 1280x3200
|
||||
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]
|
||||
@@ -687,67 +615,55 @@ def main() -> int:
|
||||
if args.only: # in the order given (e.g. frame-connected,monitor)
|
||||
by_name = dict(steps)
|
||||
steps = [(n, by_name[n]) for n in args.only.split(",") if n in by_name]
|
||||
# a first screen while the web fonts load: shot before that, text measured with a fallback font wrapped or
|
||||
# overlapped ("Q / uest", a check mark over its pill's text); its picture is 00-warmup.png. Settings, not the
|
||||
# Library: opening the Library twice restarts its card loading and the next shot caught it empty
|
||||
steps.insert(0, ("warmup", lambda a: a.go("settings")))
|
||||
ready, shot_done, done = threading.Event(), threading.Event(), []
|
||||
ready, done = threading.Event(), []
|
||||
|
||||
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
||||
install_fakes(game)
|
||||
from frameport.frame import keyboard, monitor
|
||||
|
||||
keyboard.KeyboardSession = FakeKeyboardSession
|
||||
monitor.MonitorSession = FakeMonitorSession
|
||||
FakeMonitorSession.GAME = game # the game card shows --game (with its artwork)
|
||||
FramePortApp.connect = lambda self, *a, **k: None
|
||||
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
||||
|
||||
def app_main(page: ft.Page):
|
||||
try:
|
||||
app = FramePortApp(page)
|
||||
if args.fake_frame:
|
||||
attach_fake_frame(app)
|
||||
app.target = FakeTarget()
|
||||
app.frame_info, app.frame_state = app.target.describe(), "connected"
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("startup: " + traceback.format_exc())
|
||||
return
|
||||
threading.Thread(target=driver, args=(app, steps, ready, shot_done, done), daemon=True).start()
|
||||
threading.Thread(target=driver, args=(app, steps, ready, done), daemon=True).start()
|
||||
|
||||
def shooter():
|
||||
# wait until the app's web server listens (a fixed 6 s was too short with another smoke run on the machine)
|
||||
import socket
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
for _ in range(90):
|
||||
try:
|
||||
socket.create_connection(("127.0.0.1", PORT), timeout=1).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
time.sleep(2)
|
||||
time.sleep(6)
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--use-gl=swiftshader", "--enable-unsafe-swiftshader"])
|
||||
page = browser.new_page(viewport={"width": vw, "height": vh})
|
||||
page.goto(f"http://127.0.0.1:{PORT}", wait_until="networkidle", timeout=120_000)
|
||||
shot = handled = 0
|
||||
while handled < len(steps): # one hand-off per step (see driver)
|
||||
if not ready.wait(STEP_TIMEOUT):
|
||||
ERRORS.append(f"browser: no step after {done[-1] if done else 'start'} "
|
||||
f"within {STEP_TIMEOUT} s")
|
||||
break
|
||||
ready.clear()
|
||||
name = done[-1]
|
||||
try:
|
||||
time.sleep(PAINT_SECONDS)
|
||||
if name in hover:
|
||||
page.mouse.move(*hover[name])
|
||||
shot = 0
|
||||
deadline = time.time() + 240 + 8 * len(steps)
|
||||
while len(done) < len(steps) and time.time() < deadline:
|
||||
if ready.wait(1):
|
||||
ready.clear()
|
||||
name = done[-1]
|
||||
time.sleep(3) # let Flutter paint
|
||||
if args.hover and name == "library":
|
||||
hx, hy = (float(v) for v in args.hover.split(","))
|
||||
page.mouse.move(hx, hy)
|
||||
time.sleep(1.5)
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}.png"))
|
||||
shot += 1
|
||||
if name in hover:
|
||||
page.mouse.move(130, 520) # the sidebar's empty part: nothing left hovered
|
||||
if name in mouse and mouse[name](page) is not True:
|
||||
time.sleep(PAINT_SECONDS) # e.g. typed search: the matching cards fade in
|
||||
if name in mouse:
|
||||
mouse[name](page)
|
||||
time.sleep(2)
|
||||
page.screenshot(path=str(args.out / f"{shot:02d}-{name}-mouse.png"))
|
||||
shot += 1
|
||||
finally:
|
||||
handled += 1
|
||||
shot_done.set()
|
||||
browser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
ERRORS.append("browser: " + traceback.format_exc())
|
||||
|
||||
|
Before Width: | Height: | Size: 248 KiB |
@@ -16,12 +16,67 @@ UNITY_GGM = "assets/bin/Data/globalgamemanagers"
|
||||
IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
|
||||
|
||||
def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
ANDROID_VERSIONS = {29: "10", 30: "11", 31: "12", 32: "12L", 33: "13", 34: "14", 35: "15", 36: "16", 37: "17"}
|
||||
FRAME_API = 30
|
||||
# Quest games declare up to API 32 (Quest's Android 12L) and run on the Frame (OVRPort lowers minSdk to 29; they
|
||||
# don't call newer Android classes). From API 33 on, apps call Android 13+ classes at start (GitHub #71/#72: minSdk
|
||||
# 34, NoClassDefFoundError android/window/OnBackInvokedCallback, NoSuchMethodError VarHandle.storeStoreFence).
|
||||
MAX_RUNNABLE_MIN_SDK = 32
|
||||
|
||||
|
||||
def android_version(api: int) -> str:
|
||||
"""'14' for API 34."""
|
||||
return ANDROID_VERSIONS.get(api, f"API {api}")
|
||||
|
||||
|
||||
def too_new_android(min_sdk: int | None) -> bool:
|
||||
"""The APK needs a newer Android than the Frame's Lepton (11): it crashes at start on missing Android classes."""
|
||||
return bool(min_sdk) and min_sdk > MAX_RUNNABLE_MIN_SDK
|
||||
|
||||
|
||||
TWA_URL_KEY = "android.support.customtabs.trusted.DEFAULT_URL"
|
||||
TWA_ACTIVITY = "com.google.androidbrowserhelper.trusted.LauncherActivity"
|
||||
UE_OBB_KEY = ".GameActivity.bHasOBBFiles" # com.epicgames.ue4.… (UE4) / com.epicgames.unreal.… (UE5)
|
||||
|
||||
|
||||
def expects_obb(meta: dict) -> bool:
|
||||
"""Unreal packaged the game's content as an OBB (expansion file) and opens it at start."""
|
||||
return any(k.endswith(UE_OBB_KEY) and v in (True, "true", "True") for k, v in meta.items())
|
||||
|
||||
|
||||
def _read_manifest_info(path: Path):
|
||||
"""(package, versionName, label, main activity, pyaxmlparser APK)."""
|
||||
from pyaxmlparser import APK
|
||||
|
||||
apk = APK(str(path))
|
||||
label = apk.application or apk.package
|
||||
return apk.package, apk.version_name or "", label, apk.get_main_activity()
|
||||
return apk.package, apk.version_name or "", label, apk.get_main_activity(), apk
|
||||
|
||||
|
||||
def _resolve_string(apk, value) -> str | None:
|
||||
"""A meta-data value: the string itself, or a @string/… reference resolved through resources.arsc."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if apk is None or isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
try:
|
||||
res = apk.get_android_resources()
|
||||
for _config, v in (res.get_resolved_res_configs(value) if res else ()):
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
except Exception: # noqa: BLE001 - a broken resource table only loses the URL
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def web_wrapper(meta: dict, manifest_strings: list[str], apk=None) -> dict | None:
|
||||
"""A Trusted Web Activity (Bubblewrap, Meta's PWA packaging): the APK only opens a website in a browser app
|
||||
(Meta's com.oculus.browser on Quest), which Lepton doesn't have (GitHub #86). {"url": str | None} or None."""
|
||||
if TWA_URL_KEY not in meta and TWA_ACTIVITY not in manifest_strings:
|
||||
return None
|
||||
url = _resolve_string(apk, meta.get(TWA_URL_KEY))
|
||||
return {"url": url if url and url.startswith(("https://", "http://")) else None}
|
||||
|
||||
|
||||
def _unreal_version(z: zipfile.ZipFile, lib: str) -> str | None:
|
||||
@@ -112,7 +167,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
ggm = z.read(UNITY_GGM) if deep and UNITY_GGM in names else None
|
||||
il2cpp_meta = z.read(IL2CPP_METADATA) if deep and IL2CPP_METADATA in names and "libil2cpp.so" in libs else None
|
||||
|
||||
package, version, label, activity = _read_manifest_info(path)
|
||||
package, version, label, activity, apk_info = _read_manifest_info(path)
|
||||
libset = set(libs)
|
||||
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
|
||||
else "CryEngine" if "libCrySystem.so" in libset else "Other")
|
||||
@@ -159,6 +214,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
manifest_strings = axml.Axml(manifest).strings()
|
||||
features = _features(manifest)
|
||||
used_perms, _ = axml.used_and_declared_permissions(manifest)
|
||||
meta = axml.meta_data(manifest)
|
||||
return Analysis(
|
||||
package=package,
|
||||
version=version,
|
||||
@@ -216,6 +272,13 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
# a 2D launcher activity that starts a separate VR activity (frame.start_activity)
|
||||
"vr_activity": axml.vr_activity(manifest),
|
||||
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
|
||||
# the minimum Android version the APK declares (read from the original: OVRPort lowers it to 29)
|
||||
"min_sdk": axml.min_sdk(manifest),
|
||||
# a website in an Android wrapper (TWA): nothing to port
|
||||
"web_wrapper": web_wrapper(meta, manifest_strings, apk_info),
|
||||
# Unreal packaged its content as an OBB (expansion file): without it the game hangs at start (GitHub #85).
|
||||
# (Unity's split-binary builds show no reliable sign in the APK.)
|
||||
"expects_obb": expects_obb(meta),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ RES_XML_START_ELEMENT = 0x0102
|
||||
RES_XML_END_ELEMENT = 0x0103
|
||||
TYPE_STRING = 0x03
|
||||
TYPE_INT_BOOLEAN = 0x12
|
||||
TYPE_REFERENCE = 0x01
|
||||
TYPE_INT_DEC = 0x10
|
||||
TYPE_INT_HEX = 0x11
|
||||
ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
LAUNCHER = "android.intent.category.LAUNCHER"
|
||||
INFO = "android.intent.category.INFO"
|
||||
@@ -214,6 +217,48 @@ def undeclared_meta_permissions(manifest: bytes) -> list[str]:
|
||||
return [p for p in used if p.startswith(("com.oculus.permission.", "horizonos.permission.")) and p not in declared]
|
||||
|
||||
|
||||
def min_sdk(manifest: bytes) -> int | None:
|
||||
"""<uses-sdk android:minSdkVersion> (None when absent or a preview codename)."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
for el in x.elements():
|
||||
if el.name != "uses-sdk":
|
||||
continue
|
||||
for a in el.attrs:
|
||||
if a.name < len(names) and names[a.name] == "minSdkVersion":
|
||||
if a.dtype in (TYPE_INT_DEC, TYPE_INT_HEX):
|
||||
return a.value
|
||||
s = x.attr_str(el, "minSdkVersion")
|
||||
return int(s) if s and s.isdigit() else None
|
||||
return None
|
||||
|
||||
|
||||
def meta_data(manifest: bytes) -> dict[str, str | int | bool | None]:
|
||||
"""<meta-data android:name android:value|resource> entries: a string, a bool, an int, or a resource id (int)
|
||||
for references (@string/…), None when the value isn't one of those."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
out: dict = {}
|
||||
for el in x.elements():
|
||||
if el.name != "meta-data":
|
||||
continue
|
||||
key = x.attr_str(el, "name")
|
||||
if not key:
|
||||
continue
|
||||
value = None
|
||||
for a in el.attrs:
|
||||
if a.name >= len(names) or names[a.name] not in ("value", "resource"):
|
||||
continue
|
||||
if a.raw < len(names) or a.dtype == TYPE_STRING:
|
||||
value = x.attr_str(el, names[a.name])
|
||||
elif a.dtype == TYPE_INT_BOOLEAN:
|
||||
value = a.value != 0
|
||||
elif a.dtype in (TYPE_INT_DEC, TYPE_INT_HEX, TYPE_REFERENCE):
|
||||
value = a.value
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def categories(manifest: bytes) -> set[str]:
|
||||
x = Axml(manifest)
|
||||
return {v for el in x.elements() if el.name == "category" for v in [x.attr_str(el, "name")] if v}
|
||||
|
||||
@@ -110,65 +110,3 @@ def prewarm(package: str) -> None:
|
||||
src = pick(package, kinds)
|
||||
if src:
|
||||
thumb(src, width, src.stem)
|
||||
compute_tint(package)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ cover tint
|
||||
TINT_ART = ("portrait", "square", "cover", "icon") # the library card's art (the cover people recognise)
|
||||
_tints: dict[str, str | None] = {} # package -> "#rrggbb" (None: no art / no usable colour)
|
||||
|
||||
|
||||
def tint_from_rgb(rgb: tuple[int, int, int]) -> str:
|
||||
"""A cover's dominant colour made to sit on the dark background: lightness 0.35-0.55, saturation at most 0.55."""
|
||||
import colorsys
|
||||
|
||||
h, lum, s = colorsys.rgb_to_hls(*(max(0, min(255, c)) / 255 for c in rgb))
|
||||
r, g, b = colorsys.hls_to_rgb(h, min(0.55, max(0.35, lum)), min(0.55, s))
|
||||
return "#{:02x}{:02x}{:02x}".format(*(round(c * 255) for c in (r, g, b)))
|
||||
|
||||
|
||||
def dominant_rgb(colors: list[tuple[int, tuple[int, int, int]]]) -> tuple[int, int, int] | None:
|
||||
"""The most common clearly coloured entry of [(count, rgb)] (saturation ≥ 0.25, neither near black nor near
|
||||
white); else the most common one overall. None for an empty list."""
|
||||
import colorsys
|
||||
|
||||
def colourful(rgb) -> bool:
|
||||
_, lum, s = colorsys.rgb_to_hls(*(c / 255 for c in rgb))
|
||||
return s >= 0.25 and 0.12 <= lum <= 0.88
|
||||
ranked = [rgb for _, rgb in sorted(colors, key=lambda c: -c[0])]
|
||||
return next((rgb for rgb in ranked if colourful(rgb)), ranked[0] if ranked else None)
|
||||
|
||||
|
||||
def compute_tint(package: str) -> str | None:
|
||||
"""The cover's tint, cached next to the thumbnails (t_tint_<sha8>.json, named after its source like a thumbnail)
|
||||
and in memory. Image work: call from a background thread (render paths use cached_tint)."""
|
||||
import json
|
||||
|
||||
src = pick(package, TINT_ART)
|
||||
value = None
|
||||
if src:
|
||||
out = src.parent / f"t_tint_{_digest(src)}.json"
|
||||
try:
|
||||
value = json.loads(out.read_text(encoding="utf-8")).get("tint")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(thumb(src, 400, src.stem)) as im:
|
||||
small = im.convert("RGB").resize((48, 48))
|
||||
q = small.quantize(colors=8)
|
||||
pal = q.getpalette() or []
|
||||
rgb = dominant_rgb([(n, tuple(pal[i * 3:i * 3 + 3])) for n, i in (q.getcolors() or [])])
|
||||
value = tint_from_rgb(rgb) if rgb else None
|
||||
for old in src.parent.glob("t_tint_*.json"):
|
||||
old.unlink(missing_ok=True)
|
||||
out.write_text(json.dumps({"tint": value}), encoding="utf-8")
|
||||
except Exception: # noqa: BLE001 (unreadable image: no tint)
|
||||
value = None
|
||||
_tints[package] = value
|
||||
return value
|
||||
|
||||
|
||||
def cached_tint(package: str) -> tuple[bool, str | None]:
|
||||
"""(known, tint) from memory only (no I/O, for render paths); known=False: compute_tint hasn't run yet."""
|
||||
return (package in _tints, _tints.get(package))
|
||||
@@ -10,7 +10,7 @@ from .apk.workspace import ApkWorkspace
|
||||
from .core.events import Reporter
|
||||
from .core.models import Analysis, BuildResult, Recipe, SourceGame
|
||||
from .core.paths import work_dir
|
||||
from .patches import base
|
||||
from .patches import base, upstream
|
||||
from .patches.overport import OVERPORT_PATCHES
|
||||
from .tools import overport as overport_tool
|
||||
from .validate.static import check_apk
|
||||
@@ -36,25 +36,28 @@ def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def apk_patches(recipe: Recipe) -> list[base.Patch]:
|
||||
patches = [base.get(pid) for pid in recipe.patches if base.get(pid).stage == "apk"]
|
||||
def apk_patches(selection: dict) -> list[base.Patch]:
|
||||
patches = [base.get(pid) for pid in selection if base.get(pid).stage == "apk"]
|
||||
return sorted(patches, key=lambda p: p.order)
|
||||
|
||||
|
||||
def apply_frame_fixes(apk_in: Path, apk_out_unsigned: Path, analysis: Analysis, recipe: Recipe,
|
||||
reporter: Reporter) -> tuple[list[str], list[dict]]:
|
||||
reporter: Reporter) -> tuple[list[str], list[dict], dict[str, str]]:
|
||||
"""Apply the recipe's apk-stage patches; returns (applied ids, checks, workarounds left out because OVRPort's
|
||||
output already has the upstream fix (patches/upstream.py))."""
|
||||
applied, checks = [], []
|
||||
with ApkWorkspace(apk_in) as ws:
|
||||
for patch in apk_patches(recipe):
|
||||
selection = upstream.resolve(ws, recipe.patches, reporter)
|
||||
for patch in apk_patches(selection.patches):
|
||||
reporter.check_cancel()
|
||||
ctx = base.ApkContext(ws, analysis, recipe.params(patch.id), reporter, recipe.patches)
|
||||
ctx = base.ApkContext(ws, analysis, selection.patches.get(patch.id) or {}, reporter, selection.patches)
|
||||
if patch.apply(ctx):
|
||||
applied.append(patch.id)
|
||||
reporter.log(f"applied {patch.id}" + (f" ({'; '.join(ctx.notes)})" if ctx.notes else ""))
|
||||
for name, ok, detail in patch.validate(ctx):
|
||||
checks.append({"name": name, "ok": ok, "detail": detail})
|
||||
ws.write(apk_out_unsigned)
|
||||
return applied, checks
|
||||
return applied, checks, selection.superseded
|
||||
|
||||
|
||||
def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path, reporter: Reporter,
|
||||
@@ -73,7 +76,7 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
variants = [("primary", False)] + ([("alt", True)] if recipe.alt_patches and recipe.overport else [])
|
||||
alt_copy = Path(source.apk).with_name(f"{pkg}.alt-noforcequit.apk")
|
||||
results = {}
|
||||
all_checks, applied = [], []
|
||||
all_checks, applied, superseded = [], [], {}
|
||||
try:
|
||||
for variant, alt in variants:
|
||||
input_apk = alt_copy if converted and alt and alt_copy.exists() else Path(source.apk)
|
||||
@@ -88,7 +91,9 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
shutil.copyfile(input_apk, patched)
|
||||
reporter.stage(f"Frame fixes ({variant})")
|
||||
unsigned = work / f"{pkg}.{variant}.unsigned.apk"
|
||||
applied, checks = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
|
||||
applied, checks, left_out = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
|
||||
if variant == "primary":
|
||||
superseded = left_out
|
||||
patched.unlink(missing_ok=True)
|
||||
reporter.stage(f"sign ({variant})")
|
||||
final = outdir / (f"{pkg}.apk" if not alt else f"{pkg}.alt-noforcequit.apk")
|
||||
@@ -106,4 +111,4 @@ def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path,
|
||||
primary, alt_apk = results["primary"], results.get("alt")
|
||||
return BuildResult(pkg, primary, alt_apk, sha256(primary), sha256(alt_apk) if alt_apk else None, applied,
|
||||
all_checks, {"overport": overport_ids(recipe), "alt_overport": overport_ids(recipe, True)
|
||||
if alt_apk else None})
|
||||
if alt_apk else None, "superseded": superseded})
|
||||
@@ -229,6 +229,8 @@ def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs
|
||||
for g in pipeline.add_path(path, rep):
|
||||
r = g["recipe"]
|
||||
typer.echo(f"{g['package']:40} {g.get('title', '')[:34]:34} {r['status']:11} {r['source']}")
|
||||
for warning in pipeline.analysis_warnings(g):
|
||||
typer.echo(f" ! {warning}")
|
||||
|
||||
|
||||
@app.command("catalog-update")
|
||||
@@ -329,6 +331,9 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
|
||||
typer.echo(f"{g.get('title')} ({pkg} {a['version']})\n engine {a['engine']}, XR {a['xr']}, {a['graphics']}, "
|
||||
f"ABIs {', '.join(a['abis'])}, direct VrApi: {a['direct_vrapi']}")
|
||||
typer.echo(f" status: {r['status']} recipe source: {r['source']} {r['notes']}")
|
||||
for warning in pipeline.analysis_warnings(g):
|
||||
if warning not in r["notes"]:
|
||||
typer.echo(f" ! {warning}")
|
||||
from .recommend.engine import visible_patches
|
||||
|
||||
shown, hidden = visible_patches(library.analysis_from_dict(a), library.recipe_from_dict(r))
|
||||
@@ -337,6 +342,8 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
|
||||
params = r["patches"].get(p.id) or {}
|
||||
typer.echo(f" [{'x' if on else ' '}] {p.id:36} {p.title}{' ' + json.dumps(params) if params else ''}"
|
||||
f"{' — ' + r['reasons'][p.id] if on and p.id in r.get('reasons', {}) else ''}")
|
||||
for pid, fix in ((g.get("build") or {}).get("superseded") or {}).items():
|
||||
typer.echo(f" last build left out {pid}: fixed upstream ({fix})")
|
||||
if hidden and not all_:
|
||||
typer.echo(f" ({len(hidden)} patches hidden as not relevant for this game; --all to list them)")
|
||||
if r.get("alt_patches"):
|
||||
@@ -414,13 +421,20 @@ def install(package: Optional[str] = typer.Argument(None), all_: bool = typer.Op
|
||||
no_library: bool = typer.Option(False, help="don't add to the Steam library now"),
|
||||
to: str = typer.Option("frame", help="frame, or pc (PC VR games only: install on this PC)"),
|
||||
apk: Optional[Path] = typer.Option(None, help="install this APK instead of the last build (one game; e.g. "
|
||||
"a test build signed with the game's key)")):
|
||||
"a test build signed with the game's key)"),
|
||||
dest: Optional[str] = typer.Option(None, help="drive for new installs: 'internal' or a drive's path from "
|
||||
"'frameport frame drives' (default: the app's setting); "
|
||||
"installed games stay where they are")):
|
||||
"""Install games on the Frame (or PC VR games on this PC) and add them to the Steam library."""
|
||||
target = _target(frame, password, to)
|
||||
if dest is not None and hasattr(target, "dest"):
|
||||
target.dest = "" if dest == "internal" else dest
|
||||
pkgs = _pkgs(package, all_)
|
||||
if apk and len(pkgs) != 1:
|
||||
raise typer.BadParameter("--apk needs exactly one game")
|
||||
for pkg in pkgs:
|
||||
if not apk_only and pipeline.missing_obb(library.game(pkg) or {}):
|
||||
typer.echo(f"{pkg}: warning: " + pipeline.MISSING_OBB_NOTE.format(package=pkg), err=True)
|
||||
pipeline.install_game(pkg, target, printing_reporter(False), apk_only, add_to_library=False, apk=apk)
|
||||
if not no_library:
|
||||
target.add_to_library(pkgs, printing_reporter(False))
|
||||
@@ -553,6 +567,38 @@ def frame_storage(game: Optional[str] = typer.Option(None, help="also show this
|
||||
typer.echo(f"{t['id']:10} {t['android']:45} {t['path']}" + (" (every app)" if t["shared"] else ""))
|
||||
|
||||
|
||||
@frame_app.command("drives")
|
||||
def frame_drives(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
|
||||
"""The Frame's drives (internal storage, a microSD card) games can be installed on or moved to."""
|
||||
from .install import drives
|
||||
|
||||
found = _target(frame).drives()
|
||||
if as_json:
|
||||
typer.echo(json.dumps(found, indent=1))
|
||||
return
|
||||
current = drives.install_dest()
|
||||
for d in found:
|
||||
free = drives.free_text(d) or "?"
|
||||
mark = "*" if (d["internal"] and not current) or current in (d["install_dir"], d["path"]) else " "
|
||||
state = f"{d.get('games', 0)} game(s)" if d.get("usable") else f"can't be used: {d.get('reason')}"
|
||||
typer.echo(f"{mark} {d['label']:20} {d.get('fstype') or '':6} {free:>16} {state}")
|
||||
if not d["internal"]:
|
||||
typer.echo(f" {'':20} {d['path']}")
|
||||
typer.echo("* = where new games go (frameport install --dest, or the app's Settings: Frame)")
|
||||
|
||||
|
||||
@frame_app.command("move")
|
||||
def frame_move(package: str = typer.Argument(..., help="an installed game"),
|
||||
to_: str = typer.Option(..., "--to", help="'internal' or a drive's path from 'frameport frame drives'"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Move an installed game's files to another drive of the Frame (e.g. the microSD card) or back. Saves, the
|
||||
Steam library entry and settings stay as they are."""
|
||||
pkg = _pkgs(package, False)[0]
|
||||
st = _target(frame).move(pkg, to_, printing_reporter())
|
||||
typer.echo(f"moved to {st.get('base') or st.get('to')}")
|
||||
|
||||
|
||||
@frame_app.command("info")
|
||||
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
|
||||
|
||||
@@ -15,8 +15,6 @@ from collections.abc import Callable
|
||||
from ..core import applog
|
||||
|
||||
MIN_AGENT = 62 # first agent with `_monitor`
|
||||
MIN_AGENT_MODULES = 63 # first agent that takes {"modules": [...]} (collects only what's asked for)
|
||||
MODULES = ("games", "procs", "cpu", "gpu", "mem", "temps", "power", "battery", "net", "disk") # agent MON_MODULES
|
||||
HISTORY = 120 # points per sparkline (2 min at 1 s)
|
||||
REPLY_TIMEOUT = 15.0
|
||||
|
||||
@@ -103,18 +101,6 @@ class MonitorSession:
|
||||
def pause(self, paused: bool) -> None:
|
||||
self.send({"pause": bool(paused)})
|
||||
|
||||
@property
|
||||
def supports_modules(self) -> bool:
|
||||
return int(self.static.get("agent") or 0) >= MIN_AGENT_MODULES
|
||||
|
||||
def set_modules(self, modules) -> bool:
|
||||
"""Ask the agent to collect only these modules (an iterable of MODULES names, or "all"). False (nothing sent)
|
||||
when the agent is older than MIN_AGENT_MODULES: it keeps sending everything."""
|
||||
if not self.supports_modules:
|
||||
return False
|
||||
self.send({"modules": "all" if modules == "all" else sorted(modules)})
|
||||
return True
|
||||
|
||||
def _request(self, msg: dict, timeout: float = REPLY_TIMEOUT) -> dict:
|
||||
rid = next(self._ids)
|
||||
slot = [threading.Event(), None]
|
||||
@@ -233,7 +219,7 @@ def fmt_duration(s: float | None) -> str:
|
||||
|
||||
|
||||
def fmt_pct(v: float | None) -> str:
|
||||
return "–" if v is None else f"{v:.0f}%"
|
||||
return "–" if v is None else f"{v:.0f} %"
|
||||
|
||||
|
||||
# (warn, error) thresholds; "low" metrics warn when the value falls below them
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
"""MonitorHub: one app-owned `_monitor` stream shared by every place that shows live Frame data (the Monitor tab, the
|
||||
sidebar's live Frame card). Subscribers name the modules they need and how often; the hub runs at most one
|
||||
MonitorSession with the union of the active subscribers' modules and the fastest interval, reconfigures it when
|
||||
subscribers change, fans every sample out, reconnects every RETRY_SECONDS after the stream was lost and stops it when
|
||||
nobody (unpaused) is left. No Flet here: callbacks run on the session's reader thread or the hub's own threads.
|
||||
|
||||
States passed to `on_state(name, state, detail)`: "connecting", "live", "lost" (detail = why; a retry is scheduled),
|
||||
"error" (detail = the exception; no automatic retry, `reconnect()` tries again)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..core import applog
|
||||
from . import monitor as M
|
||||
|
||||
RETRY_SECONDS = 5.0 # reconnect after the stream was lost, while someone subscribes
|
||||
ALL = "all"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscriber:
|
||||
name: str
|
||||
modules: frozenset | str # MODULES names or "all"
|
||||
interval: float
|
||||
on_sample: Callable[[dict], None]
|
||||
on_state: Callable[[str, str, object], None] | None = None
|
||||
paused: bool = False
|
||||
|
||||
|
||||
def norm_modules(modules) -> frozenset | str:
|
||||
if modules is None or modules == ALL:
|
||||
return ALL
|
||||
if isinstance(modules, str):
|
||||
return frozenset({modules})
|
||||
return frozenset(modules)
|
||||
|
||||
|
||||
def snap_interval(seconds: float) -> float:
|
||||
"""The agent only takes M.INTERVALS: the slowest one that is at least as fast as asked."""
|
||||
fits = [i for i in M.INTERVALS if i <= seconds + 1e-9]
|
||||
return float(max(fits) if fits else M.INTERVALS[0])
|
||||
|
||||
|
||||
def merge(subs: Iterable[Subscriber]) -> tuple[frozenset | str | None, float | None]:
|
||||
"""(modules, interval) one stream needs for the active (unpaused) subscribers: the union of their modules ("all"
|
||||
if any wants everything) and the fastest interval. (None, None) when nobody is active."""
|
||||
active = [s for s in subs if not s.paused]
|
||||
if not active:
|
||||
return None, None
|
||||
if any(s.modules == ALL for s in active):
|
||||
modules: frozenset | str = ALL
|
||||
else:
|
||||
modules = frozenset().union(*(s.modules for s in active))
|
||||
return modules, snap_interval(min(s.interval for s in active))
|
||||
|
||||
|
||||
def _timer(delay: float, fn: Callable[[], None]):
|
||||
t = threading.Timer(delay, fn)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _thread(fn: Callable[[], None]) -> None:
|
||||
threading.Thread(target=fn, name="monitor-hub-connect", daemon=True).start()
|
||||
|
||||
|
||||
class MonitorHub:
|
||||
def __init__(self, get_target: Callable[[], object], session_factory=M.MonitorSession,
|
||||
retry_seconds: float = RETRY_SECONDS, spawn: Callable = _thread, timer: Callable = _timer):
|
||||
self.get_target = get_target
|
||||
self.session_factory = session_factory
|
||||
self.retry_seconds = retry_seconds
|
||||
self._spawn = spawn
|
||||
self._timer = timer
|
||||
self._lock = threading.RLock()
|
||||
self._subs: dict[str, Subscriber] = {}
|
||||
self.session = None
|
||||
self.static: dict = {}
|
||||
self.state, self.detail = "idle", None
|
||||
self._gen = 0
|
||||
self._connecting = False
|
||||
self._pending = None # generation whose connect waits for the lock to be released
|
||||
self._retry = None
|
||||
self._applied: tuple = (ALL, M.DEFAULT_INTERVAL) # what the live session runs with
|
||||
self._filter = "game"
|
||||
self._stream_paused = False
|
||||
self.closed = False
|
||||
|
||||
# ---------------------------------------------------------------- subscribers
|
||||
def subscribe(self, name: str, modules, interval: float, on_sample: Callable[[dict], None],
|
||||
on_state: Callable[[str, str, object], None] | None = None) -> None:
|
||||
with self._lock:
|
||||
self._subs[name] = Subscriber(name, norm_modules(modules), float(interval), on_sample, on_state)
|
||||
note = self._reconcile()
|
||||
self._after(note, name)
|
||||
|
||||
def update(self, name: str, modules=None, interval: float | None = None) -> None:
|
||||
with self._lock:
|
||||
sub = self._subs.get(name)
|
||||
if sub is None:
|
||||
return
|
||||
if modules is not None:
|
||||
sub.modules = norm_modules(modules)
|
||||
if interval is not None:
|
||||
sub.interval = float(interval)
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def unsubscribe(self, name: str) -> None:
|
||||
with self._lock:
|
||||
if self._subs.pop(name, None) is None:
|
||||
return
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def pause(self, name: str, paused: bool = True) -> None:
|
||||
"""A paused subscriber gets no samples and doesn't count towards the stream's modules/interval."""
|
||||
with self._lock:
|
||||
sub = self._subs.get(name)
|
||||
if sub is None or sub.paused == bool(paused):
|
||||
return
|
||||
sub.paused = bool(paused)
|
||||
note = self._reconcile()
|
||||
self._after(note, None if paused else name)
|
||||
|
||||
def subscribed(self, name: str) -> bool:
|
||||
return name in self._subs
|
||||
|
||||
def reconnect(self) -> None:
|
||||
"""Try again now (after an "error", or when the Frame connection came back)."""
|
||||
with self._lock:
|
||||
if self.session is not None or self._connecting or self.closed:
|
||||
return
|
||||
self._cancel_retry()
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
self._subs.clear()
|
||||
self._stop()
|
||||
|
||||
# ---------------------------------------------------------- pass-through actions
|
||||
def set_filter(self, which: str) -> None:
|
||||
"""The process filter ("game" | "steam" | "all"); kept for reconnects."""
|
||||
with self._lock:
|
||||
self._filter = which
|
||||
session = self.session
|
||||
if session is not None:
|
||||
session.set_filter(which)
|
||||
|
||||
def pause_stream(self, paused: bool) -> None:
|
||||
"""Pause the agent's samples for every subscriber (the stream stays open; kept for reconnects)."""
|
||||
with self._lock:
|
||||
self._stream_paused = bool(paused)
|
||||
session = self.session
|
||||
if session is not None:
|
||||
session.pause(bool(paused))
|
||||
|
||||
def _live(self):
|
||||
session = self.session
|
||||
if session is None:
|
||||
raise M.MonitorUnavailable("the monitor isn't connected")
|
||||
return session
|
||||
|
||||
def kill(self, pid: int, sig: str = "TERM", force: bool = False) -> dict:
|
||||
return self._live().kill(pid, sig, force)
|
||||
|
||||
def end_game(self, package: str) -> dict:
|
||||
return self._live().end_game(package)
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
def _reconcile(self) -> list:
|
||||
"""Start, stop or reconfigure the stream for the current subscribers. Called with the lock held; returns the
|
||||
state notifications to send once it's released."""
|
||||
if self.closed:
|
||||
return []
|
||||
modules, interval = merge(self._subs.values())
|
||||
if modules is None:
|
||||
self._stop()
|
||||
return []
|
||||
if self.session is not None:
|
||||
self._configure(self.session, modules, interval)
|
||||
return []
|
||||
if self._connecting or self._retry is not None:
|
||||
return []
|
||||
return self._start()
|
||||
|
||||
def _configure(self, session, modules, interval) -> None:
|
||||
old_modules, old_interval = self._applied
|
||||
try:
|
||||
if interval != old_interval:
|
||||
session.set_interval(interval)
|
||||
if modules != old_modules:
|
||||
session.set_modules(modules)
|
||||
except Exception: # noqa: BLE001 - the stream ending is reported through on_end
|
||||
applog.log.exception("monitor hub: reconfigure failed")
|
||||
self._applied = (modules, interval)
|
||||
|
||||
def _start(self) -> list:
|
||||
self._connecting = True
|
||||
self._set_state("connecting", None)
|
||||
self._pending = self._gen # connected by _after() once the lock is released
|
||||
return self._targets()
|
||||
|
||||
def _connect(self, gen: int) -> None:
|
||||
try:
|
||||
target = self.get_target()
|
||||
if target is None:
|
||||
raise M.MonitorUnavailable("not connected to a Frame")
|
||||
session = self.session_factory(target.frame, lambda s, g=gen: self._on_sample(g, s),
|
||||
lambda why, g=gen: self._on_end(g, why))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
with self._lock:
|
||||
if gen != self._gen:
|
||||
return
|
||||
self._connecting = False
|
||||
self._set_state("error", exc)
|
||||
note = self._targets()
|
||||
self._notify(note)
|
||||
return
|
||||
with self._lock:
|
||||
self._connecting = False
|
||||
modules, interval = merge(self._subs.values())
|
||||
if gen != self._gen or self.closed or modules is None: # nobody left while connecting
|
||||
stale = True
|
||||
else:
|
||||
stale = False
|
||||
self.session = session
|
||||
self.static = getattr(session, "static", {}) or {}
|
||||
self._applied = (ALL, M.DEFAULT_INTERVAL) # the agent's defaults
|
||||
self._configure(session, modules, interval)
|
||||
if self._filter != "game":
|
||||
session.set_filter(self._filter)
|
||||
if self._stream_paused:
|
||||
session.pause(True)
|
||||
self._set_state("live", None)
|
||||
note = self._targets()
|
||||
if stale:
|
||||
session.close()
|
||||
return
|
||||
self._notify(note)
|
||||
|
||||
def _on_sample(self, gen: int, sample: dict) -> None:
|
||||
if gen != self._gen:
|
||||
return
|
||||
for sub in [s for s in list(self._subs.values()) if not s.paused]:
|
||||
try:
|
||||
sub.on_sample(sample)
|
||||
except Exception: # noqa: BLE001 - one subscriber's bug must not starve the others
|
||||
applog.log.exception("monitor hub: subscriber %s failed", sub.name)
|
||||
|
||||
def _on_end(self, gen: int, why: str) -> None:
|
||||
with self._lock:
|
||||
if gen != self._gen or self.closed:
|
||||
return
|
||||
self.session = None
|
||||
if merge(self._subs.values())[0] is None:
|
||||
return
|
||||
self._set_state("lost", why)
|
||||
note = self._targets()
|
||||
self._retry = self._timer(self.retry_seconds, lambda: self._retry_now(gen))
|
||||
self._notify(note)
|
||||
|
||||
def _retry_now(self, gen: int) -> None:
|
||||
with self._lock:
|
||||
if gen != self._gen or self.closed:
|
||||
return
|
||||
self._retry = None
|
||||
note = self._reconcile()
|
||||
self._after(note)
|
||||
|
||||
def _cancel_retry(self) -> None:
|
||||
if self._retry is not None:
|
||||
try:
|
||||
self._retry.cancel()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._retry = None
|
||||
|
||||
def _stop(self) -> None:
|
||||
"""No active subscriber: end the stream (and anything pending). Lock held."""
|
||||
self._gen += 1
|
||||
self._connecting = False
|
||||
self._cancel_retry()
|
||||
session, self.session = self.session, None
|
||||
self.state, self.detail = "idle", None
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _set_state(self, state: str, detail) -> None:
|
||||
self.state, self.detail = state, detail
|
||||
|
||||
def _targets(self) -> list:
|
||||
return [(s, self.state, self.detail) for s in self._subs.values() if not s.paused and s.on_state]
|
||||
|
||||
def _tell(self, name: str) -> None:
|
||||
"""Bring one (new or resumed) subscriber up to date with the stream's state."""
|
||||
sub = self._subs.get(name)
|
||||
if sub and sub.on_state and not sub.paused and self.state != "idle":
|
||||
self._notify([(sub, self.state, self.detail)])
|
||||
|
||||
def _after(self, note: list, name: str | None = None) -> None:
|
||||
"""Outside the lock: send state notifications (or bring `name` up to date), then start a queued connect."""
|
||||
if note:
|
||||
self._notify(note)
|
||||
elif name is not None:
|
||||
self._tell(name)
|
||||
with self._lock:
|
||||
gen, self._pending = self._pending, None
|
||||
if gen is not None:
|
||||
self._spawn(lambda: self._connect(gen))
|
||||
|
||||
@staticmethod
|
||||
def _notify(note: list) -> None:
|
||||
for sub, state, detail in note:
|
||||
try:
|
||||
sub.on_state(sub.name, state, detail)
|
||||
except Exception: # noqa: BLE001
|
||||
applog.log.exception("monitor hub: state handler of %s failed", sub.name)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Games on another drive of the Frame (a microSD card, GitHub #90): which drives there are, where new games go
|
||||
(library setting `install.drive`) and moving an installed game. No Flet here (the CLI and GUI share it).
|
||||
|
||||
The agent keeps each game's anchor (launch.sh, deployment.json, artwork) on internal storage and its files in
|
||||
`<drive>/FramePort/<package>`; a drive is given by its install dir (or mount point), internal storage by "internal"."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from ..core import library
|
||||
from ..core.events import Reporter
|
||||
from ..frame.connection import AgentFailed, Frame
|
||||
|
||||
SETTING = "install.drive"
|
||||
INTERNAL = "internal"
|
||||
|
||||
|
||||
def list_drives(frame: Frame) -> list[dict]:
|
||||
"""The agent's `drives`: [{id, path, install_dir, label, fstype, internal, removable, free_bytes, total_bytes,
|
||||
usable, reason, games}]."""
|
||||
return frame.agent("drives")["drives"]
|
||||
|
||||
|
||||
def install_dest() -> str | None:
|
||||
"""Where new games go (None = internal storage)."""
|
||||
value = library.setting(SETTING) or INTERNAL
|
||||
return None if value == INTERNAL else value
|
||||
|
||||
|
||||
def set_install_dest(install_dir: str | None) -> None:
|
||||
library.set_setting(SETTING, install_dir or INTERNAL)
|
||||
|
||||
|
||||
def free_text(d: dict) -> str:
|
||||
""""112.4 GiB free" ("" when unknown)."""
|
||||
from ..i18n import tr
|
||||
|
||||
free = d.get("free_bytes")
|
||||
return tr("{size:.1f} GiB free").format(size=free / 2**30) if isinstance(free, (int, float)) else ""
|
||||
|
||||
|
||||
def drive_text(d: dict) -> str:
|
||||
""""SD Card · 112.4 GiB free" (labels for menus and dropdowns)."""
|
||||
return " · ".join(filter(None, [d.get("label") or d.get("path"), free_text(d)]))
|
||||
|
||||
|
||||
def game_drive(dep: dict | None) -> dict | None:
|
||||
"""{"internal", "path", "label", "missing"} of an installed game (the agent's list_installed entry), or None for
|
||||
agents before v63."""
|
||||
if not dep or "drive" not in dep:
|
||||
return None
|
||||
return {**dep["drive"], "missing": bool(dep.get("drive_missing"))}
|
||||
|
||||
|
||||
def is_on(dep: dict | None, d: dict) -> bool:
|
||||
"""The game (list_installed entry) is on drive d (a `drives` entry)."""
|
||||
g = game_drive(dep)
|
||||
if not g:
|
||||
return d.get("internal", False)
|
||||
return bool(g["internal"]) if d.get("internal") else (not g["internal"] and g["path"] == d["path"])
|
||||
|
||||
|
||||
def move(frame: Frame, package: str, dest: str, reporter: Reporter, poll: float = 2.0,
|
||||
timeout: float = 6 * 3600, sleep=time.sleep) -> dict:
|
||||
"""Move an installed game to `dest` (a drive's install dir or "internal"). The copy runs on the Frame by itself;
|
||||
this follows its progress (a reconnect picks a running move of the same game up again). Cancel works until the
|
||||
copy has started."""
|
||||
reporter.stage("Move game files")
|
||||
st = frame.agent("move_status")
|
||||
if not (st.get("state") == "running" and st.get("package") == package):
|
||||
reporter.check_cancel()
|
||||
try:
|
||||
st = frame.agent("move", package=package, dest=dest)
|
||||
except AgentFailed as exc:
|
||||
# a job that lost the Frame runs again: the move it started may have finished meanwhile
|
||||
if "already there" in str(exc):
|
||||
reporter.check("Game files moved", True, "already there")
|
||||
return {"state": "done", "package": package}
|
||||
raise
|
||||
reporter.log(f"moving {st['from']} → {st['to']} ({st['total_bytes'] / 2**30:.1f} GiB"
|
||||
+ (", same drive" if st.get("same_drive") else "") + ")")
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
st = frame.agent("move_status")
|
||||
if st.get("state") in ("done", "failed"):
|
||||
break
|
||||
total, done = st.get("total_bytes") or 0, st.get("done_bytes") or 0
|
||||
phase = {"copying": "Copying", "verifying": "Checking the copy", "removing": "Removing the old copy"}.get(
|
||||
st.get("phase") or "", "Starting")
|
||||
reporter.progress(done / total if total else 0.0,
|
||||
f"{phase} {done / 2**30:.1f}/{total / 2**30:.1f} GiB" if total else phase)
|
||||
sleep(poll)
|
||||
else:
|
||||
raise RuntimeError("the move didn't finish in time; it may still be running on the Frame")
|
||||
if st.get("state") != "done":
|
||||
raise RuntimeError(f"moving failed: {st.get('error') or 'unknown error'} (the game stays where it was)")
|
||||
reporter.check("Game files moved", True, st.get("base") or st.get("to") or "")
|
||||
return st
|
||||
@@ -228,6 +228,8 @@ class LinuxPlan:
|
||||
appimage: bool = False
|
||||
openxr: bool = False
|
||||
x86_64: bool = False # runs through FEX (installed on the Frame first, like Proton)
|
||||
dest: str | None = None # a drive's install dir for a new install (GitHub #90); None = internal storage
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -236,7 +238,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
if plan.x86_64:
|
||||
ensure_proton(frame, reporter, kind="linux_x86")
|
||||
reporter.stage("Prepare Frame")
|
||||
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title)
|
||||
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title, dest=plan.dest)
|
||||
if plan.files:
|
||||
manifest = {name: (plan.root / name).stat().st_size for name in plan.files}
|
||||
else:
|
||||
@@ -259,8 +261,12 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
executables = [rel for rel in manifest if rel != plan.exe and _is_elf(plan.root / rel)][:200]
|
||||
result = frame.agent("finalize_linux", package=plan.package, title=plan.title, exe=plan.exe,
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), timeout=900)
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
if result.get("desktop_entry"):
|
||||
reporter.check("Desktop Mode entry", True, "in the application menu"
|
||||
+ (" and on the desktop" if result["desktop_entry"].get("desktop") else ""))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
" · link": "",
|
||||
" · not installed yet": "",
|
||||
" · on the Frame: ": "",
|
||||
" · on {label}": "",
|
||||
" · restart the Frame soon to reset it": "",
|
||||
" · Revive provides the Oculus runtime": "",
|
||||
" · {human} in files": "",
|
||||
" · {label} not inserted": "",
|
||||
" · {queued} more queued": "",
|
||||
" · {value:.0f} MiB": "",
|
||||
" · {value:.1f} GiB": "",
|
||||
@@ -52,7 +54,7 @@
|
||||
"90 Hz": "",
|
||||
"920 × 430": "",
|
||||
"96 Hz": "",
|
||||
"A folder with Android games (APK + OBB, for example Quest games) or PC VR games.": "",
|
||||
"A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games.": "",
|
||||
"A game is running on the Frame": "",
|
||||
"A game is running on the Frame. Close it in the headset first, then try again.": "",
|
||||
"a known blocker": "",
|
||||
@@ -60,15 +62,14 @@
|
||||
"A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android container.": "",
|
||||
"A native Linux program built for arm64 (aarch64), like the Frame's own CPU. It's installed unchanged and runs directly on SteamOS (no Android container, no Proton), started from the Frame's Steam library. VR apps use the Frame's OpenXR runtime.": "",
|
||||
"A new FramePort version is ready to install": "",
|
||||
"A newer known-good recipe for this game is available (you changed this game's settings, so it wasn't applied by itself).": "",
|
||||
"A newer known-good config for this game is available (you changed this game's settings, so it wasn't applied by itself).": "",
|
||||
"A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch for why it's there.": "",
|
||||
"a source checkout (git pull + uv sync)": "",
|
||||
"A VR app made for another Android headset. OpenXR apps are translated like Quest games; apps built on a headset maker's own SDK (Pico, HTC Wave) can't run on the Frame.": "",
|
||||
"A web page asked FramePort to install {name}.": "",
|
||||
"A Windows game without VR, added with \"Add a PC game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
|
||||
"A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
|
||||
"A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).": "",
|
||||
"A Windows program (.exe)": "",
|
||||
"A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's runtime details — enough to debug without the game files. IP addresses, host and user names, home folders and Steam IDs are replaced by placeholders. Attach it to a GitHub issue.": "",
|
||||
"A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's runtime details — enough to debug without the game files. IP addresses, host and user names, home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.": "",
|
||||
"ABIs": "",
|
||||
"About": "",
|
||||
"About this game": "",
|
||||
@@ -77,33 +78,33 @@
|
||||
"Activity": "",
|
||||
"Add a Linux app folder…": "",
|
||||
"Add a Linux app…": "",
|
||||
"Add a PC game folder…": "",
|
||||
"Add a tag": "",
|
||||
"Add a Windows program…": "",
|
||||
"Add a Windows program (.exe)…": "",
|
||||
"Add an APK file": "",
|
||||
"Add an APK file…": "",
|
||||
"Add an APK…": "",
|
||||
"Add from a link…": "",
|
||||
"Add games": "",
|
||||
"Add one game folder…": "",
|
||||
"Add this tag": "",
|
||||
"Add to Steam library: {title}": "",
|
||||
"Add videos": "",
|
||||
"Add videos and files": "",
|
||||
"Add videos and files…": "",
|
||||
"Add your games": "",
|
||||
"Add {name}": "",
|
||||
"Added automatically (engine, VR API or store genre)": "",
|
||||
"Added automatically when needed": "",
|
||||
"Added to Desktop Mode": "",
|
||||
"Added {n} game": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Added {title}": "",
|
||||
"Added {title}: it starts with {exe}. Not the right program? Change it on its page.": "",
|
||||
"Adding to the library": "",
|
||||
"Adds a launcher entry point (Lepton additionally needs category LAUNCHER, see the Frame 'launcher' fix).": "",
|
||||
"Adds a real ovrMessageType_ToString (OVRPort's platform compat library) for games whose platform loader lacks it (e.g. The Climb 2). OVRPort 1.2.5+ adds the same library itself; then this is skipped.": "",
|
||||
"Adds a small piece of Meta's platform library that some games need to start.": "",
|
||||
"Adds no-op com.oculus.os.AnalyticsEvent / UnifiedTelemetryLogger classes for native code that looks them up through the app class loader (e.g. Nano, NOPE Challenge).": "",
|
||||
"Adds OVRPort's OpenXR loader dispatcher and platform loader. Required: without it nothing is translated.": "",
|
||||
"Adds the app to Desktop Mode's application menu and puts an icon on its desktop. Some apps work better there, with a mouse and keyboard, than in Gaming Mode, where Steam Input turns the controllers into a gamepad.": "",
|
||||
"Adds the parts that let a Quest game talk to the Steam Frame. Needed for every Quest game.": "",
|
||||
"Adds the VR app metadata other launchers expect.": "",
|
||||
"Advanced": "",
|
||||
@@ -124,10 +125,8 @@
|
||||
"Also in your library: {value} version": "",
|
||||
"Also remove FramePort's games and files from the Frame": "",
|
||||
"An AppImage is a Linux app packed into one file. FramePort unpacks it on the Frame once, so it starts without FUSE.": "",
|
||||
"An AppImage, a folder or a .zip/.tar archive with a Linux program, best built for arm64 (aarch64). x86_64 builds run through FEX, Valve's x86 translator, but slower. The app must bring the libraries SteamOS doesn't have.": "",
|
||||
"an installed Python package (reinstalled from the release)": "",
|
||||
"An AppImage, a folder or a .zip/.tar archive with a Linux program built for arm64 (aarch64). x86_64 builds can't run on the Frame. The app must bring the libraries SteamOS doesn't have.": "",
|
||||
"An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's Android container.": "",
|
||||
"Analyze": "",
|
||||
"Analyze again": "",
|
||||
"Analyze {title} again": "",
|
||||
"Android": "",
|
||||
@@ -144,7 +143,6 @@
|
||||
"Apply the suggested patches and reinstall": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.": "",
|
||||
"arm64 (runs natively)": "",
|
||||
"Artwork": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
"Artwork updated for {get}": "",
|
||||
@@ -168,38 +166,39 @@
|
||||
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (e.g. 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
|
||||
"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 (for example 4XVR's Internal Storage = 4XPlayer): upload into that folder in the game's storage.": "",
|
||||
"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.": "",
|
||||
"Camera": "",
|
||||
"can't hold games: {reason}": "",
|
||||
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
|
||||
"Can't run": "",
|
||||
"Can't use that link: {reason}": "",
|
||||
"Cancel": "",
|
||||
"Cancel all": "",
|
||||
"canceled": "",
|
||||
"Canceled": "",
|
||||
"cancelled": "",
|
||||
"Cancelled": "",
|
||||
"Catalog": "",
|
||||
"Ceiling height for scene_emul.": "",
|
||||
"Ceiling height of the pretend room.": "",
|
||||
"Change executable…": "",
|
||||
"Change program…": "",
|
||||
"Change settings…": "",
|
||||
"Changes apply the next time FramePort starts.": "",
|
||||
"Changes apply to the next install. Hover a chip above for why it was suggested.": "",
|
||||
"Changes are used the next time the game starts. Not sure? Leave a setting as it is.": "",
|
||||
"Change…": "",
|
||||
"Check": "",
|
||||
"Check exe": "",
|
||||
"Check for new versions automatically": "",
|
||||
"Check for updates": "",
|
||||
"Check game files": "",
|
||||
"Check now": "",
|
||||
"Check program": "",
|
||||
"Check that the Frame and this PC are on the same network.": "",
|
||||
"Checking for updates": "",
|
||||
"Check that the Frame and this computer are on the same network.": "",
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Checking which app opens these links…": "",
|
||||
"checksum checked": "",
|
||||
"Check…": "",
|
||||
"Choose \"Flat window\" if a phone/tablet app shows nothing in the headset, \"VR\" if a VR app opens as a flat window.": "",
|
||||
"Choose PNG, JPEG or WebP images. Steam fills any missing shape from the ones you choose; FramePort never replaces your images automatically.": "",
|
||||
"Choose…": "",
|
||||
@@ -214,7 +213,6 @@
|
||||
"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": "",
|
||||
@@ -224,7 +222,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 PC with a USB cable (a data cable, not a charge-only one).": "",
|
||||
"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": "",
|
||||
@@ -242,7 +240,7 @@
|
||||
"Controllers": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
"Copied": "",
|
||||
"Copied: save it as a .json file, change the colors and install it": "",
|
||||
"Copies the game's files, data and saves to the other drive, checks the copy and then removes the old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a microSD card only starts while the card is inserted.": "",
|
||||
"Copy": "",
|
||||
"Copy all": "",
|
||||
"Copy failed; select the text instead": "",
|
||||
@@ -253,9 +251,9 @@
|
||||
"Copy OVRPort libraries": "",
|
||||
"Copy path": "",
|
||||
"Copy PID {pid}": "",
|
||||
"Copy this theme as a file": "",
|
||||
"Couldn't check this PC": "",
|
||||
"Couldn't connect: {exc}": "",
|
||||
"Couldn't list the Frame's drives: {error}": "",
|
||||
"Couldn't list the screenshots: {exc}": "",
|
||||
"Couldn't list {path}: {exc}": "",
|
||||
"couldn't load the full image: {exc}": "",
|
||||
@@ -263,7 +261,6 @@
|
||||
"Couldn't read the file list: {exc}": "",
|
||||
"Couldn't read the Frame's folders: {exc}": "",
|
||||
"Couldn't read {path}: {exc}": "",
|
||||
"Couldn't remove the theme: {error}": "",
|
||||
"Couldn't start the live view: {exc}": "",
|
||||
"Couldn't use {choice}: {exc}": "",
|
||||
"Counts the headset as worn, so games that only move the player while it's worn get head tracking and controls (e.g. BONELAB).": "",
|
||||
@@ -296,15 +293,15 @@
|
||||
"Delete…": "",
|
||||
"Deselect": "",
|
||||
"Desktop": "",
|
||||
"Desktop Mode": "",
|
||||
"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 → Enable Developer Mode) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you. With Developer Mode on you can also pair without the setup command: on the Frame open Settings → Developer → Pair new host, click Connect there and approve FramePort. That way needs no connection from the Frame into this PC.": "",
|
||||
"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.": "",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
|
||||
"Didn't finish ({value}): {error}": "",
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
"Disable Meta XR Audio telemetry": "",
|
||||
"Disconnect": "",
|
||||
"Dismiss": "",
|
||||
"Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). A video's frame rate that divides the refresh rate plays smoothest (e.g. 30 fps at 90 Hz, 24 fps at 72 Hz).": "",
|
||||
"Documents": "",
|
||||
@@ -312,10 +309,12 @@
|
||||
"done": "",
|
||||
"Done": "",
|
||||
"Done: look for the cable": "",
|
||||
"Download": "",
|
||||
"Download all": "",
|
||||
"Download all ({n})…": "",
|
||||
"Download all…": "",
|
||||
"Download and install": "",
|
||||
"Download selected…": "",
|
||||
"Download selected": "",
|
||||
"Download to this PC": "",
|
||||
"Download to this PC…": "",
|
||||
"Download to which folder on this PC?": "",
|
||||
"Download {name}": "",
|
||||
@@ -335,7 +334,6 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Downloading tools": "",
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Download…": "",
|
||||
@@ -348,17 +346,16 @@
|
||||
"Each patch is described in plain words. Turn this on to also see its id, exactly what it changes in the game (useful when reporting a problem) and its parameters.": "",
|
||||
"Enabled by you.": "",
|
||||
"End all": "",
|
||||
"End all {n}": "",
|
||||
"End all {n} {name}?": "",
|
||||
"End all {n}…": "",
|
||||
"End game": "",
|
||||
"End game…": "",
|
||||
"End process": "",
|
||||
"End the whole game": "",
|
||||
"End {name}?": "",
|
||||
"End {title}?": "",
|
||||
"Ending this stops Steam, SteamVR or the desktop": "",
|
||||
"End…": "",
|
||||
"Enter the address": "",
|
||||
"Enter the Frame's address (for example steamos@frame.local or its IP)": "",
|
||||
"Enter the Frame's address (e.g. steamos@frame.local or its IP)": "",
|
||||
"Environment variables exported by the launcher (e.g. VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
|
||||
"Environment variables for the Proton launcher on the Frame (e.g. DXVK_HUD=fps), one KEY=value per line.": "",
|
||||
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
|
||||
@@ -368,6 +365,7 @@
|
||||
"Every {n}": "",
|
||||
"everything in ~ (not seen by games)": "",
|
||||
"Everything is up to date": "",
|
||||
"Executable": "",
|
||||
"experimental": "",
|
||||
"Experimental Vulkan mutable-format workaround.": "",
|
||||
"Extra diagnostics (log)": "",
|
||||
@@ -382,15 +380,13 @@
|
||||
"Failed: {error}": "",
|
||||
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (e.g. Demeter).": "",
|
||||
"Fan": "",
|
||||
"file": "",
|
||||
"Files": "",
|
||||
"Files and environment on the Frame": "",
|
||||
"Files and environment variables placed next to the game on the Frame.": "",
|
||||
"Files on the Frame can be browsed once FramePort is connected to it.": "",
|
||||
"Files on the Frame — {title}": "",
|
||||
"Files on the Frame: browse what this install contains": "",
|
||||
"Filter files (for example .pak, Binaries)": "",
|
||||
"Find another Frame…": "",
|
||||
"Filter files (e.g. .pak, Binaries)": "",
|
||||
"Find artwork and store details": "",
|
||||
"Find artwork…": "",
|
||||
"Find automatically": "",
|
||||
@@ -407,7 +403,6 @@
|
||||
"Fixes the black picture in Minecraft launchers (e.g. QuestCraft): uses depth formats the Frame's graphics driver accepts.": "",
|
||||
"Flat": "",
|
||||
"focus_hold hides focus dips up to this long (100-5000). Longer for a headset whose wear sensor flickers (\"HMD off\" for 0.5-2 s while worn, e.g. Blade & Sorcery pausing); taking the headset off or the system menu pauses the game only after this long.": "",
|
||||
"folder": "",
|
||||
"Folder": "",
|
||||
"folder and everything in it": "",
|
||||
"Folder name": "",
|
||||
@@ -430,28 +425,24 @@
|
||||
"For Unreal games whose models flicker or show through walls and windows on the Frame (e.g. Into The Radius 2). Needs a rebuild.": "",
|
||||
"For Vulkan games whose models flicker or vanish while you look at them (e.g. Into The Radius 2). Needs a rebuild.": "",
|
||||
"Force enable passthrough": "",
|
||||
"Force quit": "",
|
||||
"Force quit all": "",
|
||||
"Force quit all {n} {name}?": "",
|
||||
"Force quit all {n}…": "",
|
||||
"Force quit {name}?": "",
|
||||
"Force quit…": "",
|
||||
"Force kill": "",
|
||||
"Force kill all": "",
|
||||
"Force kill all {n}": "",
|
||||
"Force kill all {n} {name}?": "",
|
||||
"Force kill {name}?": "",
|
||||
"Found": "",
|
||||
"fps": "",
|
||||
"fps isn't reported for PC VR and Linux games": "",
|
||||
"fps · target {hz} Hz": "",
|
||||
"fps: waiting for the game's first frames": "",
|
||||
"Frame agent": "",
|
||||
"Frame battery: charging": "",
|
||||
"Frame battery: not charging": "",
|
||||
"Frame not connected": "",
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
"FramePort": "",
|
||||
"FramePort can't add {name}: drop an APK, a Linux build (.zip, AppImage), a Windows program (.exe) or a folder": "",
|
||||
"FramePort can't replace itself in {value} (no permission): opening the release page to download it": "",
|
||||
"FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes FramePort and opens the new version; your games, settings and Frame connection are kept. With automatic install, the download happens in the background and the new version starts next time.": "",
|
||||
"FramePort downloads it, checks it, closes and opens again as the new version. Your games, settings and Frame connection stay as they are.": "",
|
||||
"FramePort found more than one arm64 program in this app. Pick the one that starts it (helpers such as crash reporters or updaters are the wrong choice).": "",
|
||||
"FramePort found more than one program that could start this game. Pick the one you'd double-click to play it. Oculus builds usually work better with Revive than Steam builds.": "",
|
||||
"FramePort has Steam on the Frame download Proton and the runtime it needs (about 1 GiB). Steam restarts once, which closes a running game.": "",
|
||||
@@ -472,7 +463,6 @@
|
||||
"FramePort {version} is the latest version": "",
|
||||
"FramePort {version} · click to install": "",
|
||||
"FramePort {version} · {kind}": "",
|
||||
"FramePort's original violet": "",
|
||||
"FramePort's tools, library, artwork and the signing keys of your rebuilt games. Back it up: an update must be signed with the same key, or the game (and its saves) has to be reinstalled.": "",
|
||||
"FramePort's translator between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.": "",
|
||||
"Frames in Developer Mode show up here.": "",
|
||||
@@ -480,7 +470,6 @@
|
||||
"Free up space": "",
|
||||
"Free up space on the Frame": "",
|
||||
"Free up space?": "",
|
||||
"Free up space…": "",
|
||||
"Freed {value:.1f} GiB on the Frame": "",
|
||||
"From the tested recipe for this game": "",
|
||||
"From the {src}": "",
|
||||
@@ -489,12 +478,13 @@
|
||||
"Game": "",
|
||||
"Game arguments (select SteamVR / OpenXR)": "",
|
||||
"Game config files": "",
|
||||
"Game configs are up to date": "",
|
||||
"Game configs from GitHub: last checked {when}": "",
|
||||
"Game data (.obb) not found": "",
|
||||
"game data (OBB)": "",
|
||||
"Game launch capacity": "",
|
||||
"Game not found": "",
|
||||
"Game page": "",
|
||||
"Game recipes are up to date": "",
|
||||
"Game recipes from GitHub: last checked {when}": "",
|
||||
"Game settings": "",
|
||||
"Game settings · {title}": "",
|
||||
"Game settings. ": "",
|
||||
@@ -543,7 +533,7 @@
|
||||
"How a PC VR game starts: Proton runs the Windows program on the Frame; for Oculus games, Revive translates the Oculus API to OpenXR.": "",
|
||||
"How Lepton runs the app: VR (headless Android, only what the app draws through OpenXR reaches the headset) or a flat window (Lepton's lepton-show-flatscreen marker: the Android window on a screen in the headset, like a phone or tablet app). Automatic uses a flat window for apps without VR code. Choose \"Flat window\" for a 2D app FramePort took for VR (it shows nothing in the headset), \"VR\" for a VR app FramePort took for 2D.": "",
|
||||
"How Lepton shows an Android app: VR (the headset shows what the app draws through OpenXR) or a flat window (a phone/tablet app on a screen in the headset). Automatic picks a window for apps without VR code.": "",
|
||||
"How much of the time the Frame's graphics chip was busy, added up from every program that draws (the game, SteamVR's compositor, Steam). Near 100% the game can't keep its frame rate.": "",
|
||||
"How much of the time the Frame's graphics chip was busy, added up from every program that draws (the game, SteamVR's compositor, Steam). Near 100 % the game can't keep its frame rate.": "",
|
||||
"How often the Frame sends new numbers": "",
|
||||
"How often the picture updates. Higher is smoother if the game can keep up.": "",
|
||||
"How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" come from recipes tested on a real Frame. \"Untested\" means FramePort suggested a recipe from the game's engine and VR API that nobody has confirmed yet. \"Can't run\" means there's a known blocker (for example a 32-bit-only game).": "",
|
||||
@@ -554,20 +544,21 @@
|
||||
"If you know the Frame's address.": "",
|
||||
"Ignore focus dips up to": "",
|
||||
"Image for the {label}": "",
|
||||
"In the headset, select a text field, then type here.": "",
|
||||
"In the menu and on the desktop": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
"Install": "",
|
||||
"Install anyway": "",
|
||||
"Install anyway…": "",
|
||||
"Install dev build": "",
|
||||
"Install dev build {version}?": "",
|
||||
"Install from a link": "",
|
||||
"Install it anyway to try it, for example with different patches.": "",
|
||||
"Install from a link…": "",
|
||||
"Install it anyway to try it, e.g. with different patches.": "",
|
||||
"Install Lepton on the Frame": "",
|
||||
"Install link: {host}": "",
|
||||
"Install links": "",
|
||||
"Install missing": "",
|
||||
"Install new games to": "",
|
||||
"Install on Frame": "",
|
||||
"Install on this PC": "",
|
||||
"Install Proton on the Frame": "",
|
||||
@@ -575,7 +566,6 @@
|
||||
"Install SteamVR from Steam to play PC VR games": "",
|
||||
"Install the alternate build (": "",
|
||||
"Install the latest dev build…": "",
|
||||
"Install theme file…": "",
|
||||
"Install updates automatically (downloads in the background, installs when FramePort next starts)": "",
|
||||
"Install {len} on this PC": "",
|
||||
"Install {name}?": "",
|
||||
@@ -584,19 +574,14 @@
|
||||
"Install {title} on {value}": "",
|
||||
"Installed": "",
|
||||
"Installed games ({len})": "",
|
||||
"Installed the theme \"{name}\"": "",
|
||||
"Installed theme file · {file}": "",
|
||||
"Installed {join}": "",
|
||||
"Installed · a newer build is ready": "",
|
||||
"Installed · launch test failed": "",
|
||||
"Installed · patch settings changed since ({changed}) — update to apply": "",
|
||||
"Installed, but it didn't start properly: ": "",
|
||||
"Installing": "",
|
||||
"Installing {name}": "",
|
||||
"Installs the app as it is, unpacked on the Frame, with an entry in its Steam library": "",
|
||||
"Installs the app as it is, with an entry in the Frame's Steam library": "",
|
||||
"Installs the game exactly as it is: no patches (your copy is already patched)": "",
|
||||
"Install…": "",
|
||||
"Internal storage": "",
|
||||
"into {value}": "",
|
||||
"It belongs to a running game, which may stop with it.": "",
|
||||
"It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort continues by itself, and uploads pick up where they stopped.": "",
|
||||
@@ -604,10 +589,14 @@
|
||||
"It has a known blocker on the Steam Frame.": "",
|
||||
"It has issues: share…": "",
|
||||
"it stopped at '{value}'": "",
|
||||
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes the desktop; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
|
||||
"It was removed from the library.": "",
|
||||
"It will be closed without saving. Continue?": "",
|
||||
"It won't start on the Frame: SteamOS doesn't have these libraries and the app doesn't bring them. Look for a build of the app that includes them, then install that one.": "",
|
||||
"It works: share…": "",
|
||||
"it's here now": "",
|
||||
"its drive": "",
|
||||
"Its manifest asks for Android {version} (API {api}); the Frame's Android is 11 (API 30). Apps like this often crash at start, but not always: try it, a launch test tells.": "",
|
||||
"Java runtime": "",
|
||||
"Java, OVRPort and apksigner are ready.": "",
|
||||
"just now": "",
|
||||
@@ -626,9 +615,11 @@
|
||||
"Last build": "",
|
||||
"Last launch test: {get} · furthest: {value}": "",
|
||||
"late": "",
|
||||
"Later": "",
|
||||
"Launch log": "",
|
||||
"Launch log · {title}": "",
|
||||
"Launch test after installing on the Frame (starts the game once without the headset and checks its log)": "",
|
||||
"Launch test on Frame": "",
|
||||
"Launch test on the Frame. ": "",
|
||||
"Launch test on this PC": "",
|
||||
"Launch test. ": "",
|
||||
@@ -675,11 +666,9 @@
|
||||
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
|
||||
"Logs every Meta Platform SDK request the game makes (user, entitlement, cloud saves, ...) and the messages that answer them, and every 10 s the requests that never got an answer (logcat tag fp_ovrtrace, in launch.log). For games that wait forever on a loading screen (e.g. Vader Immortal after its intro). Changes nothing: every call goes to the real function.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Looking at the app": "",
|
||||
"Looking at the program": "",
|
||||
"Looking for drives…": "",
|
||||
"Looking for Frames on your network…": "",
|
||||
"Looking for games": "",
|
||||
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this PC).": "",
|
||||
"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": "",
|
||||
@@ -711,8 +700,12 @@
|
||||
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
|
||||
"Modem": "",
|
||||
"Monitor": "",
|
||||
"Monitor is using {pct}% of the Frame's CPU": "",
|
||||
"Monitor is using {pct} % of the Frame's CPU": "",
|
||||
"More actions": "",
|
||||
"Move": "",
|
||||
"Move to…": "",
|
||||
"Move {title} to {label}": "",
|
||||
"Move {title} to…": "",
|
||||
"Moves where the pointer starts, forward or back.": "",
|
||||
"Moves where the pointing ray starts forward (+) or back (−).": "",
|
||||
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.": "",
|
||||
@@ -725,11 +718,12 @@
|
||||
"Network": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"never": "",
|
||||
"New config applied: use Update on Frame to install it": "",
|
||||
"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 games go to {label}": "",
|
||||
"New name": "",
|
||||
"New recipe applied: use Update on Frame to install it": "",
|
||||
"Next": "",
|
||||
"No": "",
|
||||
"No activity": "",
|
||||
@@ -740,13 +734,12 @@
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No Frame is connected: the game is added to your library now and installs once the Frame is connected.": "",
|
||||
"No FramePort game is running. Start one in the headset or with Play, or switch the filter to see everything on the Frame.": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise turn on Developer Mode and keep the Frame on the same network.": "",
|
||||
"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 other Frames remembered yet.": "",
|
||||
"No process matches.": "",
|
||||
"No release notes.": "",
|
||||
"No screenshots yet": "",
|
||||
@@ -762,6 +755,8 @@
|
||||
"Not enough space on the Frame ({detail}). Remove games you don't play or use Free up space on the Steam Frame page.": "",
|
||||
"Not from a FramePort game": "",
|
||||
"Not installed": "",
|
||||
"Not needed in the last build, OVRPort fixed it itself: {names}": "",
|
||||
"Not needed in the last build: OVRPort fixed this itself.": "",
|
||||
"Not now": "",
|
||||
"Not offered by Steam on this Frame yet": "",
|
||||
"Not recommended: breaks games that load VrApi through OVRPlugin.": "",
|
||||
@@ -776,11 +771,11 @@
|
||||
"Nothing running. Installs, launch tests and downloads show up here.": "",
|
||||
"Nothing to install": "",
|
||||
"Nothing to install on the Frame — those Oculus games need PC mode (SteamVR + Revive).": "",
|
||||
"Nothing to install: add the games' .obb files first": "",
|
||||
"Nothing to patch: it runs as is.": "",
|
||||
"Now {scale:.0%}.": "",
|
||||
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"nowhere": "",
|
||||
"NPU": "",
|
||||
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
@@ -792,36 +787,32 @@
|
||||
"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 PC over the USB cable):": "",
|
||||
"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).": "",
|
||||
"On this PC, runs Revive through SteamVR (works with more headsets).": "",
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC game folder": "",
|
||||
"One PC VR game folder": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
"Only internal storage": "",
|
||||
"Open": "",
|
||||
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
|
||||
"Open frameport:// links in FramePort": "",
|
||||
"Open game": "",
|
||||
"Open game page": "",
|
||||
"Open GitHub issue": "",
|
||||
"Open the Monitor": "",
|
||||
"Open viewer": "",
|
||||
"Open {scheme}:// links with FramePort?": "",
|
||||
"OpenGL ES games run on the Frame through Zink (OpenGL on top of Vulkan), which is stricter than Quest drivers. Vulkan games run natively.": "",
|
||||
"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 the app straight in VR, skipping its flat launcher screen (turn off to use the launcher, e.g. to import new content).": "",
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR bridge registered": "",
|
||||
"OpenXR runtime": "",
|
||||
"OR": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Enable Developer Mode) and pair it under step 2: that needs no connection into this PC.": "",
|
||||
"Orange for actions, blue for the PC side: the logo's two portals": "",
|
||||
"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.": "",
|
||||
"Other": "",
|
||||
"other file": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
@@ -838,7 +829,6 @@
|
||||
"Pairing: on the Frame open Settings → Developer → Pair new host, then approve FramePort.": "",
|
||||
"Password (first time only)": "",
|
||||
"Paste the address of an \"Install with FrameDrop\" button (right-click it → Copy link), a FramePort or FrameDrop manifest (.json) or a direct link to an APK, a Linux build (.zip) or a Windows program (.exe).": "",
|
||||
"Patch": "",
|
||||
"Patch Meta XR Audio": "",
|
||||
"Patch Oculus detection for Unity": "",
|
||||
"Patch Oculus detection for Unreal": "",
|
||||
@@ -875,14 +865,11 @@
|
||||
"Play on this PC": "",
|
||||
"Play your Quest, Android and PC VR games on the Steam Frame. Three steps and you're set.": "",
|
||||
"Play: starts the game through the Frame's Steam (put the headset on)": "",
|
||||
"playing": "",
|
||||
"playing · {t}": "",
|
||||
"Point FramePort at a folder with Quest or Android games (APK + OBB) or PC VR games. It finds them, works out what each needs and fetches artwork.": "",
|
||||
"Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games (one folder per game, or a folder of them). It finds them, works out what each needs and fetches artwork.": "",
|
||||
"Pointer angle": "",
|
||||
"Pointer start": "",
|
||||
"Pointer turn": "",
|
||||
"Points Oculus games at Revive on the Frame.": "",
|
||||
"Portal on true black, for OLED screens": "",
|
||||
"Power": "",
|
||||
"Power ICs": "",
|
||||
"Prepare {title}": "",
|
||||
@@ -892,10 +879,9 @@
|
||||
"Problems and feedback": "",
|
||||
"Processes": "",
|
||||
"Program": "",
|
||||
"Protected by SteamOS": "",
|
||||
"Proton couldn't run a test program (see the log)": "",
|
||||
"Proton debug log (Frame)": "",
|
||||
"Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. Its arm64 build isn't installed by default; FramePort can install it (Steam restarts once).": "",
|
||||
"Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).": "",
|
||||
"Proton version (Frame)": "",
|
||||
"Proton works on your Frame ({tool})": "",
|
||||
"Provide the Oculus runtime from Revive (Frame)": "",
|
||||
@@ -911,15 +897,12 @@
|
||||
"Quest ✓": "",
|
||||
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
|
||||
"Quest ✗": "",
|
||||
"Queued": "",
|
||||
"Queued {len} installs — they run one after another in the background": "",
|
||||
"Quiet Meta's VrApi loader (OpenXR games)": "",
|
||||
"Raises minSdk where the loader needs it.": "",
|
||||
"Raises the minimum Android version the game declares, so the new parts load.": "",
|
||||
"Re-create reference space": "",
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"ready": "",
|
||||
"Ready": "",
|
||||
"Ready to play": "",
|
||||
"Rebuild only (no install)": "",
|
||||
@@ -927,34 +910,31 @@
|
||||
"Recently used": "",
|
||||
"Recipe": "",
|
||||
"Recipe reset": "",
|
||||
"Recipe reset: update the game on the Frame to use it": "",
|
||||
"Recipes tested on a real Frame: bundled with FramePort, fetched from the online catalog, and the ones you saved as known-good.": "",
|
||||
"Reconnect": "",
|
||||
"Recreate spaces after the first frames (diagnostic).": "",
|
||||
"Reduce motion (no fades between pages, no portal pulse when the Frame connects)": "",
|
||||
"Refresh": "",
|
||||
"Refresh rate": "",
|
||||
"Refresh store details": "",
|
||||
"Reinstall anyway…": "",
|
||||
"Reinstall anyway": "",
|
||||
"Reinstall on Frame": "",
|
||||
"Reinstall on this PC": "",
|
||||
"Reinstalls FramePort from the release, then restarts it.": "",
|
||||
"Release page": "",
|
||||
"Remembered": "",
|
||||
"Remove": "",
|
||||
"Remove converted copies now": "",
|
||||
"Remove FramePort": "",
|
||||
"Remove from library…": "",
|
||||
"Remove from library": "",
|
||||
"Remove from this PC": "",
|
||||
"Remove from this PC's Steam library": "",
|
||||
"Remove localized app names": "",
|
||||
"Remove tag": "",
|
||||
"Remove this theme": "",
|
||||
"Remove Unreal's ForceQuit": "",
|
||||
"Remove uses-library": "",
|
||||
"Remove VrApi library": "",
|
||||
"Remove XR_KHR_composition_layer_depth chains (diagnostic).": "",
|
||||
"Remove {title} from the library?": "",
|
||||
"Removed from Desktop Mode": "",
|
||||
"Removed the converted copies ({size})": "",
|
||||
"Removed the theme \"{name}\"": "",
|
||||
"Removed {title}": "",
|
||||
"Removed {title} and deleted its files on this PC ({size})": "",
|
||||
"Removes a controller position adjustment, for games whose hands look misplaced.": "",
|
||||
@@ -986,16 +966,12 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Reset": "",
|
||||
"Reset to recommended": "",
|
||||
"Reset to suggested recipe…": "",
|
||||
"Reset {title}'s recipe?": "",
|
||||
"Reset to suggested recipe": "",
|
||||
"Restart": "",
|
||||
"Restart now": "",
|
||||
"Restart the Frame": "",
|
||||
"Restart the Frame?": "",
|
||||
"Restart the Frame…": "",
|
||||
"Restarting": "",
|
||||
"Resume": "",
|
||||
"Resuming — files already copied are skipped": "",
|
||||
"Retry rejected GLES formats/MSAA with sRGB, samples=1.": "",
|
||||
@@ -1004,16 +980,12 @@
|
||||
"Revive (Oculus LibOVR → OpenXR)": "",
|
||||
"Revive {value} · {value2} backend": "",
|
||||
"Revive: SteamVR (OpenVR) backend on PC": "",
|
||||
"Rift": "",
|
||||
"Room depth": "",
|
||||
"Room height": "",
|
||||
"Room width": "",
|
||||
"Rotate quads 180°": "",
|
||||
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (e.g. Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
|
||||
"Run launch test on Frame": "",
|
||||
"Run launch test on this PC": "",
|
||||
"Running": "",
|
||||
"Running a Windows program under Proton": "",
|
||||
"running for {t}": "",
|
||||
"Running now": "",
|
||||
"Runs directly — no Revive needed": "",
|
||||
@@ -1033,6 +1005,7 @@
|
||||
"Saved. The settings are used when the game is installed.": "",
|
||||
"saves and settings (the app's Android storage)": "",
|
||||
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
|
||||
"Scan a folder": "",
|
||||
"Scan a folder…": "",
|
||||
"Scan {name}": "",
|
||||
"Scans the folders you added games from again and adds games that appeared since. Games already in the library keep their recipe, tags and art; unchanged ones aren't analyzed again.": "",
|
||||
@@ -1041,6 +1014,7 @@
|
||||
"SDL apps: start without a clipboard service": "",
|
||||
"Search": "",
|
||||
"Search again": "",
|
||||
"Search games and tags": "",
|
||||
"Search name, PID or game": "",
|
||||
"Search the stores instead": "",
|
||||
"Searched a moment ago.": "",
|
||||
@@ -1053,15 +1027,14 @@
|
||||
"Select games to install": "",
|
||||
"Sends this game's recipe (the patches and settings it uses) to FramePort's GitHub as a prefilled issue, so it can become a built-in recipe for everyone. You review and submit it in the browser; no GitHub token and no game files are involved.": "",
|
||||
"Set by you.": "",
|
||||
"Set up for FramePort": "",
|
||||
"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": "",
|
||||
"Share working recipe · {title}": "",
|
||||
"Share working recipe…": "",
|
||||
"Share working config · {title}": "",
|
||||
"Share working config…": "",
|
||||
"Sharpness": "",
|
||||
"Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the next time it starts.": "",
|
||||
"Show 360° backgrounds and videos": "",
|
||||
@@ -1082,7 +1055,6 @@
|
||||
"Show Steam Frame controllers": "",
|
||||
"Show technical details (patch ids, exact effects, parameters)": "",
|
||||
"Show the app's Android window (for typing)": "",
|
||||
"Show the running game on the Frame card": "",
|
||||
"Show video panels": "",
|
||||
"Showing the top {shown} of {n} rows: search to find others.": "",
|
||||
"Showing {len} of {n}": "",
|
||||
@@ -1091,12 +1063,11 @@
|
||||
"Shows videos that games play on a flat panel (e.g. an intro video).": "",
|
||||
"Shut down": "",
|
||||
"Shut down the Frame?": "",
|
||||
"Sign": "",
|
||||
"Signing keys matter: game updates must be signed with the same key or their saves are lost on reinstall.": "",
|
||||
"Signs rebuilt games": "",
|
||||
"Size": "",
|
||||
"Size in pixels per eye of the 360° background, redrawn every frame on a background thread (512–4096). Higher is sharper but costs more GPU time per frame.": "",
|
||||
"Size of text and layout. Automatic follows Windows' display scaling halfway (150% in Windows → 125%) when FramePort runs under WSL, where the window doesn't get it from Windows itself.": "",
|
||||
"Size of text and layout. Automatic follows Windows' display scaling halfway (150 % in Windows → 125 %) when FramePort runs under WSL, where the window doesn't get it from Windows itself.": "",
|
||||
"Skip for now": "",
|
||||
"Skip Meta avatars": "",
|
||||
"Skip this version": "",
|
||||
@@ -1105,7 +1076,7 @@
|
||||
"Sleep": "",
|
||||
"software encoder": "",
|
||||
"Some apps open a flat Android launcher that then starts a separate VR activity (e.g. WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
|
||||
"Some developers put an \"Install with FrameDrop\" button on their site (the protocol of the FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, downloads it, adds it to your library and installs it on the Frame. Only https links to public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the link into Add games → Add from a link….": "",
|
||||
"Some developers put an \"Install with FrameDrop\" button on their site (the protocol of the FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, downloads it, adds it to your library and installs it on the Frame. Only https links to public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the link into Add games → Install from a link….": "",
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, e.g. Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (e.g. Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (e.g. xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; e.g. the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games keep content in separate files next to their OBB (e.g. Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
@@ -1116,11 +1087,9 @@
|
||||
"Sort": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"SSH found by network scan": "",
|
||||
"Standard for every game": "",
|
||||
"Start live view": "",
|
||||
"Start straight in VR (skip the 2D launcher)": "",
|
||||
"Start the live view to watch the headset in your browser": "",
|
||||
"Starting SteamVR and {title} — put your headset on": "",
|
||||
"Starting the stream on the Frame…": "",
|
||||
"Starting {title} on the Frame — put the headset on": "",
|
||||
@@ -1142,7 +1111,6 @@
|
||||
"Steam Frame patches": "",
|
||||
"Steam Frame: installed on the headset with an entry in its Steam library. This PC: a Steam shortcut on this Windows PC that starts the game through Revive, for a PC-tethered headset.": "",
|
||||
"Steam on the Frame isn't responding. Restart the Frame (or its Steam) and try again.": "",
|
||||
"SteamOS · Developer Mode": "",
|
||||
"steamos@frame.local or an IP address": "",
|
||||
"SteamVR": "",
|
||||
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
|
||||
@@ -1163,7 +1131,6 @@
|
||||
""
|
||||
],
|
||||
"Store details: {title}": "",
|
||||
"Streams a small sample from the Frame every 5 seconds (game, frame rate, battery). Turn off to save the Frame's processor.": "",
|
||||
"Strip depth layers": "",
|
||||
"Stub missing Meta platform functions": "",
|
||||
"Suggested after a launch test": "",
|
||||
@@ -1174,7 +1141,6 @@
|
||||
"swap {n}": "",
|
||||
"Swapchain format fallback": "",
|
||||
"Swaps in an OpenXR OVRPlugin for games that use VrApi through OVRPlugin (older Unity/Unreal).": "",
|
||||
"Switch Frame": "",
|
||||
"Switch Frame…": "",
|
||||
"Tags": "",
|
||||
"Tags:": "",
|
||||
@@ -1187,14 +1153,12 @@
|
||||
"Test": "",
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
"That theme file can't be used: {error}": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
"The command fetches a small setup script from this app over your local network. It turns on Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. Turning on Developer Mode closes the Frame's desktop; the setup finishes on its own and FramePort connects by itself. No password needed.": "",
|
||||
"The command fetches a small setup script from this app over your local network. It turns on Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. No password needed.": "",
|
||||
"The connection to the Frame is busy. Try again in a moment.": "",
|
||||
"The copy on your Frame differs from what FramePort would install now (the recipe or your game files changed). Update to install the new build; saves are kept.": "",
|
||||
"The CPU types the game ships code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
|
||||
"The download didn't finish. Check your internet connection.": "",
|
||||
"the downloaded app": "",
|
||||
"The first picture takes a few seconds.": "",
|
||||
"The Frame can't keep up at this quality: {n} frame dropped in the last 10 seconds. Choose a lower quality for a smoother picture.": [
|
||||
"",
|
||||
@@ -1213,8 +1177,8 @@
|
||||
"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.": "",
|
||||
"The Frame went offline: {exc}": "",
|
||||
"The Frame's battery is at {pct}% and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause}%.": "",
|
||||
"The Frame's battery is at {pct}%: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause} %.": "",
|
||||
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
"The Frame's CPU is arm64. Programs built for x86_64 PCs run through FEX, Valve's x86 translator, with the x86 libraries SteamOS ships for it (glibc, Mesa) - the way Steam on the Frame runs x86 Linux games. That works for many programs but is slower: use an arm64 build when there is one.": "",
|
||||
"The Frame's desktop password, only needed the first time so FramePort can add its own key. After that it connects with the key.": "",
|
||||
"The Frame's Steam couldn't start {title} (Steam error {code}). Please send a problem report so we can see why.": "",
|
||||
@@ -1222,7 +1186,6 @@
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
"the game didn't pass its checks (see the list above)": "",
|
||||
"The game is closed the way Steam's Exit game does it; if it doesn't stop, FramePort stops it. Progress that the game hasn't saved is lost.": "",
|
||||
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
|
||||
"the installed game (APK and its data)": "",
|
||||
"The launch settings changed since it was installed: update the Steam shortcut": "",
|
||||
"The live view can start once FramePort is connected to the Frame.": "",
|
||||
@@ -1231,17 +1194,16 @@
|
||||
"The monitor shows your Frame's games, load, temperatures and battery live once FramePort is connected to it.": "",
|
||||
"the other app": "",
|
||||
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
|
||||
"The picture opens in your default web browser, where it plays smoothly and can go full screen. It shows the headset's view with its sound (click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about once a second. The Frame's hardware video encoder does the work (if it isn't available, the processor does, which costs a game more): stop the stream when you're done.": "",
|
||||
"The picture opens in your default web browser, where it plays smoothly and can go full screen. It shows the headset's view with its sound (click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about once a second. The Frame's hardware video encoder does the work (if it isn't available, the processor does, which costs a game more); stop the stream when you're done.": "",
|
||||
"the previous version, kept for one rollback": "",
|
||||
"The repack's own Revive · runs the game directly": "",
|
||||
"The small helper program FramePort runs on the Frame (installs, launch tests, Steam entries). It comes with this app and is replaced on the Frame automatically whenever it differs.": "",
|
||||
"The time the Frame spends collecting these numbers": "",
|
||||
"The upload to the Frame was damaged on the way. Try again (a cable or the Frame's hotspot is more reliable than a busy Wi-Fi).": "",
|
||||
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
|
||||
"Theme": "",
|
||||
"Theme file to install": "",
|
||||
"These games can't run on the Steam Frame": "",
|
||||
"These games check their Oculus license": "",
|
||||
"These games keep their content in a data file (.obb), and none was found next to their APK. Without it they hang at start. Put the .obb files in a folder named like the game's package (or obb/) next to the APK and add the folder again. Tick any you still want to install.": "",
|
||||
"These installs need about {need} on the Frame, and it has {free} free. Remove games you don't play (or use Free up space on the Steam Frame page), or install fewer at once.": "",
|
||||
"These links open FramePort.": "",
|
||||
"These links will open FramePort instead of {app}. You can switch back in {app}, or by turning this setting off.": "",
|
||||
@@ -1250,18 +1212,19 @@
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
|
||||
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
|
||||
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
|
||||
"This app looks like a website in an Android wrapper: it opens the site in Meta's browser, which the Frame doesn't have. If nothing opens, use the website in a browser instead.": "",
|
||||
"This computer's keyboard works as a keyboard on your Frame while this tab is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup command.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change program…) to pick it.": "",
|
||||
"This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named {package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
|
||||
"this PC": "",
|
||||
"This PC": "",
|
||||
"This PC (for PC VR games)": "",
|
||||
"This PC (Steam + Revive)": "",
|
||||
"This PC's disk is full. Free some space and try again.": "",
|
||||
"This PC's keyboard works as a keyboard on your Frame while this tab is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This removes:": "",
|
||||
"This source checkout has uncommitted changes: commit or stash them, then update": "",
|
||||
"This system can't send web links to FramePort: use Add games → Install from a link…": "",
|
||||
@@ -1275,7 +1238,6 @@
|
||||
"transparent PNG": "",
|
||||
"Treat newer Quest controllers as Touch": "",
|
||||
"Tried it in the headset? Tell us how it runs: with your recipe it can join FramePort's built-in list, so it works out of the box for everyone.": "",
|
||||
"Troubleshoot": "",
|
||||
"Troubleshooting": "",
|
||||
"Try \"Fixed\" or \"Off\" if one eye shimmers or jitters while menus look fine.": "",
|
||||
"Try again": "",
|
||||
@@ -1297,7 +1259,6 @@
|
||||
"Type": "",
|
||||
"Type it": "",
|
||||
"Type on Frame": "",
|
||||
"Type to search games and tags": "",
|
||||
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
|
||||
"Typing on the Frame works once FramePort is connected to it.": "",
|
||||
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (and afterwards: sudo ufw delete allow {port}/tcp)": "",
|
||||
@@ -1305,9 +1266,8 @@
|
||||
"Uninstall FramePort": "",
|
||||
"Uninstall FramePort?": "",
|
||||
"Uninstall FramePort…": "",
|
||||
"Uninstall from Frame…": "",
|
||||
"Uninstall from Frame": "",
|
||||
"Uninstall from the Frame. ": "",
|
||||
"Uninstall from this PC…": "",
|
||||
"Uninstall {title}": "",
|
||||
"Uninstall {title}?": "",
|
||||
"Uninstall. ": "",
|
||||
@@ -1322,18 +1282,17 @@
|
||||
"Unity: render each eye separately (no multiview)": "",
|
||||
"Unity: skip OVROverlay layers (fades, splash screens)": "",
|
||||
"Unity: start VR without Meta's system apps": "",
|
||||
"unknown": "",
|
||||
"Unknown date": "",
|
||||
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE Challenge).": "",
|
||||
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
|
||||
"Untested": "",
|
||||
"Update all": "",
|
||||
"Update all ({updates})": "",
|
||||
"Update anyway…": "",
|
||||
"Update anyway": "",
|
||||
"Update available": "",
|
||||
"Update failed: {exc}": "",
|
||||
"Update FramePort to {version}": "",
|
||||
"Update game recipes from GitHub automatically (confirmed recipes arrive without a FramePort update)": "",
|
||||
"Update game configs from GitHub automatically (confirmed configs arrive without a FramePort update)": "",
|
||||
"Update now": "",
|
||||
"Update on Frame": "",
|
||||
"Update on this PC": "",
|
||||
@@ -1343,11 +1302,11 @@
|
||||
"Update tools": "",
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\" (a newer build, or patch settings changed since it was installed), one after another.": "",
|
||||
"Updates this source checkout (git pull + uv sync), then restarts FramePort.": "",
|
||||
"Updates, tools, this PC and where FramePort keeps its data": "",
|
||||
"Updating works in the desktop app": "",
|
||||
"Upload": "",
|
||||
"Upload files": "",
|
||||
"Upload files…": "",
|
||||
"Upload folder": "",
|
||||
"Upload folder…": "",
|
||||
"Upload to {label}": "",
|
||||
"Uploaded {n} file": [
|
||||
@@ -1360,14 +1319,14 @@
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
"Use tags to group and filter your library": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the new config": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use this PC's keyboard on the Frame": "",
|
||||
"Use this computer's keyboard on the Frame": "",
|
||||
"Use this program": "",
|
||||
"Use your own artwork…": "",
|
||||
"Use your own images…": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the Oculus Platform SDK, which checks your Oculus license: it needs the Oculus app on this PC and may quit right after starting. You can still try it.": "",
|
||||
"Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the Oculus app on this PC with a license you own, so it may quit right after starting on the headset. You can still try it.": "",
|
||||
"Uses the repack's bundled Revive — experimental on the Frame": "",
|
||||
"Uses the store title/icon (OVRPort image service) for the app label.": "",
|
||||
"Uses the store's name and icon for the game.": "",
|
||||
@@ -1402,15 +1361,16 @@
|
||||
"Waiting for the Frame": "",
|
||||
"Waiting for your Frame": "",
|
||||
"Waiting for your Frame… (code {code})": "",
|
||||
"waiting {pct}%": "",
|
||||
"waiting {pct} %": "",
|
||||
"Watch what the headset shows, in a window on this PC": "",
|
||||
"Welcome to FramePort": "",
|
||||
"What FramePort will do": "",
|
||||
"What happens? (optional: you can also write it on GitHub)": "",
|
||||
"What the whole Frame draws right now (its main power rail), with the share of the CPU and the graphics chip. Higher power means more heat and a shorter battery.": "",
|
||||
"What your Frame is doing right now": "",
|
||||
"What's new…": "",
|
||||
"What's new": "",
|
||||
"Where it's installed": "",
|
||||
"Where new games go on the Frame: its internal storage or another drive, such as a microSD card (in a FramePort folder on it). Games you installed already stay where they are; move one with right-click → Move to…. The launcher and Steam entry always stay on internal storage. Drives formatted as FAT, exFAT or NTFS can't hold games (Android and Proton need Linux file permissions): format the card in SteamOS first.": "",
|
||||
"Where the power goes": "",
|
||||
"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}?": "",
|
||||
@@ -1422,7 +1382,6 @@
|
||||
"Windows game (Proton)": "",
|
||||
"Windows not detected": "",
|
||||
"Windows program": "",
|
||||
"Windows program ran": "",
|
||||
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
|
||||
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
"Windows' firewall for WSL blocks the Frame. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
@@ -1446,9 +1405,7 @@
|
||||
"You already have the latest dev build ({version})": "",
|
||||
"You have the latest version as of the last check ({when}).": "",
|
||||
"You have {version}. {how}": "",
|
||||
"You only do this once: FramePort connects by itself when the setup has finished.": "",
|
||||
"You pasted a link to {name}.": "",
|
||||
"Your changes to its patches and game settings are replaced by the suggested recipe.": "",
|
||||
"Your copy is used as it is (only the Frame's copy gets launch patches)": "",
|
||||
"Your custom recipe": "",
|
||||
"your Frame": "",
|
||||
@@ -1475,6 +1432,7 @@
|
||||
"{human} · {files} files": "",
|
||||
"{int} h ago": "",
|
||||
"{int} min ago": "",
|
||||
"{label} (not inserted)": "",
|
||||
"{label} ({host}) isn't reachable. Make sure it's switched on and on the same network.": "",
|
||||
"{label} removed for {title}.": "",
|
||||
"{label} updated for {title}.": "",
|
||||
@@ -1506,11 +1464,11 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game didn't work out": [
|
||||
"{n} game config was updated": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game recipe was updated": [
|
||||
"{n} game didn't work out": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
@@ -1550,6 +1508,7 @@
|
||||
"{on} on your Frame": "",
|
||||
"{rate}/s": "",
|
||||
"{remaining} more after this": "",
|
||||
"{size:.1f} GiB free": "",
|
||||
"{title} couldn't be added to the Frame's Steam library: {why}": "",
|
||||
"{title} ended.": "",
|
||||
"{title} failed: {error}": "",
|
||||
@@ -1557,17 +1516,15 @@
|
||||
"{title} is already starting — put the headset on": "",
|
||||
"{title} is still running.": "",
|
||||
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
|
||||
"{title} moved to {label}": "",
|
||||
"{title} will be in the Frame's Steam library when the game that's running is closed": "",
|
||||
"{title} will be in the Frame's Steam library when you're back in Gaming Mode: start it from there": "",
|
||||
"{title}: analyzed again": "",
|
||||
"{title}: checks failed": "",
|
||||
"{title}: details from {value}": "",
|
||||
"{title}: done": "",
|
||||
"{title}: launch test {verdict} (furthest: {value})": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{used} of {total}": "",
|
||||
"{used}/{max} kernel keys used": "",
|
||||
"{user}@{host} · {get} {get2} (build {get3})": "",
|
||||
"{value:.0f} MiB": "",
|
||||
"{value:.1f} GiB": "",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Detects OVRPort's fix for the haptic-envelope bug, which makes `adapter.haptic_fix` unnecessary.
|
||||
|
||||
OVRPort's dispatcher (libopenxr_loader.so) turns XrHapticAmplitudeEnvelopeVibrationFB into 100 Hz samples. Runtimes
|
||||
up to 3.4.3-23204ea used the envelope's duration (nanoseconds) as seconds when counting them: one vibration asked for
|
||||
gigabytes (Lucky's Tale froze the Frame, BattleSisters was killed for 11 GB). Runtime 3.4.3-aa54c3f converts it first
|
||||
(ovrport/app#73): right after the type check for the envelope (XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB =
|
||||
1000173001) it builds the constant 1e9 (`count * 1e9 / duration` = samples per second), which the buggy code never
|
||||
had. Verified in the headset with Lucky's Tale and haptic_fix off (2026-10-07). See patches/upstream.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from ..upstream import UpstreamFix, code_words, constants, find_constant_load, lib_probe, register
|
||||
|
||||
DISPATCHER = "libopenxr_loader.so"
|
||||
ENVELOPE_TYPE = 1000173001 # XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB
|
||||
WINDOW = 48 # instructions after the type check that hold the sample count (the fix: 7 after it)
|
||||
NS_PER_S = {struct.unpack("<Q", struct.pack("<d", v))[0] for v in (1e9, 1e-9)} | \
|
||||
{struct.unpack("<I", struct.pack("<f", v))[0] for v in (1e9, 1e-9)}
|
||||
|
||||
|
||||
def _literals(data: bytes, offsets: list[int], words: list[int]) -> list[int]:
|
||||
"""Values loaded PC-relative (LDR literal: GP w/x, SIMD s/d) by the given instructions."""
|
||||
out = []
|
||||
for off, w in zip(offsets, words, strict=True):
|
||||
if w & 0x3B000000 != 0x18000000: # LDR (literal), GP (V=0) or SIMD (V=1)
|
||||
continue
|
||||
size = {0: 4, 1: 8}.get(w >> 30) # opc: w / s = 4 bytes, x / d = 8 (q isn't a scalar constant)
|
||||
if size is None:
|
||||
continue
|
||||
imm19 = (w >> 5) & 0x7FFFF
|
||||
target = off + ((imm19 - (1 << 19)) if imm19 & (1 << 18) else imm19) * 4
|
||||
if 0 <= target <= len(data) - size:
|
||||
out.append(int.from_bytes(data[target:target + size], "little"))
|
||||
return out
|
||||
|
||||
|
||||
def envelope_duration_fixed(data: bytes) -> bool | None:
|
||||
"""True when the dispatcher converts the envelope duration from nanoseconds (OVRPort's fix), False when its
|
||||
envelope code has no such conversion (the bug), None when there's no envelope code to judge."""
|
||||
code = list(code_words(data))
|
||||
offsets, words = [o for o, _ in code], [w for _, w in code]
|
||||
seen = False
|
||||
start = 0
|
||||
while (i := find_constant_load(words[start:], ENVELOPE_TYPE)) is not None:
|
||||
i += start
|
||||
seen = True
|
||||
window = slice(i, i + WINDOW)
|
||||
if NS_PER_S & set(constants(words[window])) or NS_PER_S & set(_literals(data, offsets[window], words[window])):
|
||||
return True
|
||||
start = i + 1
|
||||
return False if seen else None
|
||||
|
||||
|
||||
register(UpstreamFix(
|
||||
id="ovrport.haptic_envelope",
|
||||
workaround="adapter.haptic_fix",
|
||||
title="OVRPort converts the vibration envelope's duration from nanoseconds",
|
||||
upstream="ovrport/app#73 (runtime 3.4.3-aa54c3f)",
|
||||
probe=lib_probe(DISPATCHER, envelope_duration_fixed),
|
||||
tracker="#74",
|
||||
))
|
||||
@@ -7,12 +7,16 @@ Walking Dead: Saints & Sinners Ch. 2) dereferences a NULL stream: SIGSEGV in lib
|
||||
seconds after the logo. Meta's own library allows that order. The function is rewritten in place (same size) to return
|
||||
0 while no stream is open and behave as before otherwise.
|
||||
|
||||
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73 (drop this patch once fixed upstream).
|
||||
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73. OVRPort runtime 3.4.3-aa54c3f checks
|
||||
the handle and the stream itself (returns 48000 without one): builds made with it leave this patch out
|
||||
(`microphone_checked`, patches/upstream.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
from ..upstream import UpstreamFix, lib_probe
|
||||
from ..upstream import register as register_upstream_fix
|
||||
|
||||
LOADER = "libovrplatformloader.so"
|
||||
FUNC = "ovr_Microphone_GetOutputBufferMaxSize"
|
||||
@@ -31,9 +35,8 @@ def _words(data: bytes | bytearray, off: int, n: int) -> list[int]:
|
||||
return [int.from_bytes(data[off + 4 * i:off + 4 * i + 4], "little") for i in range(n)]
|
||||
|
||||
|
||||
def guard_microphone(data: bytes) -> bytes | None:
|
||||
"""The loader with a NULL-stream check in ovr_Microphone_GetOutputBufferMaxSize, or None if the function isn't
|
||||
OVRPort's unchecked 7-instruction version (already fixed, Meta's library, another OVRPort build)."""
|
||||
def _function_offset(data: bytes, size: int) -> int | None:
|
||||
"""File offset of FUNC in the loader (None: not an ELF, no such export, or shorter than `size` bytes)."""
|
||||
try:
|
||||
dynsym = elf._elf(data).get_section_by_name(".dynsym")
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -41,9 +44,31 @@ def guard_microphone(data: bytes) -> bytes | None:
|
||||
found = dynsym.get_symbol_by_name(FUNC) if dynsym is not None else None
|
||||
if not found or not found[0]["st_value"]:
|
||||
return None
|
||||
vaddr = found[0]["st_value"]
|
||||
off = elf.vaddr_to_offset(elf.load_segments(data), vaddr)
|
||||
if off is None or off + 28 > len(data):
|
||||
off = elf.vaddr_to_offset(elf.load_segments(data), found[0]["st_value"])
|
||||
return off if off is not None and off + size <= len(data) else None
|
||||
|
||||
|
||||
def microphone_checked(data: bytes) -> bool | None:
|
||||
"""True when OVRPort's GetOutputBufferMaxSize tests something (a cbz/cbnz) before its first call, as the fixed
|
||||
runtime does; False when it is the unchecked version this patch rewrites; None when it can't tell."""
|
||||
if guard_microphone(data) is not None:
|
||||
return False
|
||||
off = _function_offset(data, 48)
|
||||
if off is None:
|
||||
return None
|
||||
for word in _words(data, off, 12):
|
||||
if word & BL_MASK == BL:
|
||||
return None
|
||||
if word & 0x7E000000 == 0x34000000: # CBZ / CBNZ (32 or 64 bit)
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def guard_microphone(data: bytes) -> bytes | None:
|
||||
"""The loader with a NULL-stream check in ovr_Microphone_GetOutputBufferMaxSize, or None if the function isn't
|
||||
OVRPort's unchecked 7-instruction version (already fixed, Meta's library, another OVRPort build)."""
|
||||
off = _function_offset(data, 28)
|
||||
if off is None:
|
||||
return None
|
||||
w = _words(data, off, 7)
|
||||
if w[:3] != [STP, MOV_FP, LDR_STREAM] or w[3] & BL_MASK != BL or w[4:] != [SXTW, LDP, RET]:
|
||||
@@ -88,3 +113,11 @@ class OvrMicrophone(Patch):
|
||||
|
||||
|
||||
register(OvrMicrophone)
|
||||
register_upstream_fix(UpstreamFix(
|
||||
id="ovrport.microphone_stream",
|
||||
workaround=OvrMicrophone.id,
|
||||
title="OVRPort checks for an unopened microphone stream",
|
||||
upstream="ovrport/app#73 (runtime 3.4.3-aa54c3f)",
|
||||
probe=lib_probe(LOADER, microphone_checked),
|
||||
tracker="#73",
|
||||
))
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Upstream fixes that make one of our workarounds unnecessary, applied per build.
|
||||
|
||||
Several patches work around bugs in what OVRPort puts into a converted APK (its closed-source runtime libraries, its
|
||||
CLI). When upstream fixes such a bug, users don't all get the fix at once: OVRPort downloads runtime builds into its
|
||||
workspace and each conversion uses whichever one it picks, so one library can hold games built with the old and the
|
||||
new runtime. A version number doesn't help either (runtime builds are named `<version>-<commit>`, the commit part is
|
||||
unordered). So each fix is detected in the converted APK itself: a probe looks at the bytes OVRPort produced, and when
|
||||
it finds the upstream fix, the build leaves the workaround out.
|
||||
|
||||
The recipe is never changed: it still asks for the fix (`adapter.haptic_fix` on), the build decides how to provide it.
|
||||
A build made with an old runtime keeps the workaround; the next build with a fixed runtime drops it by itself, and a
|
||||
build made with a later runtime that regressed would get the workaround back. Builds record what was left out
|
||||
(`build.superseded`), the installer leaves those out of settings.conf too, and the build log says why.
|
||||
|
||||
Add one: write a probe (True = the upstream fix is there, False = the bug is there, None = can't tell, e.g. the library
|
||||
is missing or looks different) and `register(UpstreamFix(...))` in the workaround's own module. Only True drops the
|
||||
workaround, so a probe that can't recognise newer code errs on the safe side. Then update the workaround's tracker
|
||||
issue (`upstream` label). FRAMEPORT_KEEP_WORKAROUNDS=1 keeps every workaround (to compare builds).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..apk.workspace import ApkWorkspace
|
||||
from ..core.events import Reporter
|
||||
|
||||
Probe = Callable[["ApkWorkspace"], "bool | None"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpstreamFix:
|
||||
id: str # e.g. "ovrport.haptic_envelope"
|
||||
workaround: str # the patch id this fix makes unnecessary, e.g. "adapter.haptic_fix"
|
||||
title: str # what upstream fixed, for the build log
|
||||
upstream: str # where it was fixed, e.g. "ovrport/app#73 (runtime 3.4.3-aa54c3f)"
|
||||
probe: Probe
|
||||
tracker: str = "" # our tracking issue, e.g. "#74"
|
||||
|
||||
|
||||
REGISTRY: dict[str, UpstreamFix] = {}
|
||||
|
||||
|
||||
def register(fix: UpstreamFix) -> UpstreamFix:
|
||||
if not fix.id or fix.id in REGISTRY:
|
||||
raise ValueError(f"bad or duplicate upstream fix id {fix.id!r}")
|
||||
REGISTRY[fix.id] = fix
|
||||
return fix
|
||||
|
||||
|
||||
def lib_probe(library: str, check: Callable[[bytes], bool | None]) -> Probe:
|
||||
"""A probe that runs `check` on one of the APK's native libraries (None when the APK doesn't have it)."""
|
||||
def probe(ws: ApkWorkspace) -> bool | None:
|
||||
return check(ws.read_lib(library)) if ws.abi and ws.has(ws.lib(library)) else None
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resolution:
|
||||
patches: dict # the selection this build applies
|
||||
superseded: dict[str, str] = field(default_factory=dict) # workaround patch id -> upstream fix id
|
||||
found: dict[str, bool | None] = field(default_factory=dict) # upstream fix id -> probe result (for the log)
|
||||
|
||||
|
||||
def resolve(ws: ApkWorkspace, patches: dict, reporter: Reporter | None = None) -> Resolution:
|
||||
"""Leave out the workarounds whose upstream fix is in this (converted) APK."""
|
||||
from . import base
|
||||
|
||||
base.load_all()
|
||||
res = Resolution(dict(patches))
|
||||
if os.environ.get("FRAMEPORT_KEEP_WORKAROUNDS"):
|
||||
return res
|
||||
for fix in REGISTRY.values():
|
||||
if fix.workaround not in patches:
|
||||
continue
|
||||
try:
|
||||
found = fix.probe(ws)
|
||||
except Exception as e: # noqa: BLE001 (a probe that breaks must not break the build: keep the workaround)
|
||||
found = None
|
||||
if reporter:
|
||||
reporter.log(f"upstream fix check {fix.id} failed ({e}); keeping {fix.workaround}")
|
||||
res.found[fix.id] = found
|
||||
if found:
|
||||
res.patches.pop(fix.workaround, None)
|
||||
res.superseded[fix.workaround] = fix.id
|
||||
if reporter:
|
||||
reporter.log(f"not needed: {fix.workaround} ({fix.title}, fixed upstream in {fix.upstream})")
|
||||
return res
|
||||
|
||||
|
||||
def without_superseded(patches: dict, superseded) -> dict:
|
||||
"""A recipe's selection minus the workarounds a build left out (for the install of that build)."""
|
||||
return {pid: params for pid, params in patches.items() if pid not in (superseded or ())}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- arm64 helpers for probes
|
||||
|
||||
def code_words(data: bytes):
|
||||
"""(file offset, instruction word) for every word of the library's executable sections."""
|
||||
from ..analysis import elf
|
||||
|
||||
for sec in elf._elf(data).iter_sections():
|
||||
if sec["sh_type"] == "SHT_PROGBITS" and sec["sh_flags"] & 0x4: # SHF_EXECINSTR
|
||||
start, size = sec["sh_offset"], sec["sh_size"]
|
||||
for off in range(start, start + size - 3, 4):
|
||||
yield off, int.from_bytes(data[off:off + 4], "little")
|
||||
|
||||
|
||||
def move_wide(word: int) -> tuple[str, int, int, int, int] | None:
|
||||
"""Decode MOVZ/MOVK/MOVN: (kind, sf, rd, imm16, shift) or None."""
|
||||
kinds = {0x52800000: "movz", 0x72800000: "movk", 0x12800000: "movn"}
|
||||
kind = kinds.get(word & 0x7F800000)
|
||||
if kind is None:
|
||||
return None
|
||||
return kind, word >> 31, word & 31, (word >> 5) & 0xFFFF, ((word >> 21) & 3) * 16
|
||||
|
||||
|
||||
def constants(words) -> list[int]:
|
||||
"""Every value a register holds after each MOVZ/MOVK/MOVN in a run of instruction words (how compilers build
|
||||
64-bit constants such as doubles: movz + movk)."""
|
||||
regs: dict[int, int] = {}
|
||||
out = []
|
||||
for w in words:
|
||||
mw = move_wide(w)
|
||||
if mw is None:
|
||||
continue
|
||||
kind, sf, rd, imm, shift = mw
|
||||
mask = (1 << 64) - 1 if sf else (1 << 32) - 1
|
||||
if kind == "movz":
|
||||
regs[rd] = imm << shift
|
||||
elif kind == "movn":
|
||||
regs[rd] = ~(imm << shift) & mask
|
||||
else:
|
||||
regs[rd] = (regs.get(rd, 0) & ~(0xFFFF << shift) | imm << shift) & mask
|
||||
out.append(regs[rd])
|
||||
return out
|
||||
|
||||
|
||||
def find_constant_load(words: list[int], value: int) -> int | None:
|
||||
"""Index of the first MOVZ/MOVN that starts building `value` (32-bit) in its register, followed by the MOVK that
|
||||
completes it within the next few instructions."""
|
||||
for i, w in enumerate(words):
|
||||
mw = move_wide(w)
|
||||
if mw is None or mw[0] == "movk":
|
||||
continue
|
||||
if value in constants(words[i:i + 4]):
|
||||
return i
|
||||
return None
|
||||
@@ -9,6 +9,7 @@ PC (Revive + local Steam) or on the Frame (Proton + Revive).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -21,6 +22,7 @@ from .core import library
|
||||
from .core.events import Reporter
|
||||
from .core.models import Recipe, SourceGame
|
||||
from .core.paths import output_dir
|
||||
from .patches import upstream
|
||||
from .recommend import engine
|
||||
from .sources import quest_dump, rift_dump
|
||||
from .targets.base import Target
|
||||
@@ -284,6 +286,30 @@ def is_linux(entry: dict) -> bool:
|
||||
return entry.get("kind") == "linux"
|
||||
|
||||
|
||||
def analysis_warnings(entry: dict) -> list[str]:
|
||||
"""Blockers read from a Quest/Android game's APK, for the CLI (the game page shows them as callouts)."""
|
||||
if is_rift(entry) or is_linux(entry) or not entry.get("analysis"):
|
||||
return []
|
||||
out = engine.blocker_notes(library.analysis_from_dict(entry["analysis"]))
|
||||
if missing_obb(entry):
|
||||
out.append(MISSING_OBB_NOTE.format(package=entry.get("package", "<package>")))
|
||||
return out
|
||||
|
||||
|
||||
MISSING_OBB_NOTE = ("This game's data file (.obb) wasn't found next to the APK. Put the .obb files in a folder named "
|
||||
"{package} (or obb/) next to the APK and add the folder again; without it the game hangs at start.")
|
||||
|
||||
|
||||
def missing_obb(entry: dict) -> bool:
|
||||
"""A Quest game whose APK expects an OBB (analysis expects_obb: Unreal's bHasOBBFiles) but no data folder was
|
||||
found next to it (GitHub #85: TRIANGLE STRATEGY hung silently after OVRPlugin's JNI_OnLoad). Library fields
|
||||
only: no disk access (used by the game page)."""
|
||||
if is_rift(entry) or is_linux(entry):
|
||||
return False
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
|
||||
|
||||
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
|
||||
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
|
||||
@@ -426,7 +452,8 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
result = target.install_linux(package, steam_title(entry), Path(entry["game_dir"]), entry["exe"],
|
||||
extra.get("files"), bool(extra.get("appimage")), bool(extra.get("openxr")),
|
||||
reporter, x86_64=bool(extra.get("x86_64")))
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False)
|
||||
if extra.get("x86_64"):
|
||||
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
|
||||
"there; not checked ahead)")
|
||||
@@ -486,7 +513,12 @@ def set_recipe(package: str, recipe: Recipe) -> None:
|
||||
|
||||
|
||||
def reset_recipe(package: str) -> Recipe:
|
||||
"""Back to the suggested recipe (catalog or heuristics) for the game's analysis (Quest and Rift alike)."""
|
||||
if is_rift(library.game(package) or {}):
|
||||
entry = library.game(package)
|
||||
a = library.analysis_from_dict(entry["analysis"])
|
||||
recipe = engine.suggest(a)
|
||||
set_recipe(package, recipe)
|
||||
return recipe
|
||||
entry = library.game(package)
|
||||
recipe = engine.suggest(library.analysis_from_dict(entry["analysis"]))
|
||||
set_recipe(package, recipe)
|
||||
@@ -611,7 +643,8 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
|
||||
art, store_title = artwork.fetch(package, res.apk)
|
||||
build_info = {"apk": str(res.apk), "alt_apk": str(res.alt_apk) if res.alt_apk else None, "sha256": res.sha256,
|
||||
"alt_sha256": res.alt_sha256, "applied": res.applied, "checks": res.checks, "ok": res.ok,
|
||||
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"])}
|
||||
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"]),
|
||||
"superseded": res.meta.get("superseded") or {}}
|
||||
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
|
||||
return build_info
|
||||
|
||||
@@ -634,6 +667,8 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
if not test_build and not apk.exists(): # the converted copy was removed after an earlier install: make it again
|
||||
build_game(package, reporter)
|
||||
return install_game(package, target, reporter, apk_only, add_to_library)
|
||||
if not test_build and b.get("superseded"): # workarounds this build left out (upstream fixed): not in settings.conf
|
||||
recipe = dataclasses.replace(recipe, patches=upstream.without_superseded(recipe.patches, b["superseded"]))
|
||||
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
|
||||
title = steam_title(entry)
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only)
|
||||
@@ -795,6 +830,10 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
|
||||
def test_game(package: str, target: Target, reporter: Reporter, seconds: int = 45) -> dict:
|
||||
result, log = target.launch_test(package, reporter, seconds)
|
||||
if missing_obb(library.game(package) or {}):
|
||||
from .validate.triage import add_missing_obb
|
||||
|
||||
add_missing_obb(result)
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
logs = user_data_dir() / "logs"
|
||||
|
||||
@@ -307,7 +307,7 @@ TOGGLED_DEVICE = ("device.text_input_window",)
|
||||
|
||||
def entry_from_library(g: dict, status: str | None = None, notes: str | None = None,
|
||||
verified: dict | None = None) -> CatalogEntry:
|
||||
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working recipe" publish)."""
|
||||
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working config" publish)."""
|
||||
import time
|
||||
|
||||
from ..core import library
|
||||
|
||||
@@ -80,6 +80,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
recipe.status = "unsupported"
|
||||
recipe.notes = f"No 64-bit ARM code ({', '.join(analysis.abis)}): the Steam Frame can't run it."
|
||||
if not rift:
|
||||
_static_blockers(analysis, recipe)
|
||||
_non_quest(analysis, recipe)
|
||||
if not rift and not entry and (analysis.extra or {}).get("frame_patched"):
|
||||
# already has FramePort's adapter (e.g. a PATCHED/ build): installing it unchanged is the safe default
|
||||
@@ -177,6 +178,39 @@ def warnings(recipe: Recipe) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def android_version_note(analysis: Analysis) -> str | None:
|
||||
"""'Its manifest asks for Android 14 (API 34)…' when the APK's minimum Android is newer than the Frame's."""
|
||||
from ..analysis.detect import FRAME_API, android_version, too_new_android
|
||||
|
||||
min_sdk = (analysis.extra or {}).get("min_sdk")
|
||||
if not too_new_android(min_sdk):
|
||||
return None
|
||||
return (f"Its manifest asks for Android {android_version(min_sdk)} (API {min_sdk}); the Frame's Android is "
|
||||
f"{android_version(FRAME_API)} (API {FRAME_API}). Apps like this often crash at start on newer Android "
|
||||
"parts, but not always: a launch test tells.")
|
||||
|
||||
|
||||
def web_wrapper_note(analysis: Analysis) -> str | None:
|
||||
"""A Trusted Web Activity: the APK only opens a website in Meta's browser (analysis.detect.web_wrapper)."""
|
||||
ww = (analysis.extra or {}).get("web_wrapper")
|
||||
if not ww:
|
||||
return None
|
||||
where = f"open {ww['url']} in a browser instead" if ww.get("url") else "open the website in a browser instead"
|
||||
return (f"This app looks like a website in an Android wrapper (Trusted Web Activity): it opens the site in "
|
||||
f"Meta's browser, which the Frame doesn't have. If nothing opens, {where}.")
|
||||
|
||||
|
||||
def blocker_notes(analysis: Analysis) -> list[str]:
|
||||
"""Warnings read from the APK's manifest that it may not run on the Frame (whatever the recipe says). Only
|
||||
warnings: the manifest alone never marks a game unsupported (owner's rule); a launch test's triage decides."""
|
||||
return [n for n in (android_version_note(analysis), web_wrapper_note(analysis)) if n]
|
||||
|
||||
|
||||
def _static_blockers(analysis: Analysis, recipe: Recipe) -> None:
|
||||
for note in blocker_notes(analysis):
|
||||
recipe.notes = _add(recipe.notes, note)
|
||||
|
||||
|
||||
def _non_quest(analysis: Analysis, recipe: Recipe) -> None:
|
||||
"""Android apps that aren't Meta Quest apps (analysis.detect.vr_kind)."""
|
||||
kind = analysis.vr_kind
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
Accepted layouts:
|
||||
<folder>/<something>.apk [+ <folder>/<package>/ (OBB or raw asset data)] e.g. common downloader layouts
|
||||
<folder>/<package>.apk + <folder>/obb/ FramePort/PATCHED output layout
|
||||
<folder>/x.apk + <folder>/obb/<package>/ or Android/obb/<package>/ backups with an obb folder
|
||||
<game>/apk/x.apk + <game>/obb/<package>/ SideQuest-style backups (one game)
|
||||
a single .apk file
|
||||
(find_data_dir has the details.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,11 +42,91 @@ def _is_apk(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
OBB_FOLDERS = ("obb", "obbs")
|
||||
|
||||
|
||||
def _subdirs(d: Path) -> list[Path]:
|
||||
try:
|
||||
return sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith(("_", ".")))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _child(d: Path, name: str) -> Path | None:
|
||||
"""d/name, matching the name's case loosely (backups come from Windows: 'OBB', a lower-case package folder)."""
|
||||
exact = d / name
|
||||
if exact.is_dir():
|
||||
return exact
|
||||
return next((p for p in _subdirs(d) if p.name.lower() == name.lower()), None)
|
||||
|
||||
|
||||
def _nonempty(d: Path | None) -> bool:
|
||||
try:
|
||||
return bool(d) and d.is_dir() and any(d.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _has_obb(d: Path) -> bool:
|
||||
try:
|
||||
return any(p.suffix.lower() == ".obb" and p.is_file() for p in d.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _package_dir_below(d: Path, pkg: str, depth: int, skip: Path | None = None, other_games: bool = False) \
|
||||
-> Path | None:
|
||||
"""A folder named like the package holding .obb files, at most `depth` levels below d. other_games: skip
|
||||
folders with APKs of their own (a neighbouring game folder, e.g. another version of the same game)."""
|
||||
for sub in _subdirs(d):
|
||||
if skip is not None and sub == skip:
|
||||
continue
|
||||
if sub.name.lower() == pkg.lower():
|
||||
if _has_obb(sub):
|
||||
return sub
|
||||
continue
|
||||
if depth > 1 and not (other_games and any(sub.glob("*.apk"))):
|
||||
found = _package_dir_below(sub, pkg, depth - 1)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def find_data_dir(apk_dir: Path, pkg: str | None) -> Path | None:
|
||||
"""The folder whose contents go to Android/obb/<package>/ (so the folder holding the .obb files themselves):
|
||||
<apk dir>/<package>/ downloader layouts
|
||||
<apk dir>/obb/<package>/ or <apk dir>/obb/ FramePort/PATCHED output, backups with an obb folder
|
||||
a <package> folder with .obb files up to 3 levels below the APK's folder (e.g. Android/obb/<package>/) or 2
|
||||
below its parent (SideQuest-style backups: <game>/apk/x.apk + <game>/obb/<package>/; the parent's folders
|
||||
with APKs of their own are other games and are skipped)."""
|
||||
own = _child(apk_dir, pkg) if pkg else None
|
||||
if _nonempty(own):
|
||||
return own
|
||||
for obb in (_child(apk_dir, name) for name in OBB_FOLDERS):
|
||||
if obb is None:
|
||||
continue
|
||||
inner = _child(obb, pkg) if pkg else None
|
||||
if _nonempty(inner):
|
||||
return inner
|
||||
if _nonempty(obb):
|
||||
return obb
|
||||
if not pkg:
|
||||
return None
|
||||
found = _package_dir_below(apk_dir, pkg, 3)
|
||||
if found is None and apk_dir.parent != apk_dir:
|
||||
found = _package_dir_below(apk_dir.parent, pkg, 2, skip=apk_dir, other_games=True)
|
||||
return found
|
||||
|
||||
|
||||
# a folder that only holds the APK inside a game folder (<game>/apk/x.apk + <game>/obb/…): the game is the parent
|
||||
APK_FOLDERS = ("apk", "apks")
|
||||
|
||||
|
||||
def from_path(path: Path) -> SourceGame | None:
|
||||
path = Path(path)
|
||||
if path.is_file() and path.suffix.lower() == ".apk":
|
||||
pkg = _package_of(path)
|
||||
data = next((d for d in (path.parent / (pkg or ""), path.parent / "obb") if pkg and d.is_dir()), None)
|
||||
data = find_data_dir(path.parent, pkg) if pkg else None
|
||||
return SourceGame(display_name(path.stem), path, data, path.parent)
|
||||
if not path.is_dir():
|
||||
return None
|
||||
@@ -53,12 +136,9 @@ def from_path(path: Path) -> SourceGame | None:
|
||||
primary = [a for a in apks if ".alt-" not in a.name]
|
||||
apk = primary[0] if primary else apks[0]
|
||||
pkg = _package_of(apk)
|
||||
data = None
|
||||
for cand in ((path / pkg) if pkg else None, path / "obb"):
|
||||
if cand and cand.is_dir() and any(cand.iterdir()):
|
||||
data = cand
|
||||
break
|
||||
return SourceGame(display_name(path.name), apk, data, path, [a for a in apks if a != apk])
|
||||
data = find_data_dir(path, pkg)
|
||||
game_dir = path.parent if path.name.lower() in APK_FOLDERS else path
|
||||
return SourceGame(display_name(game_dir.name), apk, data, game_dir, [a for a in apks if a != apk])
|
||||
|
||||
|
||||
def _no_quest_games_below(d: Path) -> bool:
|
||||
@@ -77,7 +157,8 @@ def _other_folders(d: Path, game: SourceGame) -> list[Path]:
|
||||
except OSError:
|
||||
return []
|
||||
own = {game.data_dir.resolve()} if game.data_dir else set()
|
||||
return [p for p in subs if p.resolve() not in own and p.name.lower() not in ("obb", "android")]
|
||||
own |= set(game.data_dir.resolve().parents) if game.data_dir else set() # e.g. Android/ of Android/obb/<pkg>
|
||||
return [p for p in subs if p.resolve() not in own and p.name.lower() not in (*OBB_FOLDERS, "android")]
|
||||
|
||||
|
||||
def scan(root: Path, depth: int = 5) -> list[SourceGame]:
|
||||
|
||||
@@ -33,7 +33,8 @@ class Target(ABC):
|
||||
"""Start an installed game for playing, through the target's Steam (Steam library shortcut)."""
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False) -> dict:
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True) -> dict:
|
||||
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
|
||||
raise NotImplementedError(f"{self.label} can't install Linux apps")
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
from ..frame.connection import Frame, FrameTarget, sh_quote
|
||||
from ..install import installer
|
||||
from ..install import drives, installer
|
||||
from ..validate import device
|
||||
from .base import Target
|
||||
|
||||
@@ -19,6 +19,12 @@ class FrameLeptonTarget(Target):
|
||||
self.target = target
|
||||
self.frame = Frame(target, password)
|
||||
self.label = target.label
|
||||
self.dest: str | None = None # where new games go; None = the library setting (drives.install_dest)
|
||||
|
||||
def install_dest(self) -> str | None:
|
||||
"""A drive's install dir for new installs (GitHub #90), None = internal storage. Reinstalls stay where the
|
||||
game is (the agent keeps an installed game's folder)."""
|
||||
return self.dest if self.dest is not None else drives.install_dest()
|
||||
|
||||
def connect(self) -> FrameLeptonTarget:
|
||||
if self.frame.client is not None and not self.frame.alive():
|
||||
@@ -34,7 +40,7 @@ class FrameLeptonTarget(Target):
|
||||
return self.connect().frame.agent("list_installed")["games"]
|
||||
|
||||
def install(self, package, title, apk: Path, data_dir, recipe: Recipe, reporter: Reporter, apk_only=False):
|
||||
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only)
|
||||
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only, dest=self.install_dest())
|
||||
return installer.install(self.connect().frame, plan, reporter)
|
||||
|
||||
def add_to_library(self, packages, reporter):
|
||||
@@ -60,13 +66,27 @@ class FrameLeptonTarget(Target):
|
||||
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"))
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest())
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64)
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
return drives.list_drives(self.connect().frame)
|
||||
|
||||
def move(self, package: str, dest: str, reporter: Reporter) -> dict:
|
||||
"""Move an installed game's files to another drive (or back to internal storage: dest "internal")."""
|
||||
return drives.move(self.connect().frame, package, dest, reporter)
|
||||
|
||||
def set_desktop_entry(self, package: str, enabled: bool) -> dict:
|
||||
"""A Linux app's Desktop Mode entry on or off (GitHub #84)."""
|
||||
return self.connect().frame.agent("desktop_entry", package=package, enabled=enabled)
|
||||
|
||||
def proton_status(self, tool: str | None = None) -> dict:
|
||||
return self.connect().frame.agent("proton_status", tool=tool)
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ def advice(battery: dict | None, frame_work: bool, paused: str | None, warned: b
|
||||
|
||||
|
||||
def label(battery: dict | None) -> str:
|
||||
""" "76%" / "76% ⚡" (charging or plugged in); "" without a battery reading."""
|
||||
""" "76 %" / "76 % ⚡" (charging or plugged in); "" without a battery reading."""
|
||||
if not battery:
|
||||
return ""
|
||||
return f"{battery.get('percent', 0)}%" + (" ⚡" if charging(battery) else "")
|
||||
return f"{battery.get('percent', 0)} %" + (" ⚡" if charging(battery) else "")
|
||||
|
||||
|
||||
def icon(battery: dict) -> str:
|
||||
@@ -55,10 +55,10 @@ def low(battery: dict | None) -> bool:
|
||||
def message(kind: str, battery: dict) -> str:
|
||||
pct = battery.get("percent", 0)
|
||||
if kind == "pause":
|
||||
return tr("The Frame's battery is at {pct}%: installs paused so it doesn't switch off mid-upload. Plug it "
|
||||
return tr("The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it "
|
||||
"in; the queue continues by itself.").format(pct=pct)
|
||||
if kind == "warn":
|
||||
return tr("The Frame's battery is at {pct}% and not charging. Plug it in (a strong charger, not a PC "
|
||||
"port): long installs can drain it, and FramePort pauses them at {pause}%.").format(
|
||||
return tr("The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC "
|
||||
"port): long installs can drain it, and FramePort pauses them at {pause} %.").format(
|
||||
pct=pct, pause=PAUSE_AT)
|
||||
return tr("The Frame is charging: continuing the installs.")
|
||||
@@ -3,44 +3,48 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import NamedTuple
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..i18n import tr
|
||||
from . import glyphs
|
||||
from . import glyphs as G # G.NAME at call sites, like the other modules
|
||||
from . import theme as T
|
||||
from .help import HELP
|
||||
|
||||
STATUS_STYLE: dict[str, tuple] = {}
|
||||
STATUS_STYLE = {
|
||||
"works": (tr("Works"), T.OK),
|
||||
"issues": (tr("Works with issues"), T.WARN),
|
||||
"unsupported": (tr("Can't run"), T.ERROR),
|
||||
"unknown": (tr("Untested"), T.TEXT_3),
|
||||
}
|
||||
# per-game state on a target: label, icon, color
|
||||
INSTALL_STYLE: dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _fill_styles() -> None:
|
||||
"""(Re)fill the style maps with the active theme's colours, in place: callers keep a reference to the dicts."""
|
||||
STATUS_STYLE.update({
|
||||
"works": (tr("Works"), T.OK),
|
||||
"issues": (tr("Works with issues"), T.WARN),
|
||||
"unsupported": (tr("Can't run"), T.ERROR),
|
||||
"unknown": (tr("Untested"), T.TEXT_3),
|
||||
})
|
||||
INSTALL_STYLE.update({
|
||||
"installed": (tr("On Frame"), ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
|
||||
"outdated": (tr("Update ready"), ft.Icons.UPDATE_ROUNDED, T.WARN),
|
||||
"missing": (tr("Not installed"), ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
|
||||
"on_pc": (tr("On this PC"), G.PC, T.PC),
|
||||
})
|
||||
|
||||
|
||||
T.on_change(_fill_styles)
|
||||
INSTALL_STYLE = {
|
||||
"installed": (tr("On Frame"), ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK),
|
||||
"outdated": (tr("Update ready"), ft.Icons.UPDATE_ROUNDED, T.WARN),
|
||||
"missing": (tr("Not installed"), ft.Icons.CLOUD_OFF_ROUNDED, T.TEXT_3),
|
||||
"on_pc": (tr("On this PC"), ft.Icons.COMPUTER_ROUNDED, T.PC),
|
||||
}
|
||||
|
||||
|
||||
def _deployment(game: dict, frame_info: dict | None) -> dict | None:
|
||||
return next((d for d in (frame_info or {}).get("installed", []) if d.get("package") == game.get("package")), None)
|
||||
|
||||
|
||||
def frame_drive(game: dict, frame_info: dict | None) -> dict | None:
|
||||
"""The drive the game is installed on ({internal, path, label, missing}), None if not installed / old agent."""
|
||||
from ..install.drives import game_drive
|
||||
|
||||
return game_drive(_deployment(game, frame_info))
|
||||
|
||||
|
||||
def drive_note(drive: dict | None) -> str:
|
||||
"""" · on SD Card" / " · SD Card not inserted" for a game on another drive, "" on internal storage."""
|
||||
if not drive or drive.get("internal"):
|
||||
return ""
|
||||
if drive.get("missing"):
|
||||
return tr(" · {label} not inserted").format(label=drive.get("label") or tr("its drive"))
|
||||
return tr(" · on {label}").format(label=drive.get("label") or "")
|
||||
|
||||
|
||||
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
|
||||
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
|
||||
unknown: installs from before FramePort recorded their recipe)."""
|
||||
@@ -75,8 +79,8 @@ def platform(g: dict) -> tuple[str, str, str]:
|
||||
|
||||
def platform_icon(g: dict) -> str:
|
||||
"""The icon for a game's platform (card badge, artwork placeholder)."""
|
||||
return G.PC if g.get("kind") == "rift" else \
|
||||
ft.Icons.TERMINAL_ROUNDED if g.get("kind") == "linux" else G.FRAME
|
||||
return ft.Icons.COMPUTER_ROUNDED if g.get("kind") == "rift" else \
|
||||
ft.Icons.TERMINAL_ROUNDED if g.get("kind") == "linux" else ft.Icons.VIEW_IN_AR_ROUNDED
|
||||
|
||||
|
||||
def missing_libraries(game: dict, frame_info: dict | None) -> list[str]:
|
||||
@@ -99,7 +103,9 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
if frame_info is None:
|
||||
return None
|
||||
dep = _deployment(game, frame_info)
|
||||
if not dep or dep.get("apk_present") is False: # no record, or the game files are gone (half uninstalled)
|
||||
# no record, or the game files are gone (half uninstalled); a game on a microSD card that isn't inserted now is
|
||||
# still installed (GitHub #90)
|
||||
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
|
||||
return "missing"
|
||||
b = game.get("build") or {}
|
||||
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
|
||||
@@ -174,8 +180,6 @@ def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
|
||||
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
|
||||
wrap=False keeps the built-in label, for a short label in a Row next to other controls."""
|
||||
kw.setdefault("active_color", T.ACCENT)
|
||||
if T.DUAL: # orange thumb on a blue track
|
||||
kw.setdefault("active_track_color", T.soft(T.SECONDARY, 0.5))
|
||||
if not wrap or not label:
|
||||
return ft.Switch(label=label or None, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
|
||||
sw = ft.Switch(**kw)
|
||||
@@ -187,27 +191,25 @@ def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
|
||||
update(sw)
|
||||
if sw.on_change:
|
||||
sw.on_change(SimpleNamespace(control=sw))
|
||||
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label,
|
||||
ink=True, border_radius=T.RADIUS_XS)],
|
||||
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label)],
|
||||
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch")
|
||||
|
||||
|
||||
def body(text: str, color: str | None = None, size: int | None = None, **kw) -> ft.Text:
|
||||
size, color = size or T.T_BODY, color or T.TEXT_2
|
||||
def body(text: str, color: str = T.TEXT_2, size: int | None = None, **kw) -> ft.Text:
|
||||
size = size or T.T_BODY
|
||||
return ft.Text(text, size=size, color=color, **kw)
|
||||
|
||||
|
||||
def meta(text: str, color: str | None = None, **kw) -> ft.Text:
|
||||
return ft.Text(text, size=T.T_META, color=color or T.TEXT_3, **kw)
|
||||
def meta(text: str, color: str = T.TEXT_3, **kw) -> ft.Text:
|
||||
return ft.Text(text, size=T.T_META, color=color, **kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ chips / badges
|
||||
def pill(text: str, color: str | None = None, icon: str | None = None, solid: bool = False,
|
||||
def pill(text: str, color: str = T.TEXT_2, icon: str | None = None, solid: bool = False,
|
||||
tooltip: str | ft.Tooltip | None = None, overlay: bool = False) -> ft.Container:
|
||||
"""overlay: for use on top of artwork (dark translucent backing so it reads on any image)."""
|
||||
color = color or T.TEXT_2
|
||||
fg = T.ON_ACCENT if solid else color
|
||||
items = ([as_icon(icon, T.px(13), fg)] if icon else []) + \
|
||||
items = ([ft.Icon(icon, size=T.px(13), color=fg)] if icon else []) + \
|
||||
[ft.Text(text, size=T.T_SMALL, color=fg, weight=ft.FontWeight.W_600)]
|
||||
return ft.Container(ft.Row(items, spacing=T.px(4), tight=True), tooltip=tooltip,
|
||||
bgcolor=color if solid else T.soft("#000000", 0.62) if overlay else T.soft(color, 0.12),
|
||||
@@ -226,8 +228,6 @@ def install_badge(state: str, solid: bool = True) -> ft.Container:
|
||||
|
||||
|
||||
def dot(color: str, size: int = 8) -> ft.Container:
|
||||
"""A status dot; `size` in pixels at 100 % (scaled here)."""
|
||||
size = T.px(size)
|
||||
return ft.Container(width=size, height=size, border_radius=size, bgcolor=color)
|
||||
|
||||
|
||||
@@ -260,67 +260,7 @@ def with_help(control: ft.Control, key: str | None) -> ft.Control:
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ icons
|
||||
def _asset_src(name: str) -> str:
|
||||
from ..artwork.thumbs import asset_url
|
||||
from ..core.paths import user_data_dir
|
||||
|
||||
return asset_url(glyphs.path(name, user_data_dir()))
|
||||
|
||||
|
||||
def as_icon(name, size: float | None = None, color: str | None = None) -> ft.Control:
|
||||
"""A Material icon (ft.Icons.…) or one of FramePort's glyphs (glyphs.FRAME …, tinted like an icon). Its colour can
|
||||
be changed later through .color either way."""
|
||||
size, color = size or T.px(20), color or T.TEXT_2
|
||||
if glyphs.is_glyph(name):
|
||||
return ft.Image(src=_asset_src(name), width=size, height=size, color=color,
|
||||
color_blend_mode=ft.BlendMode.SRC_IN, fit=ft.BoxFit.CONTAIN)
|
||||
return ft.Icon(name, size=size, color=color)
|
||||
|
||||
|
||||
def logo(size: float, solid: bool = False) -> ft.Image:
|
||||
"""FramePort's logo (the monitor that comes out of a portal as the Frame); solid = the flat version for small
|
||||
sizes."""
|
||||
return ft.Image(src=_asset_src("logo-solid" if solid else "logo"), width=size, height=size, fit=ft.BoxFit.CONTAIN)
|
||||
|
||||
|
||||
def _btn_icon(name, color: str, disabled: bool = False, size: float = 18):
|
||||
"""A button's icon: Material icons stay as they are (the control colours them), glyphs become tinted images at
|
||||
the size Flutter gives icons in buttons (18; not scaled with the UI, like the Material icons next to them).
|
||||
Menu items size their icons themselves (menu_items)."""
|
||||
return as_icon(name, size, T.TEXT_3 if disabled else color) if glyphs.is_glyph(name) else name
|
||||
|
||||
|
||||
def wordmark(size: float) -> ft.Control:
|
||||
""""FramePort": in dual themes "Frame" in the Frame's orange, a portal, then "Port" in the PC's blue."""
|
||||
if not T.DUAL:
|
||||
return ft.Text("FramePort", size=size, weight=ft.FontWeight.W_800, color=T.TEXT)
|
||||
from ..artwork.thumbs import asset_url
|
||||
from ..core.paths import user_data_dir
|
||||
|
||||
mark = glyphs.portal_mark(user_data_dir(), T.ACCENT, T.SECONDARY, T.BG)
|
||||
word = lambda text, color: ft.Text(text, size=size, weight=ft.FontWeight.W_800, color=color) # noqa: E731
|
||||
portal = ft.Image(src=asset_url(mark), width=size * 0.8, height=size * 1.4, fit=ft.BoxFit.CONTAIN, scale=1.0,
|
||||
animate_scale=ft.Animation(220, ft.AnimationCurve.EASE_OUT))
|
||||
# .data = the portal (the app pulses it once when the Frame connects)
|
||||
return ft.Row([word("Frame", T.ACCENT), portal, word("Port", T.SECONDARY)], data=portal,
|
||||
spacing=size * 0.08, tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
|
||||
def portal_gradient(vertical: bool = False, opacity: float = 1.0) -> ft.LinearGradient:
|
||||
"""Blue (the PC's portal) into orange (the Frame's), as in the logo."""
|
||||
begin, end = (ft.Alignment.TOP_CENTER, ft.Alignment.BOTTOM_CENTER) if vertical else \
|
||||
(ft.Alignment.CENTER_LEFT, ft.Alignment.CENTER_RIGHT)
|
||||
return ft.LinearGradient(begin=begin, end=end,
|
||||
colors=[T.soft(T.SECONDARY, opacity), T.soft(T.ACCENT, opacity)])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ buttons
|
||||
def button_shape(radius=None) -> ft.RoundedRectangleBorder:
|
||||
"""The buttons' shape (RADIUS_SM unless given)."""
|
||||
return _shape(radius)
|
||||
|
||||
|
||||
def _shape(radius=None):
|
||||
radius = T.RADIUS_SM if radius is None else radius
|
||||
return ft.RoundedRectangleBorder(radius=radius)
|
||||
@@ -330,8 +270,7 @@ def primary(label: str, icon: str | None = None, on_click: Callable | None = Non
|
||||
tooltip: str | None = None, big: bool = False) -> ft.FilledButton:
|
||||
padding = (ft.Padding(T.px(22), T.px(18), T.px(22), T.px(18)) if big
|
||||
else ft.Padding(T.px(16), T.px(12), T.px(16), T.px(12)))
|
||||
return ft.FilledButton(label, icon=_btn_icon(icon, T.ON_ACCENT, disabled), on_click=on_click, disabled=disabled,
|
||||
tooltip=tooltip,
|
||||
return ft.FilledButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), bgcolor={ft.ControlState.DEFAULT: T.ACCENT,
|
||||
ft.ControlState.DISABLED: T.SURFACE_3},
|
||||
color={ft.ControlState.DEFAULT: T.ON_ACCENT,
|
||||
@@ -343,201 +282,30 @@ def primary(label: str, icon: str | None = None, on_click: Callable | None = Non
|
||||
|
||||
def secondary(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None) -> ft.OutlinedButton:
|
||||
# dual themes: secondary actions are the blue portal next to the orange primary one
|
||||
edge, mark = (T.soft(T.SECONDARY, 0.55), T.SECONDARY) if T.DUAL else (T.BORDER_STRONG, T.TEXT)
|
||||
return ft.OutlinedButton(label, icon=_btn_icon(icon, mark, disabled), on_click=on_click, disabled=disabled,
|
||||
tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), side=ft.BorderSide(1, edge),
|
||||
return ft.OutlinedButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), side=ft.BorderSide(1, T.BORDER_STRONG),
|
||||
color={ft.ControlState.DEFAULT: T.TEXT,
|
||||
ft.ControlState.DISABLED: T.TEXT_3},
|
||||
icon_color={ft.ControlState.DEFAULT: mark,
|
||||
ft.ControlState.DISABLED: T.TEXT_3},
|
||||
overlay_color=T.soft(T.SECONDARY, 0.08) if T.DUAL else None,
|
||||
padding=ft.Padding(T.px(14), T.px(12), T.px(14), T.px(12))))
|
||||
|
||||
|
||||
def ghost(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None, color: str | None = None, url: str | None = None) -> ft.TextButton:
|
||||
"""A quiet action (Close, Cancel, links); `color` e.g. T.ACCENT for a link, `url` opens in the browser."""
|
||||
return ft.TextButton(label, icon=_btn_icon(icon, color or T.TEXT_2, disabled), on_click=on_click,
|
||||
disabled=disabled, tooltip=tooltip, url=url,
|
||||
style=ft.ButtonStyle(shape=_shape(), color={ft.ControlState.DEFAULT: color or T.TEXT_2,
|
||||
tooltip: str | None = None, color: str = T.TEXT_2) -> ft.TextButton:
|
||||
return ft.TextButton(label, icon=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
|
||||
style=ft.ButtonStyle(shape=_shape(), color={ft.ControlState.DEFAULT: color,
|
||||
ft.ControlState.DISABLED: T.TEXT_3}))
|
||||
|
||||
|
||||
def icon_btn(icon: str, tooltip: str, on_click: Callable | None = None, disabled: bool = False,
|
||||
color: str | None = None) -> ft.IconButton:
|
||||
return ft.IconButton(_btn_icon(icon, color or T.TEXT_2, disabled), tooltip=tooltip, on_click=on_click,
|
||||
disabled=disabled, icon_color=color or T.TEXT_2,
|
||||
color: str = T.TEXT_2) -> ft.IconButton:
|
||||
return ft.IconButton(icon, tooltip=tooltip, on_click=on_click, disabled=disabled, icon_color=color,
|
||||
icon_size=T.px(20), style=ft.ButtonStyle(shape=_shape()))
|
||||
|
||||
|
||||
def danger(label: str, icon: str | None = None, on_click: Callable | None = None, disabled: bool = False,
|
||||
tooltip: str | None = None, outline: bool = False) -> ft.FilledButton | ft.OutlinedButton:
|
||||
"""A destructive action (uninstall, delete): C.primary's shape and size on the error colour. outline = C.secondary's
|
||||
look in the error colour, for an entry that asks first (the filled one is the dialog's final button)."""
|
||||
if outline:
|
||||
btn = secondary(label, icon, on_click, disabled, tooltip)
|
||||
btn.style.side = ft.BorderSide(1, T.soft(T.ERROR, 0.6))
|
||||
btn.style.color = {ft.ControlState.DEFAULT: T.ERROR, ft.ControlState.DISABLED: T.TEXT_3}
|
||||
btn.style.icon_color = {ft.ControlState.DEFAULT: T.ERROR, ft.ControlState.DISABLED: T.TEXT_3}
|
||||
btn.style.overlay_color = T.soft(T.ERROR, 0.08)
|
||||
if glyphs.is_glyph(icon):
|
||||
btn.icon = _btn_icon(icon, T.ERROR, disabled)
|
||||
return btn
|
||||
btn = primary(label, icon, on_click, disabled, tooltip)
|
||||
btn.style.bgcolor = {ft.ControlState.DEFAULT: T.ERROR, ft.ControlState.DISABLED: T.SURFACE_3}
|
||||
return btn
|
||||
|
||||
|
||||
_danger = danger # for functions with a `danger` argument
|
||||
|
||||
|
||||
def primary_menu(label: str, icon: str | None, items: list[ft.PopupMenuItem], tooltip: str | None = None
|
||||
) -> ft.PopupMenuButton:
|
||||
"""A menu that opens from a button looking exactly like C.primary (e.g. Library → Add games). The button is
|
||||
drawn disabled (in the enabled colours) so it doesn't take the click: the menu button around it does."""
|
||||
btn = primary(label, icon, disabled=True)
|
||||
btn.style.bgcolor = T.ACCENT
|
||||
btn.style.color = T.ON_ACCENT
|
||||
btn.style.icon_color = T.ON_ACCENT
|
||||
btn.style.mouse_cursor = ft.MouseCursor.CLICK
|
||||
return menu_button(items, content=btn, tooltip=tooltip or "")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ inputs
|
||||
def _input_style(kw: dict) -> dict:
|
||||
"""The one input look: filled SURFACE_3, no border until focused (then the accent), small radius."""
|
||||
kw.setdefault("dense", True)
|
||||
kw.setdefault("border_radius", T.RADIUS_SM)
|
||||
kw.setdefault("border_color", ft.Colors.TRANSPARENT)
|
||||
kw.setdefault("focused_border_color", T.ACCENT)
|
||||
kw.setdefault("text_size", T.T_BODY)
|
||||
kw.setdefault("content_padding", ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)))
|
||||
return kw
|
||||
|
||||
|
||||
def field(**kw) -> ft.TextField:
|
||||
"""A text field in the app's input style (all ft.TextField arguments work)."""
|
||||
kw = _input_style(kw)
|
||||
kw.setdefault("bgcolor", T.SURFACE_3) # (filled=True would add Material's extra height)
|
||||
kw.setdefault("cursor_color", T.ACCENT)
|
||||
if kw.get("label") is not None:
|
||||
kw.setdefault("label_style", ft.TextStyle(color=T.TEXT_2, size=T.T_BODY))
|
||||
return ft.TextField(**kw)
|
||||
|
||||
|
||||
def search(**kw) -> ft.TextField:
|
||||
"""A search field: C.field with a magnifier."""
|
||||
kw.setdefault("prefix_icon", ft.Icons.SEARCH_ROUNDED)
|
||||
kw.setdefault("content_padding", ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)))
|
||||
return field(**kw)
|
||||
|
||||
|
||||
def dropdown(**kw) -> ft.Dropdown:
|
||||
"""A dropdown in the app's input style (all ft.Dropdown arguments work)."""
|
||||
kw.setdefault("content_padding", ft.Padding(T.px(12), T.px(6), T.px(8), T.px(6)))
|
||||
kw = _input_style(kw)
|
||||
kw.setdefault("filled", True)
|
||||
kw.setdefault("fill_color", T.SURFACE_3)
|
||||
if kw.get("label") is not None:
|
||||
kw.setdefault("label_style", ft.TextStyle(color=T.TEXT_2, size=T.T_BODY))
|
||||
return ft.Dropdown(**kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ selection
|
||||
def selected_style(box: ft.Container, on: bool, icon: ft.Control | None = None, text: ft.Control | None = None,
|
||||
idle_bg: str | None = None, subtle: bool = False) -> None:
|
||||
"""The app-wide "this one is selected" look, set on an existing container (call again when it changes): dual
|
||||
themes = the portal fade with a white icon and label (an orange icon on the fade clashed: owner), else the accent
|
||||
tint with an accent icon. `idle_bg` = the background when not selected; subtle = a lighter fade (an overlay on
|
||||
a picture, e.g. a selected screenshot)."""
|
||||
if T.DUAL:
|
||||
box.bgcolor, box.gradient = (None, portal_gradient(opacity=0.14 if subtle else 0.22)) if on else (idle_bg, None)
|
||||
else:
|
||||
box.bgcolor, box.gradient = (T.soft(T.ACCENT, 0.12) if subtle else T.ACCENT_SOFT) if on else idle_bg, None
|
||||
if icon is not None:
|
||||
icon.color = (T.TEXT if T.DUAL else T.ACCENT) if on else T.TEXT_2
|
||||
if text is not None:
|
||||
text.color = T.TEXT if on else T.TEXT_2
|
||||
|
||||
|
||||
def quiet(*controls: ft.Control, shown: bool = False) -> ft.Container:
|
||||
"""Row/tile actions that only show on hover, keyboard focus or selection ("quiet rows"): opacity 0 keeps their
|
||||
space, so the layout doesn't jump when they appear. Toggle with reveal()."""
|
||||
content = controls[0] if len(controls) == 1 else ft.Row(list(controls), spacing=0, tight=True)
|
||||
return ft.Container(content, opacity=1.0 if shown else 0.0, animate_opacity=ft.Animation(120))
|
||||
|
||||
|
||||
def reveal(box: ft.Container, on: bool) -> bool:
|
||||
"""Show/hide a quiet() box; True when it changed (the caller updates it)."""
|
||||
value = 1.0 if on else 0.0
|
||||
if box.opacity == value:
|
||||
return False
|
||||
box.opacity = value
|
||||
return True
|
||||
|
||||
|
||||
def segmented(options: list[tuple[str, str]], value: str | None, on_change: Callable[[str], None]) -> ft.Container:
|
||||
"""A pill track of choices (one selected): the selected one is the portal fade in dual themes, else SURFACE_3.
|
||||
Clicking restyles in place, then calls on_change(value)."""
|
||||
items: dict[str, ft.Container] = {}
|
||||
|
||||
def paint(current):
|
||||
for v, box in items.items():
|
||||
on = v == current
|
||||
box.content.color = T.TEXT if on else T.TEXT_2
|
||||
if T.DUAL:
|
||||
box.bgcolor, box.gradient = None, portal_gradient(opacity=0.22) if on else None
|
||||
else:
|
||||
box.bgcolor, box.gradient = T.SURFACE_3 if on else None, None
|
||||
|
||||
def click(v):
|
||||
def handler(e):
|
||||
if track.data == v:
|
||||
return
|
||||
track.data = v
|
||||
paint(v)
|
||||
update(track)
|
||||
on_change(v)
|
||||
return handler
|
||||
|
||||
for v, label in options:
|
||||
items[v] = ft.Container(ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600),
|
||||
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20),
|
||||
on_click=click(v), ink=True)
|
||||
track = ft.Container(ft.Row(list(items.values()), spacing=T.px(2), tight=True), padding=T.px(3),
|
||||
border_radius=T.px(22), border=ft.Border.all(1, T.BORDER), data=value)
|
||||
paint(value)
|
||||
return track
|
||||
|
||||
|
||||
def hoverable(box: ft.Container) -> ft.Container:
|
||||
"""A clickable container: ink (the hover highlight and the click ripple, drawn over its background or the
|
||||
selected fade) like the buttons around it."""
|
||||
box.ink = True
|
||||
return box
|
||||
|
||||
|
||||
def spinner(size: str = "s", color: str | None = None) -> ft.ProgressRing:
|
||||
"""Something is running: "s" (16, next to text) or "m" (24); blue in dual themes (progress), else the accent.
|
||||
`color` only for a spinner on a coloured fill (e.g. ON_ACCENT inside a primary button)."""
|
||||
px = T.ICON_S if size == "s" else T.ICON_L
|
||||
return ft.ProgressRing(width=px, height=px, stroke_width=T.px(2),
|
||||
color=color or (T.SECONDARY if T.DUAL else T.ACCENT))
|
||||
|
||||
|
||||
def gauge(value: float = 0.0, size: int = 40, color: str | None = None, stroke: float = 3.5) -> ft.ProgressRing:
|
||||
"""A level as a ring (e.g. the Frame card's battery): value 0..1 on a SURFACE_3 track; `size` px at 100 %."""
|
||||
px = T.px(size)
|
||||
return ft.ProgressRing(value=value, width=px, height=px, stroke_width=T.px(stroke), color=color or T.OK,
|
||||
bgcolor=T.SURFACE_3)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ containers
|
||||
def card(content: ft.Control, padding: int | None = None, bgcolor: str | None = None, **kw) -> ft.Container:
|
||||
def card(content: ft.Control, padding: int | None = None, bgcolor: str = T.SURFACE, **kw) -> ft.Container:
|
||||
padding = T.S4 if padding is None else padding
|
||||
return ft.Container(content, padding=padding, bgcolor=bgcolor or T.SURFACE, border_radius=T.RADIUS,
|
||||
return ft.Container(content, padding=padding, bgcolor=bgcolor, border_radius=T.RADIUS,
|
||||
border=ft.Border.all(1, T.BORDER), **kw)
|
||||
|
||||
|
||||
@@ -553,33 +321,20 @@ def callout(text: str | ft.Control, kind: str = "info", icon: str | None = None)
|
||||
color = {"info": T.INFO, "warn": T.WARN, "error": T.ERROR, "ok": T.OK, "pc": T.PC}[kind]
|
||||
icon = icon or {"info": ft.Icons.INFO_OUTLINE_ROUNDED, "warn": ft.Icons.WARNING_AMBER_ROUNDED,
|
||||
"error": ft.Icons.ERROR_OUTLINE_ROUNDED, "ok": ft.Icons.CHECK_CIRCLE_OUTLINE_ROUNDED,
|
||||
"pc": G.PC}[kind]
|
||||
"pc": ft.Icons.COMPUTER_ROUNDED}[kind]
|
||||
content = body(text, T.TEXT) if isinstance(text, str) else text
|
||||
return ft.Container(ft.Row([as_icon(icon, T.px(18), color), ft.Container(content, expand=True)],
|
||||
return ft.Container(ft.Row([ft.Icon(icon, color=color, size=T.px(18)), ft.Container(content, expand=True)],
|
||||
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
bgcolor=T.soft(color, 0.08), border=ft.Border.all(1, T.soft(color, 0.35)),
|
||||
border_radius=T.RADIUS_SM, padding=ft.Padding(T.px(14), T.px(12), T.px(14), T.px(12)))
|
||||
|
||||
|
||||
def portal_scene(icon: str, height: float | None = None) -> ft.Control:
|
||||
"""The transit's portal with what the page is about (`icon`) coming out of it, on a faint glow: the empty states'
|
||||
illustration (the Live view's idle picture uses the same portal + headset)."""
|
||||
h = height or T.px(72)
|
||||
portal = transit_parts()["portal"]
|
||||
return ft.Container(
|
||||
ft.Row([ft.Image(src=portal, height=h, width=h * 24 / 50, fit=ft.BoxFit.CONTAIN),
|
||||
as_icon(icon, h * 0.5, T.TEXT_2)],
|
||||
spacing=T.S3, tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
# the glow fades out at the box's top and bottom edges (radius = half the shortest side): no visible edge
|
||||
padding=ft.Padding(T.S6, T.S3, T.S6, T.S3),
|
||||
gradient=ft.RadialGradient(colors=[T.soft(T.ACCENT, 0.13), T.soft(T.ACCENT, 0.0)], radius=0.5))
|
||||
|
||||
|
||||
def empty_state(icon: str, heading: str, text: str, *actions: ft.Control) -> ft.Container:
|
||||
return ft.Container(ft.Column([
|
||||
portal_scene(icon),
|
||||
ft.Container(ft.Icon(icon, size=T.px(40), color=T.ACCENT), width=T.px(84), height=T.px(84),
|
||||
border_radius=T.px(42), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Container(height=T.S2),
|
||||
title(heading, T.T_DISPLAY),
|
||||
title(heading, 22),
|
||||
ft.Container(body(text, text_align=ft.TextAlign.CENTER), width=T.px(460)),
|
||||
ft.Container(height=T.S2),
|
||||
ft.Row(list(actions), alignment=ft.MainAxisAlignment.CENTER, spacing=T.S3),
|
||||
@@ -593,56 +348,20 @@ def kv(label: str, value: str | ft.Control, help: str | None = None) -> ft.Row:
|
||||
expand=True)], vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
|
||||
class Check(NamedTuple):
|
||||
"""One line of a setup checklist (checklist()). ok: True = ready, False = a problem, "warn" = optional/not
|
||||
needed yet, None = still checking (spinner). fix = (label, icon, handler) shown on the right while the item isn't
|
||||
ready (handler() takes no arguments); extra = a control shown on the right in every state (e.g. "Test")."""
|
||||
ok: bool | str | None
|
||||
title: str
|
||||
detail: str = ""
|
||||
help: str | None = None
|
||||
fix: tuple[str, str | None, Callable[[], object]] | None = None
|
||||
extra: ft.Control | None = None
|
||||
def status_row(ok: bool | None, heading: str, detail: str = "", action: ft.Control | None = None,
|
||||
help: str | None = None) -> ft.Container:
|
||||
icon, color = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.ERROR_ROUNDED, T.ERROR),
|
||||
None: (ft.Icons.RADIO_BUTTON_UNCHECKED_ROUNDED, T.WARN)}[ok]
|
||||
return ft.Container(ft.Row([
|
||||
ft.Icon(icon, color=color, size=T.px(20)),
|
||||
ft.Column([with_help(body(heading, T.TEXT, weight=ft.FontWeight.W_500), help)]
|
||||
+ ([meta(detail)] if detail else []),
|
||||
spacing=1, expand=True),
|
||||
*([action] if action else []),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), padding=ft.Padding(0, T.px(6), 0, T.px(6)))
|
||||
|
||||
|
||||
def check_look(ok: bool | str | None) -> tuple[str | None, str]:
|
||||
"""(icon, colour) of a checklist mark; icon None = a spinner (still checking)."""
|
||||
return {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.ERROR_ROUNDED, T.ERROR),
|
||||
"warn": (ft.Icons.RADIO_BUTTON_UNCHECKED_ROUNDED, T.WARN),
|
||||
None: (None, T.SECONDARY if T.DUAL else T.ACCENT)}[ok]
|
||||
|
||||
|
||||
def check_fix(item: Check) -> tuple[str, str | None, Callable[[], object]] | None:
|
||||
"""The fix button an item shows: only while it's a problem or not set up (not while ready or still checking)."""
|
||||
return item.fix if item.ok in (False, "warn") else None
|
||||
|
||||
|
||||
def checklist(items: list[Check | tuple]) -> ft.Column:
|
||||
"""The setup checklists (Frame → Ready to play, Settings → Tools and This PC): a mark, the title (+ help) and
|
||||
detail, and the item's fix button inline on the right. Put it in a card(padding=Padding(S4, S2, S4, S2))."""
|
||||
rows = []
|
||||
for raw in items:
|
||||
item = raw if isinstance(raw, Check) else Check(*raw)
|
||||
icon, color = check_look(item.ok)
|
||||
mark = ft.Container(spinner() if icon is None else as_icon(icon, T.px(20), color), width=T.px(20),
|
||||
alignment=ft.Alignment.CENTER)
|
||||
right: list[ft.Control] = []
|
||||
fix = check_fix(item)
|
||||
if fix:
|
||||
label, fix_icon, handler = fix
|
||||
right.append(secondary(label, fix_icon, lambda e, h=handler: h()))
|
||||
if item.extra is not None:
|
||||
right.append(item.extra)
|
||||
rows.append(ft.Container(ft.Row([
|
||||
mark,
|
||||
ft.Column([with_help(body(item.title, T.TEXT, weight=ft.FontWeight.W_500), item.help)]
|
||||
+ ([meta(item.detail)] if item.detail else []), spacing=1, expand=True),
|
||||
*right,
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), padding=ft.Padding(0, T.px(6), 0, T.px(6))))
|
||||
return ft.Column(rows, spacing=0)
|
||||
|
||||
|
||||
def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_OUTLINED,
|
||||
def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_ROUNDED,
|
||||
hero: bool = False, **kw) -> ft.Container:
|
||||
"""Artwork that covers its box (cards, heroes), by asset URL (artwork.thumbs.url — never image bytes: those are
|
||||
re-sent on every update); an accent gradient with a large faint icon when there's none."""
|
||||
@@ -650,12 +369,12 @@ def art_fill(src: str | None, radius: int | None = None, placeholder_icon: str =
|
||||
if src:
|
||||
return ft.Container(image=ft.DecorationImage(src=src, fit=ft.BoxFit.COVER), border_radius=radius,
|
||||
bgcolor=T.SURFACE_2, **kw)
|
||||
icon = as_icon(placeholder_icon, T.px(220) if hero else T.px(56), T.soft(T.ACCENT, 0.22 if hero else 0.45))
|
||||
icon = ft.Icon(placeholder_icon, size=T.px(220) if hero else T.px(56),
|
||||
color=T.soft(T.ACCENT, 0.22 if hero else 0.45))
|
||||
return ft.Container(ft.Container(icon, padding=ft.Padding(0, 0, T.px(60), 0) if hero else 0), border_radius=radius,
|
||||
alignment=ft.Alignment.CENTER_RIGHT if hero else ft.Alignment.CENTER,
|
||||
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_LEFT, end=ft.Alignment.BOTTOM_RIGHT,
|
||||
colors=[T.SECONDARY_SOFT, T.SURFACE, T.ACCENT_SOFT] if T.DUAL
|
||||
else [T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
|
||||
colors=[T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
|
||||
|
||||
|
||||
def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Container:
|
||||
@@ -664,130 +383,40 @@ def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Contain
|
||||
colors=[ft.Colors.TRANSPARENT, T.soft("#000000", strength)]))
|
||||
|
||||
|
||||
DIALOG_TITLE_SIZE = 20 # px at 100 %
|
||||
|
||||
|
||||
def dialog(title: str | ft.Control | None, content: ft.Control | None, actions: list[ft.Control] | None = None,
|
||||
size: str = "m", modal: bool = False, on_dismiss: Callable | None = None,
|
||||
title_actions: list[ft.Control] | None = None, width: float | None = None, height: float | None = None,
|
||||
**kw) -> ft.AlertDialog:
|
||||
"""The one dialog look: SURFACE_2, RADIUS corners, a semibold title, content `size` wide ("s"/"m"/"l" =
|
||||
DIALOG_S/M/L, or `width`; `height` fixes it), actions at the end (Cancel/Close first, then the action).
|
||||
title_actions go at the title's right (e.g. a counter or a close button)."""
|
||||
if isinstance(title, str):
|
||||
title = ft.Text(title, size=T.px(DIALOG_TITLE_SIZE), weight=ft.FontWeight.W_600, color=T.TEXT)
|
||||
if title is not None and title_actions:
|
||||
title = ft.Row([ft.Container(title, expand=True), *title_actions],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
width = width or {"s": T.DIALOG_S, "m": T.DIALOG_M, "l": T.DIALOG_L}[size]
|
||||
box = ft.Container(content, width=width, height=height) if content is not None else None
|
||||
return ft.AlertDialog(title=title, content=box,
|
||||
actions=actions or [], modal=modal, on_dismiss=on_dismiss, bgcolor=T.SURFACE_2,
|
||||
shape=_shape(T.RADIUS), actions_alignment=ft.MainAxisAlignment.END, **kw)
|
||||
|
||||
|
||||
def dialog_open(page: ft.Page | None) -> bool:
|
||||
"""Whether a dialog (or viewer/bottom sheet) is open: Flet 1.0's page.show_dialog keeps every shown dialog in
|
||||
page._dialogs until Flutter reports it dismissed, and pop_dialog sets open=False at once. Snack bars (toasts)
|
||||
travel the same way but don't take the keyboard, so they don't count. Without that list: assume none."""
|
||||
try:
|
||||
shown = page._dialogs.controls # noqa: SLF001 (Flet has no public "is a dialog open")
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return any(d.open and not isinstance(d, ft.SnackBar) for d in shown)
|
||||
|
||||
|
||||
def viewer(content: ft.Control, on_dismiss: Callable | None = None, **kw) -> ft.AlertDialog:
|
||||
"""A full-size image/screenshot viewer: no title, a dark backdrop around the picture."""
|
||||
return ft.AlertDialog(content=content, bgcolor=T.BG, content_padding=T.S3, shape=_shape(T.RADIUS),
|
||||
on_dismiss=on_dismiss, **kw)
|
||||
|
||||
|
||||
def confirm(page: ft.Page, heading: str, text: str, ok_label: str, on_ok: Callable[[], None],
|
||||
danger: bool = False, extra: ft.Control | None = None) -> None:
|
||||
"""A yes/no dialog; `extra` (e.g. an option checkbox) goes below the text and is read by on_ok."""
|
||||
def go(e):
|
||||
page.pop_dialog()
|
||||
on_ok()
|
||||
content = body(text) if extra is None else ft.Column([body(text), extra], spacing=T.S3, tight=True)
|
||||
page.show_dialog(dialog(heading, content, [ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
(_danger if danger else primary)(ok_label, on_click=go)],
|
||||
size="s"))
|
||||
content = body(text) if extra is None else ft.Column([body(text), extra], spacing=T.px(12), tight=True)
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(heading, weight=ft.FontWeight.W_600), content=ft.Container(content, width=T.px(420)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
ft.FilledButton(ok_label, on_click=go, style=ft.ButtonStyle(
|
||||
shape=_shape(), bgcolor=T.ERROR if danger else T.ACCENT, color=T.ON_ACCENT))]))
|
||||
|
||||
|
||||
def progress_bar(value: float | None = None, color: str | None = None) -> ft.ProgressBar:
|
||||
"""Progress: blue in dual themes (something on its way through the portal), else the accent."""
|
||||
return ft.ProgressBar(value=value, color=color or (T.SECONDARY if T.DUAL else T.ACCENT), bgcolor=T.SURFACE_3,
|
||||
border_radius=T.px(4), bar_height=T.px(6))
|
||||
def progress_bar(value: float | None = None, color: str = T.ACCENT) -> ft.ProgressBar:
|
||||
return ft.ProgressBar(value=value, color=color, bgcolor=T.SURFACE_3, border_radius=T.px(4), bar_height=6)
|
||||
|
||||
|
||||
def _is_header(a) -> bool:
|
||||
from .menus import Header
|
||||
|
||||
return isinstance(a, Header)
|
||||
|
||||
|
||||
def menu_entries(actions: list) -> list:
|
||||
"""The entries menu_items shows: a header is dropped when nothing follows it before the next divider/header,
|
||||
dividers are never first, last or doubled."""
|
||||
kept = []
|
||||
for i, a in enumerate(actions):
|
||||
if _is_header(a) and (i + 1 >= len(actions) or actions[i + 1] is None or _is_header(actions[i + 1])):
|
||||
def menu_items(actions: list[tuple | None]) -> list[ft.PopupMenuItem]:
|
||||
"""[(label, icon, handler) | None] → popup menu items; None becomes a divider (never first, last or doubled)."""
|
||||
out: list[ft.PopupMenuItem] = []
|
||||
for a in actions:
|
||||
if a is None:
|
||||
if out and out[-1].content is not None:
|
||||
out.append(ft.PopupMenuItem())
|
||||
continue
|
||||
kept.append(a)
|
||||
out: list = []
|
||||
for a in kept:
|
||||
if a is None and (not out or out[-1] is None):
|
||||
continue
|
||||
out.append(a)
|
||||
while out and out[-1] is None:
|
||||
label, icon, handler = a
|
||||
out.append(ft.PopupMenuItem(content=ft.Text(label), icon=icon, on_click=handler))
|
||||
while out and out[-1].content is None:
|
||||
out.pop()
|
||||
return out
|
||||
|
||||
|
||||
def menu_items(actions: list) -> list[ft.PopupMenuItem]:
|
||||
"""[(label, icon, handler) | menus.Header | None] → compact popup menu items (32 px rows). None becomes a divider
|
||||
(never first, last or doubled), a Header a small caps group heading (dropped when its group is empty); an item
|
||||
without a handler is shown disabled (e.g. "Protected by SteamOS")."""
|
||||
out: list[ft.PopupMenuItem] = []
|
||||
for a in menu_entries(actions):
|
||||
if a is None:
|
||||
out.append(ft.PopupMenuItem())
|
||||
elif _is_header(a):
|
||||
out.append(ft.PopupMenuItem(
|
||||
content=ft.Text(a.label.upper(), size=T.T_SMALL, color=T.TEXT_3, weight=ft.FontWeight.W_600,
|
||||
font_family="monospace", style=ft.TextStyle(letter_spacing=T.px(1))),
|
||||
height=T.px(24), padding=ft.Padding(T.px(14), T.px(6), T.px(16), 0), disabled=True,
|
||||
mouse_cursor=ft.MouseCursor.BASIC))
|
||||
else:
|
||||
label, icon, handler = a
|
||||
color = T.TEXT if handler else T.TEXT_3
|
||||
out.append(ft.PopupMenuItem(
|
||||
content=ft.Text(label, size=T.T_BODY, color=color),
|
||||
icon=as_icon(icon, T.ICON_S, T.TEXT_2 if handler else T.TEXT_3) if icon else None,
|
||||
height=T.px(32), padding=ft.Padding(T.px(14), 0, T.px(18), 0), on_click=handler,
|
||||
disabled=handler is None))
|
||||
return out
|
||||
|
||||
|
||||
def check_item(label: str, checked: bool, on_click: Callable) -> ft.PopupMenuItem:
|
||||
"""A compact menu row with a check mark (filter/sort chips)."""
|
||||
return ft.PopupMenuItem(content=ft.Text(label, size=T.T_BODY, color=T.TEXT), checked=checked, on_click=on_click,
|
||||
height=T.px(32), padding=ft.Padding(T.px(14), 0, T.px(18), 0))
|
||||
|
||||
|
||||
def menu_button(items: list[ft.PopupMenuItem], **kw) -> ft.PopupMenuButton:
|
||||
"""A PopupMenuButton with the shared menu look (border, shadow, SURFACE_2, small radius; also the theme's
|
||||
popup_menu_theme, which right-click ContextMenus use)."""
|
||||
kw.setdefault("tooltip", "")
|
||||
return ft.PopupMenuButton(items=items, bgcolor=T.SURFACE_2, elevation=8, shadow_color="#000000",
|
||||
shape=menu_shape(), menu_padding=ft.Padding(0, T.px(4), 0, T.px(4)), **kw)
|
||||
|
||||
|
||||
def menu_shape() -> ft.RoundedRectangleBorder:
|
||||
return ft.RoundedRectangleBorder(radius=T.RADIUS_SM, side=ft.BorderSide(1, T.BORDER_STRONG))
|
||||
|
||||
|
||||
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."""
|
||||
@@ -877,11 +506,10 @@ class Sparkline:
|
||||
"""A small line chart with a soft area fill (Flet canvas, built into Flet: no charts extension). Created once;
|
||||
set() replaces only the paths' elements, so a tick sends a small patch. Fills its width (on_resize)."""
|
||||
|
||||
def __init__(self, color: str | None = None, height: int = 36, slots: int = 120, lo: float = 0.0,
|
||||
def __init__(self, color: str = T.ACCENT, height: int = 36, slots: int = 120, lo: float = 0.0,
|
||||
hi: float | None = None, min_slots: int = 30, fit: float | None = None):
|
||||
import flet.canvas as cv
|
||||
|
||||
color = color or T.ACCENT
|
||||
self.cv, self.slots, self.min_slots, self.lo, self.hi = cv, slots, min_slots, lo, hi
|
||||
self.fit = fit # zoom to the data: a scale of at least this span around it (temperatures, fan speed)
|
||||
self.width, self.height = 0.0, T.px(height)
|
||||
@@ -937,151 +565,6 @@ class Sparkline:
|
||||
self.guide.elements = guide
|
||||
|
||||
|
||||
def _darker(color: str, factor: float = 0.6) -> str:
|
||||
c = color.lstrip("#")
|
||||
return "#" + "".join(f"{round(int(c[i:i + 2], 16) * factor):02X}" for i in (0, 2, 4))
|
||||
|
||||
|
||||
def transit_parts() -> dict[str, str]:
|
||||
"""The logo's monitor, portal and headset in the active theme's colours (asset URLs; glyphs.transit_parts)."""
|
||||
from ..artwork.thumbs import asset_url
|
||||
from ..core.paths import user_data_dir
|
||||
|
||||
parts = glyphs.transit_parts(user_data_dir(), {
|
||||
"line": T.TEXT, "body": T.BG, "screen": T.SECONDARY, "lens": T.ACCENT,
|
||||
"near": T.SECONDARY if T.DUAL else T.ACCENT, "far": T.ACCENT, "hole": _darker(T.BG)})
|
||||
return {k: asset_url(v) for k, v in parts.items()}
|
||||
|
||||
|
||||
class Transit:
|
||||
"""Install progress as a transit: the PC's monitor · a track the game's cover rides along, into the portal at the
|
||||
progress head · the Frame headset (a monitor again for installs on this PC). Below: "Title → Frame" with what
|
||||
happens now (heading=False: without "Title → Frame"), and the six steps (not when compact). Created once per
|
||||
place; set() changes only properties, so it can follow every progress tick."""
|
||||
|
||||
def __init__(self, compact: bool = False, heading: bool = True):
|
||||
from .transit import STEPS
|
||||
|
||||
self.compact = compact
|
||||
heading = heading and not compact
|
||||
self.width = 0.0
|
||||
self.frac = 0.0
|
||||
self.done = False
|
||||
self._job_id = None
|
||||
self._mono = None
|
||||
icon_h = T.px(22 if compact else 30)
|
||||
self.track_h = T.px(26 if compact else 36)
|
||||
self.bar_h = T.px(4 if compact else 6)
|
||||
self.cover_s = T.px(22 if compact else 36) # fits the track height
|
||||
self.portal_w, self.portal_h = self.track_h * 24 / 50, self.track_h
|
||||
self.parts = transit_parts()
|
||||
size = lambda name, h: (h * glyphs.TRANSIT_SIZES[name][0] / glyphs.TRANSIT_SIZES[name][1], h) # noqa: E731
|
||||
mw, mh = size("monitor", icon_h)
|
||||
self._dest_sizes = {"frame": size("headset", icon_h * 0.86), "pc": (mw, mh)}
|
||||
self.source = ft.Image(src=self.parts["monitor"], width=mw, height=mh, fit=ft.BoxFit.CONTAIN)
|
||||
hw, hh = self._dest_sizes["frame"]
|
||||
self.dest = ft.Image(src=self.parts["headset"], width=hw, height=hh, fit=ft.BoxFit.CONTAIN)
|
||||
anim = ft.Animation(250, ft.AnimationCurve.EASE_OUT)
|
||||
top = (self.track_h - self.bar_h) / 2
|
||||
self.fill = ft.Container(left=0, top=top, height=self.bar_h, width=0, border_radius=self.bar_h,
|
||||
gradient=portal_gradient() if T.DUAL else None,
|
||||
bgcolor=None if T.DUAL else T.ACCENT, animate_size=anim)
|
||||
self.cover = ft.Container(left=0, top=(self.track_h - self.cover_s) / 2, width=self.cover_s,
|
||||
height=self.cover_s, border_radius=T.px(4), bgcolor=T.SURFACE_3,
|
||||
border=ft.Border.all(1, T.BORDER_STRONG), alignment=ft.Alignment.CENTER,
|
||||
clip_behavior=ft.ClipBehavior.ANTI_ALIAS, animate_position=anim)
|
||||
self.portal = ft.Image(src=self.parts["portal"], left=0, top=0, width=self.portal_w, height=self.portal_h,
|
||||
fit=ft.BoxFit.CONTAIN, animate_position=anim, animate_opacity=anim)
|
||||
base = ft.Container(left=0, right=0, top=top, height=self.bar_h, border_radius=self.bar_h,
|
||||
bgcolor=T.SURFACE_3)
|
||||
self.track = ft.Container(ft.Stack([base, self.fill, self.cover, self.portal], height=self.track_h,
|
||||
clip_behavior=ft.ClipBehavior.NONE),
|
||||
expand=True, height=self.track_h, on_size_change=self._resized)
|
||||
row = ft.Row([self.source, self.track, self.dest], spacing=T.px(6 if compact else 10),
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
self.heading = ft.Text("", size=T.T_META, weight=ft.FontWeight.W_600, color=T.TEXT, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS, expand=True)
|
||||
self.detail = ft.Text("", size=T.T_META, color=T.TEXT_2, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS,
|
||||
expand=not heading, text_align=ft.TextAlign.RIGHT if heading else ft.TextAlign.LEFT)
|
||||
info = ft.Row([self.heading, self.detail] if heading else [self.detail], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
labels = {"analyze": tr("Analyze"), "patch": tr("Patch"), "sign": tr("Sign"), "upload": tr("Upload"),
|
||||
"install": tr("Install"), "test": tr("Test")}
|
||||
self.steps = [ft.Container(ft.Text(labels[k], size=T.T_SMALL, color=T.TEXT_3, max_lines=1, no_wrap=True,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
expand=True, alignment=ft.Alignment.CENTER,
|
||||
padding=ft.Padding(0, 0, 0, T.px(3)),
|
||||
border=ft.Border(bottom=ft.BorderSide(2, T.SURFACE_3))) for k in STEPS]
|
||||
steps_row = ft.Row(self.steps, spacing=T.px(3), visible=not compact)
|
||||
self.control = ft.Column([row, info, steps_row], spacing=T.px(4 if compact else 6), tight=True)
|
||||
|
||||
# ------------------------------------------------------------------ layout
|
||||
def _resized(self, e) -> None:
|
||||
if e.width and abs(e.width - self.width) > 0.5:
|
||||
self.width = e.width
|
||||
self._place()
|
||||
update(self.track)
|
||||
|
||||
def _place(self) -> None:
|
||||
"""Riders from the progress head: the portal centred on it, the cover just before it (not overlapping); once
|
||||
done the cover has come out on the far side."""
|
||||
w, pw, cs, gap = self.width, self.portal_w, self.cover_s, T.px(2)
|
||||
if w <= 0:
|
||||
return
|
||||
if self.done:
|
||||
self.fill.width = w
|
||||
self.cover.left = max(0.0, w - cs)
|
||||
self.portal.left = max(0.0, w - cs - gap - pw)
|
||||
return
|
||||
head = min(max(self.frac * w, cs + gap + pw / 2), w - pw / 2)
|
||||
self.fill.width = max(0.0, min(w, self.frac * w))
|
||||
self.portal.left = head - pw / 2
|
||||
self.cover.left = max(0.0, self.portal.left - cs - gap) # just before the portal, not under it
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
def set(self, job, title: str = "", dest: str = "", art: str | None = None, icon=None, extra: str = "") -> None:
|
||||
"""Show `job` (ui.jobs.Job). title/dest: "Title → Frame"; art: the cover's asset URL (icon when none);
|
||||
extra: appended to the detail (e.g. " · 2 more queued")."""
|
||||
from .transit import WAITING, Monotonic, job_state
|
||||
|
||||
self._mono = self._mono or Monotonic()
|
||||
s = self._mono.follow(job.id, job_state(job))
|
||||
if job.id != self._job_id: # another job: its cover and destination
|
||||
self._job_id = job.id
|
||||
self.cover.image = ft.DecorationImage(src=art, fit=ft.BoxFit.COVER) if art else None
|
||||
self.cover.content = None if art else as_icon(icon or ft.Icons.VIDEOGAME_ASSET_OUTLINED,
|
||||
self.cover_s * 0.66, T.TEXT_2)
|
||||
self.dest.src = self.parts["monitor" if s.target == "pc" else "headset"]
|
||||
self.dest.width, self.dest.height = self._dest_sizes[s.target]
|
||||
self.frac, self.done = s.fraction, s.label == "done"
|
||||
self.heading.value = f"{title} → {dest}" if dest else title
|
||||
if s.waiting:
|
||||
text, color = tr(WAITING), T.WARN
|
||||
elif s.failed:
|
||||
text, color = (job.error or tr("Failed")), T.ERROR
|
||||
elif job.state == "queued":
|
||||
text, color = tr("Queued"), T.TEXT_3
|
||||
else:
|
||||
text, color = (s.detail or tr("Starting…")), T.TEXT_2
|
||||
self.detail.value, self.detail.color = text + extra, color
|
||||
self.portal.opacity = 0.35 if s.waiting else 1.0
|
||||
if s.failed:
|
||||
self.fill.gradient, self.fill.bgcolor = None, T.ERROR
|
||||
else:
|
||||
self.fill.gradient = portal_gradient() if T.DUAL else None
|
||||
self.fill.bgcolor = None if T.DUAL else T.ACCENT
|
||||
for i, box in enumerate(self.steps):
|
||||
text = box.content
|
||||
current = i == s.step and not self.done
|
||||
finished = i < s.step or self.done
|
||||
text.color = T.ACCENT if current else T.TEXT_2 if finished else T.TEXT_3
|
||||
text.weight = ft.FontWeight.W_600 if current else ft.FontWeight.W_400
|
||||
box.border = ft.Border(bottom=ft.BorderSide(
|
||||
2, (T.ERROR if s.failed else T.WARN if s.waiting else T.ACCENT) if current
|
||||
else (T.SECONDARY if T.DUAL else T.ACCENT) if finished else T.SURFACE_3))
|
||||
self._place()
|
||||
|
||||
|
||||
class MeterBar:
|
||||
"""A thin horizontal bar of coloured segments (per-core load, power split); widths are fractions of the bar."""
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""The sidebar's live Frame card (no Flet): what one monitor sample shows there — the running game (title, how long it
|
||||
has been playing, its frame rate and that rate's level) and the battery ring. The app subscribes the card to the
|
||||
shared MonitorHub (frame/monitor_hub.py) with only the "games" and "battery" modules, every CARD_INTERVAL seconds,
|
||||
while the setting `ui.live_frame_card` is on."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..frame import monitor as M
|
||||
from ..i18n import tr
|
||||
from . import battery as B
|
||||
|
||||
SETTING = "ui.live_frame_card"
|
||||
CARD_INTERVAL = 5.0 # seconds between samples (the agent collects only games + battery for it)
|
||||
CARD_MODULES = ("games", "battery")
|
||||
FPS_WINDOW = 120.0 # the sparkline covers the last 2 minutes
|
||||
FPS_BUCKET = 5.0 # one point per CARD_INTERVAL (faster samples, e.g. with the Monitor open, are averaged)
|
||||
FPS_POINTS = int(FPS_WINDOW // FPS_BUCKET)
|
||||
|
||||
|
||||
def fps_history() -> M.History:
|
||||
return M.History(size=FPS_POINTS, bucket=FPS_BUCKET)
|
||||
|
||||
|
||||
def now_playing(sample: dict | None, fps_series: list | None = None) -> dict | None:
|
||||
"""Display values for the card's "now playing" row, or None when no game runs: package, title, line ("playing ·
|
||||
27 min"), fps text ("72" / "–"), level (ok | warn | error by the fps against its presumed refresh rate) and the
|
||||
fps target (refresh rate) for the sparkline's guide."""
|
||||
games = (sample or {}).get("games") or []
|
||||
if not games:
|
||||
return None
|
||||
g = games[0]
|
||||
pkg = g.get("package") or ""
|
||||
elapsed = g.get("elapsed")
|
||||
line = tr("playing · {t}").format(t=M.fmt_duration(elapsed)) if elapsed is not None else tr("playing")
|
||||
fps = g.get("fps")
|
||||
series = list(fps_series or [])
|
||||
target = M.fps_target(series + ([fps] if fps is not None else []))
|
||||
lvl = M.level("fps_ratio", fps / target if fps is not None and target else None)
|
||||
return {"package": pkg, "title": g.get("title") or pkg, "line": line, "minutes": int(elapsed // 60)
|
||||
if elapsed is not None else None, "fps": fps, "fps_text": f"{fps:.0f}" if fps is not None else "–",
|
||||
"level": lvl if fps is not None else "none", "target": target}
|
||||
|
||||
|
||||
def battery_ring(bat: dict | None) -> dict | None:
|
||||
"""The battery ring: value (0..1), the "76" text and its level (warn when low on battery power, charging when it
|
||||
gains charge, ok otherwise). None without a reading."""
|
||||
if not bat or bat.get("percent") is None:
|
||||
return None
|
||||
pct = max(0, min(100, int(bat.get("percent") or 0)))
|
||||
level = "warn" if B.low(bat) else "charging" if B.charging(bat) else "ok"
|
||||
return {"value": pct / 100.0, "text": f"{pct}", "level": level}
|
||||
|
||||
|
||||
def uploading(job) -> bool:
|
||||
"""A job is sending files to the Frame (installer/files stages "Upload …"): the card pauses its samples meanwhile
|
||||
(the link is busy)."""
|
||||
return bool(job) and (getattr(job, "stage", "") or "").startswith("Upload")
|
||||
@@ -1,120 +0,0 @@
|
||||
"""FramePort's own icons (no Flet): the logo and 24 px line glyphs for what Material has no icon for (the Frame, a
|
||||
portal, launch tests …). They ship as SVG files in ui/icons and are copied into the GUI's assets folder (the user data
|
||||
dir) so they load like artwork: by URL in source runs and the web view, by file path in packaged apps
|
||||
(artwork.thumbs.asset_url). Glyphs are drawn in white and tinted by the GUI (components.icon)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PREFIX = "fp:"
|
||||
# glyph names usable wherever the GUI takes an icon (components.icon turns them into a tinted image)
|
||||
FRAME = "fp:frame" # the Steam Frame headset
|
||||
PORT = "fp:port" # portal + arrow: build / convert a game
|
||||
PATCH = "fp:patch"
|
||||
CONTAINER = "fp:container" # Lepton's per-game container
|
||||
TEST = "fp:test" # launch test
|
||||
PC = "fp:pc" # on this PC / PC VR
|
||||
SYNC = "fp:sync" # update on Frame
|
||||
LIVE = "fp:live" # live view
|
||||
SHOT = "fp:shot" # screenshots
|
||||
KEYS = "fp:keys" # type on Frame
|
||||
RECIPE = "fp:recipe" # verified catalog recipe
|
||||
LINK = "fp:link" # Frame connection
|
||||
|
||||
SOURCE = Path(__file__).with_name("icons")
|
||||
FOLDER = "ui-icons" # inside the assets folder
|
||||
|
||||
|
||||
def is_glyph(icon) -> bool:
|
||||
return isinstance(icon, str) and icon.startswith(PREFIX)
|
||||
|
||||
|
||||
def install(assets: Path) -> Path:
|
||||
"""Copy the bundled SVGs into <assets>/ui-icons (only files that changed). Returns that folder."""
|
||||
target = assets / FOLDER
|
||||
for src in [*SOURCE.glob("*.svg"), *(SOURCE / "glyphs").glob("*.svg")]:
|
||||
dest = target / src.name
|
||||
data = src.read_bytes()
|
||||
try:
|
||||
if dest.read_bytes() == data:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(data)
|
||||
return target
|
||||
|
||||
|
||||
PORTAL_REV = 2
|
||||
|
||||
|
||||
def portal_mark(assets: Path, near: str, far: str, hole: str) -> Path:
|
||||
"""The small tilted portal between "Frame" and "Port" in the wordmark, in a theme's colours: the rim runs from
|
||||
`near` (the side facing "Frame") to `far` (facing "Port"), around a dark hole. Written once per colour set."""
|
||||
near, far, hole = (c.upper() for c in (near, far, hole))
|
||||
dest = assets / FOLDER / f"portal{PORTAL_REV}-{near[1:]}-{far[1:]}-{hole[1:]}.svg" # REV: the drawing changed
|
||||
if not dest.is_file():
|
||||
rim = (f'<linearGradient id="r" gradientUnits="userSpaceOnUse" x1="2" y1="0" x2="14" y2="0">'
|
||||
f'<stop offset=".3" stop-color="{near}"/><stop offset=".7" stop-color="{far}"/></linearGradient>')
|
||||
ring = '<ellipse cx="8" cy="14" rx="4.4" ry="10.6" transform="rotate(14 8 14)"'
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(f'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="28" viewBox="0 0 16 28">'
|
||||
f'<defs>{rim}</defs>'
|
||||
f'{ring} fill="none" stroke="url(#r)" stroke-opacity=".35" stroke-width="5"/>'
|
||||
f'{ring} fill="{hole}" stroke="url(#r)" stroke-width="2.6"/></svg>\n', encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
TRANSIT_REV = 1
|
||||
|
||||
# The logo's parts, whole (the logo shows the monitor's left half and the headset's right half): same 64-unit
|
||||
# coordinates, strokes and radii as icons/logo.svg, each cropped to its own viewBox.
|
||||
_MONITOR = ('<svg xmlns="http://www.w3.org/2000/svg" width="39" height="38" viewBox="3 11.5 39 38">'
|
||||
'<path d="M9 15h27a3 3 0 0 1 3 3v20a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V18a3 3 0 0 1 3-3z" fill="{body}" '
|
||||
'stroke="{line}" stroke-width="3" stroke-linejoin="round"/>'
|
||||
'<rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="{screen}" fill-opacity="0.35"/>'
|
||||
'<path d="M22.5 41v6M15.5 47h14" fill="none" stroke="{line}" stroke-width="3" stroke-linecap="round"/>'
|
||||
'</svg>\n')
|
||||
# the visor mirrored about its nose notch (x 44.25), lenses either side, a strap stub on both ends
|
||||
_HEADSET = ('<svg xmlns="http://www.w3.org/2000/svg" width="34" height="28" viewBox="27.25 17.5 34 28">'
|
||||
'<path d="M30 31.5H32M56.5 31.5H58.5" stroke="{line}" stroke-width="3.4" stroke-linecap="round"/>'
|
||||
'<path d="M39.5 21H49a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7'
|
||||
'a3 3 0 0 1-2.6 1.5H39.5a8 8 0 0 1-8-8v-5a8 8 0 0 1 8-8z" fill="{body}" stroke="{line}" stroke-width="3" '
|
||||
'stroke-linejoin="round"/>'
|
||||
'<ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="{lens}"/>'
|
||||
'<ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="{lens}"/></svg>\n')
|
||||
# the portal: dark hole with a keyline, a soft glow and the blue→orange rim, tilted 18°
|
||||
_PORTAL = ('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="50" viewBox="20 7 24 50">'
|
||||
'<defs><linearGradient id="r" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0">'
|
||||
'<stop offset=".3" stop-color="{near}"/><stop offset=".7" stop-color="{far}"/></linearGradient></defs>'
|
||||
'<g transform="rotate(18 32 32)">'
|
||||
'<ellipse cx="32" cy="32" rx="4.5" ry="22" fill="{hole}" stroke="{hole}" stroke-width="5.8"/>'
|
||||
'<ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" stroke="url(#r)" stroke-opacity=".25" '
|
||||
'stroke-width="6.46"/>'
|
||||
'<ellipse cx="32" cy="32" rx="4.5" ry="22" fill="url(#r)" fill-opacity="0.14" stroke="url(#r)" '
|
||||
'stroke-width="3.4"/></g></svg>\n')
|
||||
TRANSIT_SIZES = {"monitor": (39, 38), "headset": (34, 28), "portal": (24, 50)} # viewBox sizes (aspect ratios)
|
||||
|
||||
|
||||
def transit_parts(assets: Path, colors: dict) -> dict[str, Path]:
|
||||
"""The install transit's monitor, portal and headset: the logo's own shapes, whole, in a theme's colours.
|
||||
colors: line (strokes), body (inside the outlines, the logo's tile), screen (the monitor's screen), lens (the
|
||||
visor's lenses), near/far (the portal rim's PC and Frame sides), hole (the portal's inside). Written once per
|
||||
colour set; returns {"monitor", "portal", "headset"} → file."""
|
||||
c = {k: str(v).upper() for k, v in colors.items()}
|
||||
out = {}
|
||||
for name, svg, keys in (("monitor", _MONITOR, ("line", "body", "screen")),
|
||||
("headset", _HEADSET, ("line", "body", "lens")),
|
||||
("portal", _PORTAL, ("near", "far", "hole"))):
|
||||
tag = "-".join(c[k].lstrip("#") for k in keys)
|
||||
dest = assets / FOLDER / f"transit{TRANSIT_REV}-{name}-{tag}.svg" # REV: the drawing changed
|
||||
if not dest.is_file():
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(svg.format(**{k: c[k] for k in keys}), encoding="utf-8")
|
||||
out[name] = dest
|
||||
return out
|
||||
|
||||
|
||||
def path(name: str, assets: Path) -> Path:
|
||||
"""The installed file for a glyph ("fp:frame") or a logo file name ("logo")."""
|
||||
return assets / FOLDER / f"{name.removeprefix(PREFIX)}.svg"
|
||||
@@ -25,11 +25,11 @@ HELP: dict[str, str] = _Translated({
|
||||
"update_ready": "The copy on your Frame differs from what FramePort would install now (the recipe or your game "
|
||||
"files changed). Update to install the new build; saves are kept.",
|
||||
"check_exe": "This game's folder has several programs and FramePort isn't sure which one starts the game. Open the "
|
||||
"game (or right-click → Change program…) to pick it.",
|
||||
"game (or right-click → Change executable) to pick it.",
|
||||
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
|
||||
"container.",
|
||||
"platform_windows": "A Windows game without VR, added with \"Add a PC game folder…\". On the Frame, Proton "
|
||||
"runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
|
||||
"platform_windows": "A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it "
|
||||
"as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
|
||||
"platform_pcvr": "A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the "
|
||||
"Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).",
|
||||
"platform_android": "An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's "
|
||||
@@ -39,9 +39,8 @@ HELP: dict[str, str] = _Translated({
|
||||
"platform_linux": "A native Linux program built for arm64 (aarch64), like the Frame's own CPU. It's installed "
|
||||
"unchanged and runs directly on SteamOS (no Android container, no Proton), started from the "
|
||||
"Frame's Steam library. VR apps use the Frame's OpenXR runtime.",
|
||||
"linux_app": "An AppImage, a folder or a .zip/.tar archive with a Linux program, best built for arm64 (aarch64). "
|
||||
"x86_64 builds run through FEX, Valve's x86 translator, but slower. The app must bring the "
|
||||
"libraries SteamOS doesn't have.",
|
||||
"linux_app": "An AppImage, a folder or a .zip/.tar archive with a Linux program built for arm64 (aarch64). x86_64 "
|
||||
"builds can't run on the Frame. The app must bring the libraries SteamOS doesn't have.",
|
||||
"appimage": "An AppImage is a Linux app packed into one file. FramePort unpacks it on the Frame once, so it starts "
|
||||
"without FUSE.",
|
||||
"select": "Pick several games and install them in one go. FramePort asks every question first, then works through "
|
||||
@@ -70,7 +69,7 @@ HELP: dict[str, str] = _Translated({
|
||||
"FrameDrop sideloader). FramePort reads the same links: it shows what the link offers, asks, "
|
||||
"downloads it, adds it to your library and installs it on the Frame. Only https links to "
|
||||
"public servers are used. Windows and Linux open these links in FramePort; on macOS, paste the "
|
||||
"link into Add games → Add from a link….",
|
||||
"link into Add games → Install from a link….",
|
||||
"linux_x86": "The Frame's CPU is arm64. Programs built for x86_64 PCs run through FEX, Valve's x86 translator, "
|
||||
"with the x86 libraries SteamOS ships for it (glibc, Mesa) - the way Steam on the Frame runs x86 "
|
||||
"Linux games. That works for many programs but is slower: use an arm64 build when there is one.",
|
||||
@@ -97,27 +96,23 @@ HELP: dict[str, str] = _Translated({
|
||||
"starts.",
|
||||
"cat_device": "Files and environment variables placed next to the game on the Frame.",
|
||||
# ---- Frame
|
||||
"developer_mode": "Developer Mode (on the Frame: Settings → System → Enable Developer Mode) lets FramePort find "
|
||||
"the Frame on your network and connect to it over SSH. The first-time setup command turns it on "
|
||||
"for you. With Developer Mode on you can also pair without the setup command: on the Frame open "
|
||||
"Settings → Developer → Pair new host, click Connect there and approve FramePort. That way needs "
|
||||
"no connection from the Frame into this PC.",
|
||||
"developer_mode": "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.",
|
||||
"first_time_setup": "The command fetches a small setup script from this app over your local network. It turns on "
|
||||
"Developer Mode (which includes SSH), lets this app's key in and installs Lepton if needed. "
|
||||
"Turning on Developer Mode closes the Frame's desktop; the setup finishes on its own and "
|
||||
"FramePort connects by itself. No password needed.",
|
||||
"No password needed.",
|
||||
"password": "The Frame's desktop password, only needed the first time so FramePort can add its own key. After "
|
||||
"that it connects with the key.",
|
||||
"lepton": "Lepton is Valve's Android container on the Frame. Quest games run inside it, one container per game. "
|
||||
"It needs Developer Mode.",
|
||||
"proton": "Proton is Valve's Windows compatibility layer. On the Frame it runs PC VR games. "
|
||||
"Its arm64 build isn't installed by default; FramePort can install it (Steam restarts once).",
|
||||
"Its ARM64 build isn't installed by default; FramePort can install it (Steam restarts once).",
|
||||
"openxr": "The VR runtime games talk to. On the Frame that's SteamVR.",
|
||||
"kernel_keys": "Every game start on the Frame used to leak one kernel key, and after about 200 launches every game "
|
||||
"fails to start. FramePort switches that leak off, but keys already used only come back after a "
|
||||
"restart of the Frame.",
|
||||
"ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway "
|
||||
"(150% in Windows → 125%) when FramePort runs under WSL, where the window doesn't get it from "
|
||||
"(150 % in Windows → 125 %) when FramePort runs under WSL, where the window doesn't get it from "
|
||||
"Windows itself.",
|
||||
"app_updates": "FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the "
|
||||
"Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes "
|
||||
@@ -130,17 +125,21 @@ HELP: dict[str, str] = _Translated({
|
||||
"files": "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 (for example 4XVR's Internal "
|
||||
"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.",
|
||||
"live_view": "The picture opens in your default web browser, where it plays smoothly and can go full screen. "
|
||||
"It shows the headset's view with its sound (click Sound on in the player) whatever is running: "
|
||||
"Steam's menus, SteamVR or a game; while the headset sleeps the picture is black and updates about "
|
||||
"once a second. The Frame's hardware video encoder does the work (if it isn't available, the "
|
||||
"processor does, which costs a game more): stop the stream when you're done.",
|
||||
"type_on_frame": "This PC's keyboard works as a keyboard on your Frame while this tab is open. In the headset, "
|
||||
"select a text field (in a game or app, in Steam or on the desktop), then type here.",
|
||||
"free_space": "Deletes the rollback copies kept from each game's previous install and leftover uploads. The "
|
||||
"games and saves stay.",
|
||||
"install_drive": "Where new games go on the Frame: its internal storage or another drive, such as a microSD card "
|
||||
"(in a FramePort folder on it). Games you installed already stay where they are; move one with "
|
||||
"right-click → Move to…. The launcher and Steam entry always stay on internal storage. Drives "
|
||||
"formatted as FAT, exFAT or NTFS can't hold games (Android and Proton need Linux file "
|
||||
"permissions): format the card in SteamOS first.",
|
||||
"move_game": "Copies the game's files, data and saves to the other drive, checks the copy and then removes the "
|
||||
"old one. The Steam entry, settings and saves stay valid. The game must be closed. A game on a "
|
||||
"microSD card only starts while the card is inserted.",
|
||||
"desktop_entry": "Adds the app to Desktop Mode's application menu and puts an icon on its desktop. Some apps work "
|
||||
"better there, with a mouse and keyboard, than in Gaming Mode, where Steam Input turns the "
|
||||
"controllers into a gamepad.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the "
|
||||
"next time it starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
@@ -161,9 +160,9 @@ HELP: dict[str, str] = _Translated({
|
||||
"no GitHub token and no game files are involved.",
|
||||
"diag_bundle": "A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's "
|
||||
"runtime details — enough to debug without the game files. IP addresses, host and user names, "
|
||||
"home folders and Steam IDs are replaced by placeholders. Attach it to a GitHub issue.",
|
||||
"home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.",
|
||||
"monitor_gpu": "How much of the time the Frame's graphics chip was busy, added up from every program that draws "
|
||||
"(the game, SteamVR's compositor, Steam). Near 100% the game can't keep its frame rate.",
|
||||
"(the game, SteamVR's compositor, Steam). Near 100 % the game can't keep its frame rate.",
|
||||
"monitor_pressure": "Memory in use by everything on the Frame. \"Waiting\" is the share of time programs had to "
|
||||
"wait for memory in the last 10 s: when it climbs, the Frame is running out and may freeze or "
|
||||
"close a game.",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 20 7.5v9L12 21l-8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/></svg>
|
||||
|
Before Width: | Height: | Size: 264 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5A3.5 3.5 0 0 1 6.5 6h11A3.5 3.5 0 0 1 21 9.5v4a3.5 3.5 0 0 1-3.5 3.5h-2.2a2 2 0 0 1-1.7-.95l-.75-1.2a1 1 0 0 0-1.7 0l-.75 1.2A2 2 0 0 1 8.7 17H6.5A3.5 3.5 0 0 1 3 13.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 369 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6" width="19" height="12" rx="2.5"/><path d="M6.5 10h.01M10 10h.01M13.5 10h.01M17.5 10h.01M8 14.5h8"/></svg>
|
||||
|
Before Width: | Height: | Size: 302 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9a14 14 0 0 1 19 0M5.6 12.4a9.5 9.5 0 0 1 12.8 0M8.7 15.8a5 5 0 0 1 6.6 0"/><path d="M12 19.5h.01"/></svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M8.2 8.2a5.4 5.4 0 0 0 0 7.6M15.8 8.2a5.4 5.4 0 0 1 0 7.6M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/></svg>
|
||||
|
Before Width: | Height: | Size: 342 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.6" y="8.2" width="18.8" height="7.6" rx="3.8" transform="rotate(-45 12 12)"/><path d="M10.6 10.6h.01M13.4 10.6h.01M10.6 13.4h.01M13.4 13.4h.01"/></svg>
|
||||
|
Before Width: | Height: | Size: 339 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2.5"/><path d="M12 16v4M8 20h8"/></svg>
|
||||
|
Before Width: | Height: | Size: 261 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="7" cy="12" rx="4" ry="9.2" transform="rotate(15 7 12)"/><path d="M13.5 12H21M18 9l3 3-3 3"/></svg>
|
||||
|
Before Width: | Height: | Size: 288 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5.5A1.5 1.5 0 0 1 6.5 4H19v16H6.5A1.5 1.5 0 0 1 5 18.5z"/><path d="M5 17.5A1.5 1.5 0 0 1 6.5 16H19"/><path d="m9 9.5 2 2 3.5-3.5"/></svg>
|
||||
|
Before Width: | Height: | Size: 326 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8.5V6.5A2.5 2.5 0 0 1 6.5 4h2M15.5 4h2A2.5 2.5 0 0 1 20 6.5v2M20 15.5v2a2.5 2.5 0 0 1-2.5 2.5h-2M8.5 20h-2A2.5 2.5 0 0 1 4 17.5v-2"/><circle cx="12" cy="12" r="3.2"/></svg>
|
||||
|
Before Width: | Height: | Size: 361 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 10A8 8 0 0 0 5.6 7.2L4 9"/><path d="M4 4.5V9h4.5"/><path d="M4.5 14a8 8 0 0 0 13.9 2.8L20 15"/><path d="M20 19.5V15h-4.5"/></svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.9A2 2 0 0 0 6.75 21h10.5A2 2 0 0 0 19 17.9L14 9V3"/><path d="M7.4 15h9.2"/></svg>
|
||||
|
Before Width: | Height: | Size: 286 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><defs><linearGradient id="fp0" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3AA8FF" stop-opacity=".18"/><stop offset=".5" stop-color="#3AA8FF" stop-opacity="0"/><stop offset=".5" stop-color="#FF8A1F" stop-opacity="0"/><stop offset="1" stop-color="#FF8A1F" stop-opacity=".18"/></linearGradient><linearGradient id="fp1" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0"><stop offset=".3" stop-color="#3AA8FF"/><stop offset=".7" stop-color="#FF8A1F"/></linearGradient><clipPath id="fp2"><polygon points="-30,-30 49.14502116644019,-30 8.854978833559809,94 -30,94"/></clipPath><clipPath id="fp3"><polygon points="55.14502116644019,-30 94,-30 94,94 14.854978833559809,94"/></clipPath></defs><rect width="64" height="64" rx="15" fill="#101218"/><g clip-path="url(#fp2)"><g transform="translate(0 0)"><path d="M34 15H9a3 3 0 0 0-3 3v20a3 3 0 0 0 3 3h25z" fill="#ECEDF3" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="#3AA8FF" fill-opacity="1"/><path d="M18 41v6M11 47h14" stroke="#ECEDF3" stroke-width="3" stroke-linecap="round"/></g></g><g clip-path="url(#fp3)"><path d="M56.5 31.5H58.5" stroke="#ECEDF3" stroke-width="3.4" stroke-linecap="round"/><path d="M22 21h27a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7a3 3 0 0 1-2.6 1.5H22z" fill="#ECEDF3" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/><ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/></g><g transform="rotate(18 32 32)"><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="#08090C" stroke="#08090C" stroke-width="5.8"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" fill-opacity="0.14" stroke="url(#fp1)" stroke-width="3.4"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><defs><linearGradient id="fp0" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3AA8FF" stop-opacity=".18"/><stop offset=".5" stop-color="#3AA8FF" stop-opacity="0"/><stop offset=".5" stop-color="#FF8A1F" stop-opacity="0"/><stop offset="1" stop-color="#FF8A1F" stop-opacity=".18"/></linearGradient><linearGradient id="fp1" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0"><stop offset=".3" stop-color="#3AA8FF"/><stop offset=".7" stop-color="#FF8A1F"/></linearGradient><clipPath id="fp2"><polygon points="-30,-30 49.14502116644019,-30 8.854978833559809,94 -30,94"/></clipPath><clipPath id="fp3"><polygon points="55.14502116644019,-30 94,-30 94,94 14.854978833559809,94"/></clipPath></defs><rect width="64" height="64" rx="15" fill="#101218"/><rect width="64" height="64" rx="15" fill="url(#fp0)"/><g clip-path="url(#fp2)"><g transform="translate(0 0)"><path d="M34 15H9a3 3 0 0 0-3 3v20a3 3 0 0 0 3 3h25z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="#3AA8FF" fill-opacity="0.35"/><path d="M18 41v6M11 47h14" stroke="#ECEDF3" stroke-width="3" stroke-linecap="round"/></g></g><g clip-path="url(#fp3)"><path d="M56.5 31.5H58.5" stroke="#ECEDF3" stroke-width="3.4" stroke-linecap="round"/><path d="M22 21h27a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7a3 3 0 0 1-2.6 1.5H22z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/><ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/></g><g transform="rotate(18 32 32)"><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="#08090C" stroke="#08090C" stroke-width="5.8"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" stroke="url(#fp1)" stroke-opacity=".25" stroke-width="6.46"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="url(#fp1)" fill-opacity="0.14" stroke="url(#fp1)" stroke-width="3.4"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,125 +0,0 @@
|
||||
"""A game's menu (Library right-click, game page "…") as sections: a pure builder, no controls and no app.
|
||||
|
||||
Entries: (label, icon, handler) | Header(label) | None (divider). C.menu_items renders them (headers as small caps,
|
||||
compact rows). Flet 1.0's PopupMenuItem can't nest, so groups get a header instead of a submenu."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import NamedTuple
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..i18n import tr
|
||||
from . import glyphs as G
|
||||
|
||||
|
||||
class Header(NamedTuple):
|
||||
"""A group heading inside a menu (not clickable)."""
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuState:
|
||||
"""What the menu depends on, gathered by the app (FramePortApp.game_actions)."""
|
||||
quick: bool = True # True: Library right-click; False: the game page's "…" (hero/cards show the rest)
|
||||
connected: bool = False # a Frame is connected
|
||||
on_frame: bool = False # installed on the Frame (current or outdated)
|
||||
on_pc: bool = False # a PC VR game installed on this PC
|
||||
job: bool = False # a job runs (or waits) for this game
|
||||
plays: list = field(default_factory=list) # where it can be started now [(label, icon, handler)]
|
||||
installs: list = field(default_factory=list) # install/update/reinstall options [(label, icon, handler)]
|
||||
settings: bool = False # has Game settings
|
||||
programs: bool = False # "Change program…" makes sense
|
||||
selectable: bool = False # the Library view exists (Select)
|
||||
media_button: bool = False # the game page shows "Add videos" already
|
||||
|
||||
|
||||
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
"""The game's menu. `act(key)` gives the handler for an action key (tests pass `lambda k: k`)."""
|
||||
rift = g.get("kind") == "rift"
|
||||
linux = g.get("kind") == "linux" # installed as it is: nothing to analyze, convert or share as a recipe
|
||||
quick, job = st.quick, st.job
|
||||
top: list = []
|
||||
if job and quick: # the game page shows progress + Cancel in its hero
|
||||
top += [(tr("Show progress"), ft.Icons.SYNC_ROUNDED, act("progress")),
|
||||
(tr("Cancel"), ft.Icons.CLOSE_ROUNDED, act("cancel"))]
|
||||
if quick and not job:
|
||||
top += st.plays
|
||||
if quick:
|
||||
top.append((tr("Open game page"), ft.Icons.OPEN_IN_NEW_ROUNDED, act("open")))
|
||||
if st.selectable:
|
||||
top.append((tr("Select"), ft.Icons.CHECKLIST_ROUNDED, act("select")))
|
||||
if not job:
|
||||
top += st.installs
|
||||
if st.settings and not job:
|
||||
top.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
|
||||
if st.connected:
|
||||
top.append((tr("Screenshots"), G.SHOT, act("screenshots")))
|
||||
if st.on_frame and not rift and not linux and not job and (quick or not st.media_button):
|
||||
top.append((tr("Add videos and files"), ft.Icons.VIDEO_LIBRARY_OUTLINED, act("files")))
|
||||
|
||||
artwork = [(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
|
||||
(tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_OUTLINED, act("custom_art"))]
|
||||
if not job and st.on_frame:
|
||||
artwork.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED, act("steam_art")))
|
||||
|
||||
recipe = [] if linux else [
|
||||
(tr("Reset to suggested recipe…"), ft.Icons.SETTINGS_BACKUP_RESTORE_ROUNDED, act("reset_recipe")),
|
||||
(tr("Save as known-good recipe"), G.RECIPE, act("save_recipe")),
|
||||
(tr("Share working recipe…"), ft.Icons.SHARE_ROUNDED, act("share"))]
|
||||
|
||||
trouble: list = []
|
||||
if quick and not job and st.on_frame: # the game page has these on its "Where it's installed" cards
|
||||
trouble.append((tr("Run launch test on Frame"), G.TEST, act("test_frame")))
|
||||
if quick and not job and st.on_pc:
|
||||
trouble.append((tr("Run launch test on this PC"), G.TEST, act("test_pc")))
|
||||
if not job and not rift and not linux:
|
||||
trouble.append((tr("Analyze again"), ft.Icons.MANAGE_SEARCH_ROUNDED, act("analyze")))
|
||||
if not job and not linux:
|
||||
trouble.append((tr("Check game files") if rift else tr("Rebuild only (no install)"), G.PORT, act("build")))
|
||||
if st.programs:
|
||||
trouble.append((tr("Change program…"), ft.Icons.SWAP_HORIZ_ROUNDED, act("program")))
|
||||
trouble += [(tr("Refresh store details"), ft.Icons.PUBLISHED_WITH_CHANGES_ROUNDED, act("details")),
|
||||
(tr("Collect logs"), ft.Icons.FOLDER_ZIP_OUTLINED, act("logs")),
|
||||
(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED, act("report"))]
|
||||
|
||||
danger: list = []
|
||||
if quick and not job and st.on_frame:
|
||||
danger.append((tr("Uninstall from Frame…"), ft.Icons.DELETE_OUTLINE_ROUNDED, act("uninstall_frame")))
|
||||
if quick and not job and st.on_pc:
|
||||
danger.append((tr("Uninstall from this PC…"), ft.Icons.DELETE_OUTLINE_ROUNDED, act("uninstall_pc")))
|
||||
danger.append((tr("Remove from library…"), ft.Icons.REMOVE_CIRCLE_OUTLINE_ROUNDED, act("remove")))
|
||||
|
||||
out: list = list(top)
|
||||
for head, items in ((tr("Artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
|
||||
if items:
|
||||
out += [None, Header(head), *items]
|
||||
return out + [None, *danger]
|
||||
|
||||
|
||||
def process_menu(p: dict, act: Callable[[str], Callable]) -> list:
|
||||
"""Monitor's right-click menu for a process row or a group row (`p` = the row). Harmless items first, then
|
||||
End…/Force quit…, End game… last; a process SteamOS protects shows that instead of the End items."""
|
||||
out: list = []
|
||||
if p.get("kind") == "group":
|
||||
n = p.get("count", 0)
|
||||
out += [(tr("Hide its processes") if p.get("expanded") else tr("Show its processes"),
|
||||
ft.Icons.UNFOLD_MORE_ROUNDED, act("toggle")),
|
||||
(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, act("copy_name")), None]
|
||||
if p.get("locked"):
|
||||
out.append((tr("Protected by SteamOS"), ft.Icons.LOCK_OUTLINE_ROUNDED, None))
|
||||
else:
|
||||
out += [(tr("End all {n}…").format(n=n), ft.Icons.LOGOUT_ROUNDED, act("term")),
|
||||
(tr("Force quit all {n}…").format(n=n), ft.Icons.DANGEROUS_OUTLINED, act("kill"))]
|
||||
else:
|
||||
out += [(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, act("copy_name")),
|
||||
(tr("Copy PID {pid}").format(pid=p.get("pid")), ft.Icons.CONTENT_COPY_ROUNDED, act("copy_pid")), None]
|
||||
if p.get("locked"):
|
||||
out.append((tr("Protected by SteamOS"), ft.Icons.LOCK_OUTLINE_ROUNDED, None))
|
||||
else:
|
||||
out += [(tr("End…"), ft.Icons.LOGOUT_ROUNDED, act("term")),
|
||||
(tr("Force quit…"), ft.Icons.DANGEROUS_OUTLINED, act("kill"))]
|
||||
if p.get("game"):
|
||||
out += [None, (tr("End game…"), ft.Icons.STOP_CIRCLE_OUTLINED, act("end_game"))]
|
||||
return out
|
||||
@@ -1,223 +1,40 @@
|
||||
"""Design tokens for the FramePort GUI (dark only, the owner's choice): colour themes (an accent, a neutral surface
|
||||
ramp, semantic state colours), an 8-pt spacing scale and a small type scale. Every view takes its colours and sizes
|
||||
from here, reading them as `T.NAME` when it builds (never as a default argument or a module-level constant: those keep
|
||||
the colours of the theme that was active at import; module-level maps re-fill themselves through on_change()).
|
||||
|
||||
Themes: the built-ins below plus theme files the user installs (<data>/themes/*.json, docs/THEMES.md)."""
|
||||
"""Design tokens for the FramePort GUI (dark only): one accent, a neutral surface ramp, semantic state colours, an
|
||||
8-pt spacing scale and a small type scale. Every view takes its colours and sizes from here."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import flet as ft
|
||||
|
||||
# Colour tokens of a theme:
|
||||
# surfaces: BG window, SIDEBAR, SURFACE cards, SURFACE_2 raised/hover, SURFACE_3 inputs/chips, BORDER(_STRONG)
|
||||
# text: TEXT, TEXT_2 secondary, TEXT_3 meta/disabled
|
||||
# ACCENT (+ ACCENT_SOFT tint, ON_ACCENT text on it): the main action and the current selection
|
||||
# SECONDARY (+ SECONDARY_SOFT): secondary actions, progress, switch tracks (with "dual" also the two-colour touches)
|
||||
# states OK/WARN/ERROR/INFO, PC = PC VR / "on this PC"
|
||||
TOKENS = ("BG", "SIDEBAR", "SURFACE", "SURFACE_2", "SURFACE_3", "BORDER", "BORDER_STRONG", "TEXT", "TEXT_2", "TEXT_3",
|
||||
"ACCENT", "ACCENT_SOFT", "ON_ACCENT", "SECONDARY", "SECONDARY_SOFT", "OK", "WARN", "ERROR", "INFO", "PC")
|
||||
# surfaces (darkest → lightest)
|
||||
BG = "#0E0F13" # window background
|
||||
SIDEBAR = "#121319"
|
||||
SURFACE = "#171920" # cards
|
||||
SURFACE_2 = "#1E2029" # raised / hover
|
||||
SURFACE_3 = "#262935" # inputs, chips
|
||||
BORDER = "#2C2F3B"
|
||||
BORDER_STRONG = "#3A3E4D"
|
||||
|
||||
# "portal" follows the logo: orange (the Frame's side) = actions and selection, blue (the PC's side) = secondary
|
||||
# actions, progress and the PC; "dual" adds the blue→orange touches (sidebar edge, wordmark, selected tab).
|
||||
# Status colours are picked so a warning never reads as the accent.
|
||||
_PORTAL = {
|
||||
"BG": "#0D0E12", "SIDEBAR": "#111217", "SURFACE": "#16181E", "SURFACE_2": "#1D1F27", "SURFACE_3": "#252832",
|
||||
"BORDER": "#2A2D37", "BORDER_STRONG": "#393D49",
|
||||
"TEXT": "#EDEEF2", "TEXT_2": "#A7ABB7", "TEXT_3": "#717583",
|
||||
"ACCENT": "#FF8A1F", "ACCENT_SOFT": "#3A2512", "ON_ACCENT": "#160C03",
|
||||
"SECONDARY": "#3AA8FF", "SECONDARY_SOFT": "#0F2A40",
|
||||
# INFO is a cyan of its own: next to PC (the portal blue) it tells data series apart (Monitor: Steam vs
|
||||
# SteamVR processes, CPU vs memory) and stays clear of OK's green
|
||||
"OK": "#3DD68C", "WARN": "#F2C94C", "ERROR": "#FF6166", "INFO": "#5CD0E6", "PC": "#3AA8FF",
|
||||
}
|
||||
BUILTIN: dict[str, dict] = {
|
||||
"portal": {"name": "Portal", "dual": True, "colors": _PORTAL},
|
||||
"portal_oled": {"name": "Portal (OLED)", "dual": True, "colors": {
|
||||
**_PORTAL, # true black behind everything; cards just lift off it
|
||||
"BG": "#000000", "SIDEBAR": "#000000", "SURFACE": "#0A0A0D", "SURFACE_2": "#121317", "SURFACE_3": "#1A1B21",
|
||||
"BORDER": "#1E1F26", "BORDER_STRONG": "#2B2D36", "ACCENT_SOFT": "#2B1A0A", "SECONDARY_SOFT": "#0A1F31"}},
|
||||
"original": {"name": "Original", "dual": False, "colors": { # FramePort's colours before the Portal theme
|
||||
"BG": "#0E0F13", "SIDEBAR": "#121319", "SURFACE": "#171920", "SURFACE_2": "#1E2029", "SURFACE_3": "#262935",
|
||||
"BORDER": "#2C2F3B", "BORDER_STRONG": "#3A3E4D",
|
||||
"TEXT": "#ECEDF3", "TEXT_2": "#A9ADBD", "TEXT_3": "#737889",
|
||||
"ACCENT": "#8B7CFF", "ACCENT_SOFT": "#2A2550", "ON_ACCENT": "#0E0F13",
|
||||
"SECONDARY": "#38BDF8", "SECONDARY_SOFT": "#12303F",
|
||||
"OK": "#4ADE80", "WARN": "#FBBF24", "ERROR": "#F87171", "INFO": "#60A5FA", "PC": "#38BDF8"}},
|
||||
}
|
||||
ALIASES = {"classic": "original"} # an earlier name
|
||||
USER_PREFIX = "user:"
|
||||
THEMES: dict[str, dict] = dict(BUILTIN) # + installed theme files (load_user_themes)
|
||||
DEFAULT_THEME = "portal"
|
||||
THEME = DEFAULT_THEME
|
||||
DUAL = True
|
||||
_listeners: list[Callable[[], None]] = []
|
||||
# text
|
||||
TEXT = "#ECEDF3"
|
||||
TEXT_2 = "#A9ADBD" # secondary
|
||||
TEXT_3 = "#737889" # meta / disabled
|
||||
|
||||
# the active theme's tokens as module globals (set_theme() replaces them); declared for linters and readers
|
||||
BG = SIDEBAR = SURFACE = SURFACE_2 = SURFACE_3 = BORDER = BORDER_STRONG = ""
|
||||
TEXT = TEXT_2 = TEXT_3 = ACCENT = ACCENT_SOFT = ON_ACCENT = SECONDARY = SECONDARY_SOFT = ""
|
||||
OK = WARN = ERROR = INFO = PC = ""
|
||||
|
||||
|
||||
def theme_from_setting(value) -> str:
|
||||
"""Library setting ui.theme → a known theme id (unknown/missing/removed → the default)."""
|
||||
value = ALIASES.get(value, value)
|
||||
return value if value in THEMES else DEFAULT_THEME
|
||||
|
||||
|
||||
def on_change(callback: Callable[[], None]) -> None:
|
||||
"""Call `callback` after every set_theme() (and once now): for module-level maps that hold colours."""
|
||||
_listeners.append(callback)
|
||||
callback()
|
||||
|
||||
|
||||
def set_theme(name: str) -> None:
|
||||
"""Switch the colour tokens. Views built afterwards use the new colours (app.restyle() rebuilds the open ones)."""
|
||||
global THEME, DUAL
|
||||
THEME = theme_from_setting(name)
|
||||
DUAL = bool(THEMES[THEME]["dual"])
|
||||
globals().update(THEMES[THEME]["colors"])
|
||||
for callback in list(_listeners):
|
||||
callback()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ theme files
|
||||
class ThemeError(ValueError):
|
||||
"""A theme file FramePort can't use; the message says why, in plain words."""
|
||||
|
||||
|
||||
_HEX = re.compile(r"#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})")
|
||||
|
||||
|
||||
def _hex(value, key: str) -> str:
|
||||
m = _HEX.fullmatch(str(value).strip()) if isinstance(value, str) else None
|
||||
if not m:
|
||||
raise ThemeError(f"{key}: \"{value}\" is not a color like #FF8A1F")
|
||||
h = m.group(1)
|
||||
return "#" + (h if len(h) == 6 else "".join(c * 2 for c in h)).upper()
|
||||
|
||||
|
||||
def _luminance(color: str) -> float:
|
||||
def ch(v):
|
||||
v /= 255
|
||||
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
|
||||
r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
|
||||
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)
|
||||
|
||||
|
||||
def contrast(a: str, b: str) -> float:
|
||||
la, lb = sorted((_luminance(a), _luminance(b)), reverse=True)
|
||||
return (la + 0.05) / (lb + 0.05)
|
||||
|
||||
|
||||
def parse_theme(data: dict, fallback_name: str = "Custom theme") -> dict:
|
||||
"""A theme file's content → a theme ({"name", "dual", "colors"}); raises ThemeError. Colours it leaves out come
|
||||
from its "base" built-in (default Portal); keys may be any case."""
|
||||
if not isinstance(data, dict):
|
||||
raise ThemeError("the file isn't a theme (expected a JSON object with \"colors\")")
|
||||
unknown = set(data) - {"name", "base", "dual", "colors", "description", "author", "version"}
|
||||
if unknown:
|
||||
raise ThemeError(f"unknown field(s): {', '.join(sorted(unknown))}")
|
||||
base_id = ALIASES.get(data.get("base") or DEFAULT_THEME, data.get("base") or DEFAULT_THEME)
|
||||
if base_id not in BUILTIN:
|
||||
raise ThemeError(f"base \"{data.get('base')}\" isn't a built-in theme ({', '.join(BUILTIN)})")
|
||||
base = BUILTIN[base_id]
|
||||
raw = data.get("colors") or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ThemeError("\"colors\" must be an object of TOKEN: \"#RRGGBB\"")
|
||||
colors = dict(base["colors"])
|
||||
for key, value in raw.items():
|
||||
token = str(key).upper()
|
||||
if token not in TOKENS:
|
||||
raise ThemeError(f"unknown color \"{key}\" (known: {', '.join(TOKENS)})")
|
||||
colors[token] = _hex(value, token)
|
||||
# FramePort is dark only: a light window would leave Material's own parts (menus, inputs) unreadable
|
||||
if _luminance(colors["BG"]) > 0.05:
|
||||
raise ThemeError(f"BG {colors['BG']} is too light: FramePort themes are dark")
|
||||
if contrast(colors["TEXT"], colors["SURFACE"]) < 4.5:
|
||||
raise ThemeError(f"TEXT {colors['TEXT']} is hard to read on SURFACE {colors['SURFACE']}")
|
||||
name = str(data.get("name") or fallback_name).strip()[:40] or fallback_name
|
||||
dual = data.get("dual", base["dual"])
|
||||
if not isinstance(dual, bool):
|
||||
raise ThemeError("\"dual\" must be true or false")
|
||||
return {"name": name, "dual": dual, "colors": colors}
|
||||
|
||||
|
||||
def read_theme_file(path: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise ThemeError(f"can't read the file ({exc})") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ThemeError(f"not valid JSON (line {exc.lineno}: {exc.msg})") from exc
|
||||
return parse_theme(data, Path(path).stem.replace("_", " ").replace("-", " ").title())
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "theme"
|
||||
|
||||
|
||||
def load_user_themes(folder: Path) -> list[str]:
|
||||
"""(Re)load the installed theme files into THEMES. Returns problems with files that were skipped."""
|
||||
for key in [k for k in THEMES if k.startswith(USER_PREFIX)]:
|
||||
del THEMES[key]
|
||||
problems = []
|
||||
for f in sorted(Path(folder).glob("*.json")) if Path(folder).is_dir() else []:
|
||||
try:
|
||||
THEMES[USER_PREFIX + f.stem] = {**read_theme_file(f), "path": str(f)}
|
||||
except ThemeError as exc:
|
||||
problems.append(f"{f.name}: {exc}")
|
||||
return problems
|
||||
|
||||
|
||||
def install_theme(source: Path, folder: Path) -> str:
|
||||
"""Check a theme file and copy it into the themes folder (named after the theme). Returns its theme id."""
|
||||
theme = read_theme_file(source)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
dest = folder / f"{_slug(theme['name'])}.json"
|
||||
if Path(source).resolve() != dest.resolve():
|
||||
shutil.copyfile(source, dest)
|
||||
tid = USER_PREFIX + dest.stem
|
||||
THEMES[tid] = {**theme, "path": str(dest)}
|
||||
return tid
|
||||
|
||||
|
||||
def remove_theme(theme_id: str) -> None:
|
||||
"""Delete an installed theme file (built-ins can't be removed)."""
|
||||
theme = THEMES.get(theme_id)
|
||||
if not theme_id.startswith(USER_PREFIX) or not theme:
|
||||
raise ThemeError("only installed themes can be removed")
|
||||
Path(theme["path"]).unlink(missing_ok=True)
|
||||
del THEMES[theme_id]
|
||||
|
||||
|
||||
def theme_json(theme_id: str) -> str:
|
||||
"""A theme as a theme file (a starting point for your own)."""
|
||||
t = THEMES[theme_id]
|
||||
base = theme_id if theme_id in BUILTIN else DEFAULT_THEME
|
||||
return json.dumps({"name": f"My {t['name']}", "base": base, "dual": t["dual"], "colors": t["colors"]}, indent=2)
|
||||
|
||||
|
||||
set_theme(DEFAULT_THEME)
|
||||
# accent + states
|
||||
ACCENT = "#8B7CFF"
|
||||
ACCENT_SOFT = "#2A2550"
|
||||
ON_ACCENT = "#0E0F13"
|
||||
OK = "#4ADE80"
|
||||
WARN = "#FBBF24"
|
||||
ERROR = "#F87171"
|
||||
INFO = "#60A5FA"
|
||||
PC = "#38BDF8" # PC VR / "on this PC"
|
||||
|
||||
# spacing / shape / type (at 100 %; set_scale() multiplies them)
|
||||
# RADIUS cards/dialogs, RADIUS_SM buttons/inputs/thumbnails, RADIUS_XS small chips/tooltips; ICON_S/M/L icon sizes;
|
||||
# DIALOG_S/M/L dialog content widths; T_DISPLAY big headings (empty states, setup)
|
||||
_BASE = {"S1": 4, "S2": 8, "S3": 12, "S4": 16, "S5": 24, "S6": 32, "RADIUS": 12, "RADIUS_SM": 8, "RADIUS_XS": 6,
|
||||
"ICON_S": 16, "ICON_M": 20, "ICON_L": 24, "DIALOG_S": 440, "DIALOG_M": 600, "DIALOG_L": 880,
|
||||
"T_DISPLAY": 22, "T_TITLE": 28, "T_H2": 16, "T_BODY": 13, "T_META": 12, "T_SMALL": 11}
|
||||
_BASE = {"S1": 4, "S2": 8, "S3": 12, "S4": 16, "S5": 24, "S6": 32, "RADIUS": 12, "RADIUS_SM": 8,
|
||||
"T_TITLE": 28, "T_H2": 16, "T_BODY": 13, "T_META": 12, "T_SMALL": 11}
|
||||
S1, S2, S3, S4, S5, S6 = 4, 8, 12, 16, 24, 32
|
||||
RADIUS = 12
|
||||
RADIUS_SM = 8
|
||||
RADIUS_XS = 6
|
||||
ICON_S, ICON_M, ICON_L = 16, 20, 24
|
||||
DIALOG_S, DIALOG_M, DIALOG_L = 440, 600, 880
|
||||
T_DISPLAY, T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 22, 28, 16, 13, 12, 11
|
||||
T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 28, 16, 13, 12, 11
|
||||
SCALE = 1.0
|
||||
|
||||
|
||||
@@ -274,7 +91,7 @@ def soft(color: str, opacity: float = 0.14) -> str:
|
||||
def apply(page: ft.Page) -> None:
|
||||
scheme = ft.ColorScheme(
|
||||
primary=ACCENT, on_primary=ON_ACCENT, primary_container=ACCENT_SOFT, on_primary_container=TEXT,
|
||||
secondary=SECONDARY, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
|
||||
secondary=PC, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
|
||||
surface_container_lowest=BG, surface_container_low=SIDEBAR, surface_container=SURFACE,
|
||||
surface_container_high=SURFACE_2, surface_container_highest=SURFACE_3, outline=BORDER_STRONG,
|
||||
outline_variant=BORDER, error=ERROR, on_error=ON_ACCENT,
|
||||
@@ -291,19 +108,13 @@ def apply(page: ft.Page) -> None:
|
||||
text_theme=text_theme,
|
||||
divider_theme=ft.DividerTheme(color=BORDER, thickness=1, space=1),
|
||||
tooltip_theme=ft.TooltipTheme(
|
||||
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=RADIUS_XS,
|
||||
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=6,
|
||||
border=ft.Border.all(1, BORDER)),
|
||||
text_style=ft.TextStyle(color=TEXT, size=px(12)), wait_duration=400),
|
||||
expansion_tile_theme=ft.ExpansionTileTheme(
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS), collapsed_shape=ft.RoundedRectangleBorder(
|
||||
radius=RADIUS), icon_color=TEXT_2, collapsed_icon_color=TEXT_3, text_color=TEXT,
|
||||
collapsed_text_color=TEXT),
|
||||
# menus lift off the dark page: border + shadow (right-click ContextMenus only get this theme)
|
||||
popup_menu_theme=ft.PopupMenuTheme(
|
||||
color=SURFACE_2, elevation=8, shadow_color="#000000",
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM, side=ft.BorderSide(1, BORDER_STRONG)),
|
||||
menu_padding=ft.Padding(0, px(4), 0, px(4)),
|
||||
label_text_style=ft.TextStyle(size=T_BODY, color=TEXT)),
|
||||
scaffold_bgcolor=BG, card_bgcolor=SURFACE, canvas_color=SURFACE_2)
|
||||
page.theme = page.dark_theme = theme
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Install progress as a transit (no Flet): a job's stage names → one of six steps and an overall fraction, so the GUI
|
||||
can show the game crossing the portal from the PC to the Frame (components.Transit).
|
||||
|
||||
The stage names come from the install job (app._submit_install), build.py, pipeline.py and the installers; they are
|
||||
intentionally not translated so they can be mapped here."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
# step keys (stable) and their labels (translated when shown)
|
||||
STEPS = ("analyze", "patch", "sign", "upload", "install", "test")
|
||||
LABELS = {"analyze": "Analyze", "patch": "Patch", "sign": "Sign", "upload": "Upload", "install": "Install",
|
||||
"test": "Test"}
|
||||
|
||||
# a run starts over with these (a job that lost the Frame runs again from the beginning)
|
||||
_START = ("patching the game", "checking the game")
|
||||
# lower-case stage prefix → step index; the first match wins
|
||||
_PREFIXES = (
|
||||
("patching", 0), ("checking", 0), ("check game files", 0),
|
||||
("ovrport", 1), ("frame fixes", 1), ("revive", 1),
|
||||
("sign", 2), ("validate", 2),
|
||||
("prepare", 3), ("upload", 3),
|
||||
("artwork", 4), ("finalize", 4), ("add to steam", 4),
|
||||
("launch test", 5), ("starting steamvr", 5),
|
||||
)
|
||||
_DONE = ("installed",)
|
||||
WAITING = "Waiting for the Frame"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitState:
|
||||
step: int # index into STEPS (len(STEPS) = everything done)
|
||||
fraction: float # overall progress 0..1
|
||||
label: str # the step's key ("upload" …), "done" when finished
|
||||
detail: str # what happens right now: the stage, or the upload's % and speed
|
||||
waiting: bool # the queue waits for the Frame
|
||||
failed: bool
|
||||
target: str # "frame" | "pc"
|
||||
|
||||
|
||||
def step_of(stage: str) -> int | None:
|
||||
"""The step a stage name belongs to (None: unknown, keeps the previous step)."""
|
||||
low = (stage or "").strip().lower()
|
||||
if low.startswith(_DONE):
|
||||
return len(STEPS)
|
||||
for prefix, index in _PREFIXES:
|
||||
if low.startswith(prefix):
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _is_waiting(stage: str) -> bool:
|
||||
from ..i18n import tr
|
||||
|
||||
return bool(stage) and stage in (WAITING, tr(WAITING))
|
||||
|
||||
|
||||
def transit_state(state: str = "running", stage: str = "", stages: list[str] | None = None,
|
||||
fraction: float | None = None, speed: str = "", message: str = "", kind: str = "install",
|
||||
to: str = "frame") -> TransitState:
|
||||
"""Where a job is on its way: the furthest step of the current run (a second build variant's OVRPort after the
|
||||
first one's validate doesn't move it back), the upload's own fraction inside the Upload step, and 1.0 when the
|
||||
job is done (also when the launch test is off and it ends at "Installed")."""
|
||||
stages = list(stages or [])
|
||||
waiting = _is_waiting(stage)
|
||||
step = 0
|
||||
for s in stages:
|
||||
if s.strip().lower().startswith(_START):
|
||||
step = 0 # a new run
|
||||
index = step_of(s)
|
||||
if index is not None:
|
||||
step = max(step, index)
|
||||
if kind == "test" and not any(step_of(s) is not None for s in stages):
|
||||
step = 5 # a launch test on its own: everything before it is done
|
||||
current = None if waiting else step_of(stage)
|
||||
within = 0.0
|
||||
if fraction is not None and current == step and step < len(STEPS):
|
||||
within = min(1.0, max(0.0, float(fraction)))
|
||||
done = state == "done" or step >= len(STEPS)
|
||||
if done:
|
||||
step, within = len(STEPS), 0.0
|
||||
overall = 1.0 if done else (step + within) / len(STEPS)
|
||||
if waiting:
|
||||
detail = WAITING
|
||||
elif current == 3 and fraction is not None:
|
||||
detail = " · ".join(x for x in (f"{fraction:.0%}", speed) if x)
|
||||
else:
|
||||
detail = stage or message
|
||||
return TransitState(step=step, fraction=overall, label="done" if done else STEPS[step], detail=detail,
|
||||
waiting=waiting, failed=state == "failed", target="pc" if to == "pc" else "frame")
|
||||
|
||||
|
||||
def job_state(job) -> TransitState:
|
||||
"""transit_state() for a ui.jobs.Job."""
|
||||
return transit_state(job.state, job.stage, job.stages, job.fraction, job.speed, job.message, job.kind,
|
||||
getattr(job, "to", "frame"))
|
||||
|
||||
|
||||
class Monotonic:
|
||||
"""Keeps one place's bar from moving back within a run: the installer reports the upload's fraction over all its
|
||||
files (APK, then data), but a new stage clears the job's fraction until the first tick ("Upload data" started at
|
||||
the Upload step's 0 % after the APK had filled it). The fraction never decreases for the same job unless the step
|
||||
goes back (the job started over, e.g. after the Frame was lost)."""
|
||||
|
||||
def __init__(self):
|
||||
self.key = None
|
||||
self.step = -1
|
||||
self.best = 0.0
|
||||
|
||||
def follow(self, key, state: TransitState) -> TransitState:
|
||||
if key != self.key or state.step < self.step:
|
||||
self.key, self.best = key, 0.0
|
||||
self.step = state.step
|
||||
self.best = max(self.best, state.fraction)
|
||||
return state if state.fraction == self.best else replace(state, fraction=self.best)
|
||||
@@ -15,7 +15,6 @@ from ..core import applog, library
|
||||
from ..errors import explain
|
||||
from ..i18n import tr
|
||||
from . import components as C
|
||||
from . import glyphs as G
|
||||
from . import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -30,17 +29,12 @@ class Updater:
|
||||
self.found: updates.Update | None = None
|
||||
self.restart = None # callable: what to do once the job queue is idle (install + quit)
|
||||
self.preparing = False
|
||||
self.build_card()
|
||||
|
||||
def build_card(self) -> None:
|
||||
"""The sidebar's "Update available" card (again after a theme change; keeps what it shows)."""
|
||||
old = getattr(self, "card", None)
|
||||
self._version = C.meta(getattr(self, "_version", None) and self._version.value or "", color=T.ON_ACCENT)
|
||||
self._version = C.meta("", color=T.ON_ACCENT)
|
||||
self.card = ft.Container(
|
||||
ft.Row([ft.Icon(ft.Icons.SYSTEM_UPDATE_ROUNDED, size=T.px(18), color=T.ON_ACCENT),
|
||||
ft.Column([C.body(tr("Update available"), T.ON_ACCENT, weight=ft.FontWeight.W_600), self._version],
|
||||
spacing=0, expand=True)], spacing=T.S2),
|
||||
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=bool(old and old.visible),
|
||||
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=False,
|
||||
tooltip=tr("A new FramePort version is ready to install"), on_click=lambda e: self.show_dialog())
|
||||
|
||||
# ---------------------------------------------------------------- checking
|
||||
@@ -99,13 +93,13 @@ class Updater:
|
||||
return
|
||||
page = self.app.page
|
||||
kind = updates.install_kind()
|
||||
how = {"bundle": tr("FramePort downloads it, checks it, closes and opens again as the new version. Your "
|
||||
"games, settings and Frame connection stay as they are."),
|
||||
"source": tr("Updates this source checkout (git pull + uv sync), then restarts FramePort."),
|
||||
"wheel": tr("Reinstalls FramePort from the release, then restarts it.")}[kind]
|
||||
how = {"bundle": "FramePort downloads it, checks it, closes and opens again as the new version. Your games, "
|
||||
"settings and Frame connection stay as they are.",
|
||||
"source": "Updates this source checkout (git pull + uv sync), then restarts FramePort.",
|
||||
"wheel": "Reinstalls FramePort from the release, then restarts it."}[kind]
|
||||
notes = up.notes.strip() or tr("No release notes.")
|
||||
|
||||
def skip(e):
|
||||
def later(e):
|
||||
updates.skip(up.version)
|
||||
self._set(None)
|
||||
page.pop_dialog()
|
||||
@@ -113,9 +107,11 @@ class Updater:
|
||||
def go(e):
|
||||
page.pop_dialog()
|
||||
self.install()
|
||||
page.show_dialog(C.dialog(
|
||||
tr("FramePort {version} is available").format(version=up.version),
|
||||
ft.Column([
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("FramePort {version} is available").format(version=up.version), color=T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2,
|
||||
content=ft.Container(ft.Column([
|
||||
C.body(tr("You have {version}. {how}").format(version=__version__, how=how), T.TEXT_2),
|
||||
ft.Container(ft.Markdown(notes, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
md_style_sheet=_notes_style(),
|
||||
@@ -123,8 +119,8 @@ class Updater:
|
||||
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),
|
||||
height=T.px(min(420, 130 + 26 * len(notes.splitlines()))),
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=skip),
|
||||
width=T.px(560), height=T.px(min(420, 130 + 26 * len(notes.splitlines())))),
|
||||
actions=[C.ghost(tr("Skip this version"), on_click=later),
|
||||
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)]))
|
||||
|
||||
@@ -151,9 +147,11 @@ class Updater:
|
||||
page.pop_dialog()
|
||||
self.found = up
|
||||
self.install()
|
||||
page.show_dialog(C.dialog(
|
||||
tr("Install dev build {version}?").format(version=up.version),
|
||||
ft.Column([
|
||||
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"),
|
||||
@@ -163,10 +161,10 @@ class Updater:
|
||||
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),
|
||||
height=T.px(min(440, 190 + 26 * len(notes.splitlines()))),
|
||||
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"), G.TEST, go)]))
|
||||
C.primary(tr("Install dev build"), ft.Icons.SCIENCE_OUTLINED, go)]))
|
||||
|
||||
# ---------------------------------------------------------------- installing
|
||||
def install(self) -> None:
|
||||
@@ -211,7 +209,7 @@ class Updater:
|
||||
raise RuntimeError(tr("{value} failed ({returncode})")
|
||||
.format(value=cmd[0], returncode=out.returncode))
|
||||
self.restart = self._restart_process
|
||||
rep.stage(tr("Restarting"))
|
||||
rep.stage("Restarting")
|
||||
return tr("FramePort {version} is ready: restarting").format(version=up.version)
|
||||
self.app.submit(tr("Update FramePort to {version}").format(version=up.version), run, None, kind="app-update",
|
||||
open_panel=True)
|
||||
@@ -270,8 +268,8 @@ def library_bar(app: FramePortApp) -> ft.Control | None:
|
||||
C.body(tr("FramePort {version} is available (you have {version2}).")
|
||||
.format(version=up.version, version2=__version__), T.TEXT, expand=True),
|
||||
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, lambda e: app.updater.install()),
|
||||
C.ghost(tr("What's new…"), on_click=lambda e: app.updater.show_dialog()),
|
||||
C.ghost(tr("Not now"), on_click=lambda e: (updates.snooze(up.version), app.updater._set(None))),
|
||||
C.ghost(tr("What's new"), on_click=lambda e: app.updater.show_dialog()),
|
||||
C.ghost(tr("Later"), on_click=lambda e: (updates.skip(up.version), app.updater._set(None))),
|
||||
], spacing=T.S3), "info", ft.Icons.SYSTEM_UPDATE_ROUNDED)
|
||||
|
||||
|
||||
|
||||
@@ -15,22 +15,13 @@ from ..jobs import Job
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
CHECK_ICON: dict = {}
|
||||
STATE_STYLE: dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _fill_styles() -> None:
|
||||
"""(Re)fill the maps with the active theme's colours, in place."""
|
||||
CHECK_ICON.update({True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
|
||||
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)})
|
||||
STATE_STYLE.update({"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, tr("Waiting")),
|
||||
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, tr("Working")),
|
||||
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, tr("Done")),
|
||||
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, tr("Failed")),
|
||||
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, tr("Canceled"))})
|
||||
|
||||
|
||||
T.on_change(_fill_styles)
|
||||
CHECK_ICON = {True: (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK), False: (ft.Icons.CANCEL_ROUNDED, T.ERROR),
|
||||
None: (ft.Icons.WARNING_AMBER_ROUNDED, T.WARN)}
|
||||
STATE_STYLE = {"queued": (ft.Icons.SCHEDULE_ROUNDED, T.TEXT_3, tr("Waiting")),
|
||||
"running": (ft.Icons.SYNC_ROUNDED, T.ACCENT, tr("Working")),
|
||||
"done": (ft.Icons.CHECK_CIRCLE_ROUNDED, T.OK, tr("Done")),
|
||||
"failed": (ft.Icons.ERROR_ROUNDED, T.ERROR, tr("Failed")),
|
||||
"cancelled": (ft.Icons.DO_NOT_DISTURB_ON_OUTLINED, T.TEXT_3, tr("Cancelled"))}
|
||||
|
||||
|
||||
def _dur(job: Job) -> str:
|
||||
@@ -40,14 +31,18 @@ def _dur(job: Job) -> str:
|
||||
return f"{s // 60}:{s % 60:02d}"
|
||||
|
||||
|
||||
|
||||
def _progress_text(job: Job) -> str:
|
||||
"""Speed first: the line is ellipsized, and the file name matters least."""
|
||||
return " · ".join(x for x in (job.speed, job.message) if x)
|
||||
|
||||
class ActivityPanel:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.expanded: set[int] = set()
|
||||
self.logs: set[int] = set()
|
||||
self._tiles: dict[int, tuple[tuple, ft.Control]] = {} # job id -> (state key, tile)
|
||||
self._live: dict[int, tuple] = {} # running job id -> (transit, log text or None)
|
||||
self._transits: dict[int, C.Transit] = {} # running job id -> its transit, kept across tile rebuilds
|
||||
self._live: dict[int, tuple] = {} # running job id -> (progress bar, message, stage text, log text or None)
|
||||
# job id -> (log text, its scrolling column): kept across tile rebuilds so reading the log doesn't jump
|
||||
self._logviews: dict[int, tuple[ft.Text, ft.Column]] = {}
|
||||
self._lock = threading.Lock() # refresh() runs from job threads and the UI thread
|
||||
@@ -100,9 +95,11 @@ class ActivityPanel:
|
||||
self._tiles[j.id] = cached
|
||||
rebuilt = True
|
||||
elif running and j.id in self._live:
|
||||
transit, log = self._live[j.id]
|
||||
transit.set(j, **self.app.transit_info(j))
|
||||
live.append(transit.control)
|
||||
bar, msg, meta, log = self._live[j.id]
|
||||
bar.value = j.fraction
|
||||
msg.value = _progress_text(j)
|
||||
meta.value = j.stage or ""
|
||||
live += [bar, msg, meta]
|
||||
if log is not None and len(j.log) != getattr(log, "_lines", -1): # only when it grew
|
||||
log.value = "\n".join(j.log[-400:])
|
||||
log._lines = len(j.log)
|
||||
@@ -122,8 +119,6 @@ class ActivityPanel:
|
||||
return
|
||||
self._layout = layout
|
||||
self._tiles = {j.id: self._tiles[j.id] for j in jobs}
|
||||
self._transits = {jid: t for jid, t in self._transits.items()
|
||||
if any(j.id == jid and j.state == "running" for j in jobs)}
|
||||
self._logviews = {jid: v for jid, v in self._logviews.items() if jid in self._tiles}
|
||||
if self.app.jobs.paused == "frame":
|
||||
pinned.insert(0, self._paused_notice())
|
||||
@@ -166,10 +161,11 @@ class ActivityPanel:
|
||||
icon, color, word = ft.Icons.WARNING_AMBER_ROUNDED, T.WARN, tr("Installed · launch test failed")
|
||||
running = job.state == "running"
|
||||
expanded = running or job.id in self.expanded
|
||||
meta = C.meta((job.error or word) + (f" · {_dur(job)}" if job.finished else ""),
|
||||
T.ERROR if job.state == "failed" else T.TEXT_2, max_lines=2, visible=not running)
|
||||
meta = C.meta(job.stage if running and job.stage else
|
||||
(job.error or word) + (f" · {_dur(job)}" if job.finished else ""),
|
||||
T.ERROR if job.state == "failed" else T.TEXT_2, max_lines=2)
|
||||
head = ft.Row([
|
||||
C.spinner() if running
|
||||
ft.ProgressRing(width=T.px(18), height=T.px(18), stroke_width=T.px(2), color=T.ACCENT) if running
|
||||
else ft.Icon(icon, color=color, size=T.px(20)),
|
||||
ft.Column([C.body(job.title, T.TEXT, weight=ft.FontWeight.W_600, max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS), meta],
|
||||
@@ -178,13 +174,11 @@ class ActivityPanel:
|
||||
if job.active else []),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S3)
|
||||
parts: list[ft.Control] = [head]
|
||||
if running: # the game on its way through the portal, with the six steps
|
||||
transit = self._transits.get(job.id)
|
||||
if transit is None:
|
||||
transit = self._transits[job.id] = C.Transit(heading=False)
|
||||
transit.set(job, **self.app.transit_info(job))
|
||||
parts.append(transit.control)
|
||||
self._live[job.id] = (transit, None)
|
||||
if running:
|
||||
bar = C.progress_bar(job.fraction)
|
||||
msg = C.meta(_progress_text(job), max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
parts += [bar, msg]
|
||||
self._live[job.id] = (bar, msg, meta, None)
|
||||
if expanded:
|
||||
if job.stages:
|
||||
parts.append(ft.Column([
|
||||
@@ -208,22 +202,21 @@ class ActivityPanel:
|
||||
C.ghost(tr("Hide log") if show_log else tr("Show log"), ft.Icons.TERMINAL_ROUNDED,
|
||||
lambda e: self._toggle(self.logs, job.id)),
|
||||
C.ghost(tr("Copy log"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(self.job_text(job))),
|
||||
*([C.ghost(tr("Full launch log"), ft.Icons.DESCRIPTION_OUTLINED,
|
||||
*([C.ghost(tr("Full launch log"), ft.Icons.DESCRIPTION_ROUNDED,
|
||||
lambda e: self.app.show_log_file(job.log_path, job.title))] if job.log_path else []),
|
||||
], spacing=0, wrap=True))
|
||||
if show_log:
|
||||
log, col = self._log_view(job)
|
||||
log.value = "\n".join(job.log[-400:])
|
||||
if running:
|
||||
self._live[job.id] = (self._live[job.id][0], log)
|
||||
self._live[job.id] = (*self._live[job.id][:3], log)
|
||||
else:
|
||||
col.auto_scroll = False
|
||||
parts.append(ft.Container(col, bgcolor=T.BG, border_radius=T.RADIUS_SM, padding=T.S2,
|
||||
height=T.px(240)))
|
||||
return ft.Container(ft.Column(parts, spacing=T.S2), bgcolor=T.SURFACE, border_radius=T.RADIUS,
|
||||
border=ft.Border.all(1, T.ACCENT if running else T.BORDER), padding=T.S3,
|
||||
on_click=None if running else lambda e: self._toggle(self.expanded, job.id),
|
||||
ink=not running)
|
||||
on_click=None if running else lambda e: self._toggle(self.expanded, job.id))
|
||||
|
||||
def _log_view(self, job: Job) -> tuple[ft.Text, ft.Column]:
|
||||
"""The job's log view, the same one for the job's whole life: rebuilding it on every new stage or check reset
|
||||
|
||||
@@ -123,7 +123,7 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
choices = list(spec[0])
|
||||
if v not in [c for c, _ in choices]:
|
||||
choices.append((v, str(v))) # a value set elsewhere (CLI, catalog) stays selectable
|
||||
return C.dropdown(value=str(v), width=T.px(220),
|
||||
return ft.Dropdown(value=str(v), width=T.px(220), dense=True, border_color=T.BORDER,
|
||||
options=[ft.DropdownOption(key=str(c), text=tr(label)) for c, label in choices],
|
||||
on_select=lambda e: set_value(key, e.control.value))
|
||||
|
||||
@@ -201,8 +201,12 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
if show_adv else []),
|
||||
], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True)
|
||||
title = tr("Game settings · {title}").format(title=app._title(package) if g else package)
|
||||
app.page.show_dialog(C.dialog(
|
||||
title, content, [C.ghost(tr("Reset to recommended"), ft.Icons.RESTART_ALT_ROUNDED, reset),
|
||||
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Save"), on_click=save)], height=T.px(560)))
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(title, weight=ft.FontWeight.W_600), bgcolor=T.SURFACE_2,
|
||||
shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
content=ft.Container(content, width=T.px(640), height=T.px(560)),
|
||||
actions=[C.ghost(tr("Reset to recommended"), ft.Icons.RESTART_ALT_ROUNDED, reset),
|
||||
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Save"), on_click=save)],
|
||||
actions_alignment=ft.MainAxisAlignment.END))
|
||||
apply_visibility()
|
||||
@@ -19,7 +19,9 @@ if TYPE_CHECKING:
|
||||
|
||||
def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
g = library.game(package)
|
||||
term = C.search(value=g.get("title") or package, expand=True)
|
||||
term = ft.TextField(value=g.get("title") or package, dense=True, expand=True, border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT, focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)), text_size=T.T_BODY)
|
||||
results = ft.GridView(max_extent=T.px(190), child_aspect_ratio=0.8, spacing=T.S3, run_spacing=T.S3,
|
||||
height=T.px(400))
|
||||
status = C.meta("")
|
||||
@@ -41,7 +43,7 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
if installed:
|
||||
app.toast(tr("Artwork updated for {get}. The Frame's Steam library still shows the old art.")
|
||||
.format(get=g.get('title')),
|
||||
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(tr("Artwork updated for {get}").format(get=g.get('title')))
|
||||
app.run_bg(work)
|
||||
@@ -54,7 +56,7 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
def work():
|
||||
found = sources.search(term.value.strip())
|
||||
results.controls = [ft.Container(ft.Column([
|
||||
ft.Container(C.art_fill(r["preview"], radius=T.RADIUS_SM, height=T.px(120)), height=T.px(120)),
|
||||
ft.Container(C.art_fill(r["preview"], radius=T.px(8), height=T.px(120)), height=T.px(120)),
|
||||
C.body(r["name"] or "", T.TEXT, size=T.T_META, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
C.meta(r["source"]),
|
||||
], spacing=T.px(4)), padding=T.S2, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, ink=True,
|
||||
@@ -78,19 +80,20 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
if src and src != "none" and installed:
|
||||
app.toast(tr("Artwork updated ({src}). The Frame's Steam library still shows the old art.")
|
||||
.format(src=src),
|
||||
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(tr("Artwork: {value}").format(value=src or 'none found'))
|
||||
app.run_bg(work)
|
||||
|
||||
term.on_submit = search
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Artwork for {get}").format(get=g.get('title')),
|
||||
ft.Column([
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Artwork for {get}").format(get=g.get('title')), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
ft.Row([term, C.secondary(tr("Search"), ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
|
||||
status, results,
|
||||
], spacing=T.S3, tight=True),
|
||||
actions=[C.ghost(tr("Use your own images…"), ft.Icons.UPLOAD_FILE_OUTLINED,
|
||||
], spacing=T.S3, tight=True), width=T.px(660)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Use your own images…"), ft.Icons.UPLOAD_FILE_ROUNDED,
|
||||
lambda e: (app.page.pop_dialog(), show_custom_art_dialog(app, package))),
|
||||
C.ghost(tr("Find automatically"), ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())]))
|
||||
@@ -122,7 +125,7 @@ def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
app.refresh_view()
|
||||
if installed:
|
||||
app.toast(tr("{message} The Frame's Steam library still shows the old art.").format(message=message),
|
||||
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
action=tr("Update on Frame"), on_action=lambda e: app.update_steam_art(package))
|
||||
else:
|
||||
app.toast(message)
|
||||
|
||||
@@ -152,16 +155,16 @@ def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
art = thumbs.url(package, (kind,), 400)
|
||||
if art and kind in ("logo", "icon"): # shown whole (a logo is often very wide, an icon small)
|
||||
box = ft.Container(ft.Image(src=art, fit=ft.BoxFit.CONTAIN), height=T.px(110), padding=T.S2,
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, alignment=ft.Alignment.CENTER)
|
||||
border_radius=T.px(8), bgcolor=T.SURFACE_3, alignment=ft.Alignment.CENTER)
|
||||
else:
|
||||
box = (C.art_fill(art, radius=T.RADIUS_SM, height=T.px(110)) if art else
|
||||
box = (C.art_fill(art, radius=T.px(8), height=T.px(110)) if art else
|
||||
ft.Container(C.meta(tr("None")), height=T.px(110), alignment=ft.Alignment.CENTER,
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3))
|
||||
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_OUTLINED,
|
||||
border_radius=T.px(8), bgcolor=T.SURFACE_3))
|
||||
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_ROUNDED,
|
||||
lambda e, k=kind, lb=label: app.page.run_task(choose, k, lb))]
|
||||
if art:
|
||||
buttons.append(C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove"),
|
||||
lambda e, k=kind, lb=label: remove(k, lb), color=T.TEXT_3))
|
||||
buttons.append(ft.IconButton(ft.Icons.DELETE_OUTLINE_ROUNDED, icon_size=T.px(18), icon_color=T.TEXT_3,
|
||||
tooltip=tr("Remove"), on_click=lambda e, k=kind, lb=label: remove(k, lb)))
|
||||
return ft.Container(ft.Column([
|
||||
box,
|
||||
C.body(label, T.TEXT, size=T.T_META, weight=ft.FontWeight.W_600),
|
||||
@@ -175,13 +178,14 @@ def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
C.update(grid)
|
||||
|
||||
grid.controls = [slot(*s) for s in custom_slots()]
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Your own artwork for {title}").format(title=g.get("title")),
|
||||
ft.Column([
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Your own artwork for {title}").format(title=g.get("title")), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(ft.Column([
|
||||
C.body(tr("Choose PNG, JPEG or WebP images. Steam fills any missing shape from the ones you choose; "
|
||||
"FramePort never replaces your images automatically.")),
|
||||
grid,
|
||||
], spacing=T.S3, tight=True),
|
||||
], spacing=T.S3, tight=True), width=T.px(660)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Search the stores instead"), ft.Icons.IMAGE_SEARCH_ROUNDED,
|
||||
lambda e: (app.page.pop_dialog(), show_art_dialog(app, package))),
|
||||
C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())]))
|
||||
@@ -42,18 +42,6 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
cands = extra.get("exe_candidates") or [{"path": g.get("exe"), "score": 0, "reasons": [], "size": 0}]
|
||||
current = g.get("exe") or cands[0]["path"]
|
||||
group = ft.RadioGroup(value=current, content=ft.Column(spacing=T.S2))
|
||||
rows: dict[str, ft.Container] = {}
|
||||
|
||||
def paint(e=None):
|
||||
for path, row in rows.items():
|
||||
C.selected_style(row, path == group.value, idle_bg=T.SURFACE)
|
||||
if e is not None:
|
||||
C.update(group)
|
||||
|
||||
def choose(path):
|
||||
group.value = path
|
||||
paint(True)
|
||||
group.on_change = paint
|
||||
for i, c in enumerate(cands):
|
||||
tags = [C.pill(r, T.OK if r in ("Unreal game build", "Unity game (next to its data)", "name matches the game",
|
||||
"named in the Oculus manifest") else
|
||||
@@ -62,7 +50,7 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
if i == 0:
|
||||
tags.insert(0, C.pill(tr("Best guess"), T.ACCENT, ft.Icons.AUTO_AWESOME_ROUNDED))
|
||||
folder, _, name = c["path"].rpartition("/")
|
||||
rows[c["path"]] = row = C.hoverable(ft.Container(ft.Row([
|
||||
group.content.controls.append(ft.Container(ft.Row([
|
||||
ft.Radio(value=c["path"], active_color=T.ACCENT),
|
||||
ft.Column([
|
||||
ft.Row([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.meta(_size(c.get("size") or 0))],
|
||||
@@ -71,9 +59,8 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
ft.Row(tags, spacing=T.px(6), wrap=True) if tags else ft.Container(),
|
||||
], spacing=T.px(4), expand=True),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.START), padding=ft.Padding(T.S2, T.S2, T.S3, T.S2),
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, on_click=lambda e, p=c["path"]: choose(p)))
|
||||
group.content.controls.append(row)
|
||||
paint()
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE if c["path"] != current else T.ACCENT_SOFT,
|
||||
on_click=lambda e, p=c["path"]: (setattr(group, "value", p), group.update())))
|
||||
|
||||
def use(e):
|
||||
choice = group.value
|
||||
@@ -101,10 +88,12 @@ def show_exe_dialog(app: FramePortApp, package: str, remaining: int = 0,
|
||||
"as crash reporters or updaters are the wrong choice).") if linux else
|
||||
tr("FramePort found more than one program that could start this game. Pick the one you'd double-click to "
|
||||
"play it. Oculus builds usually work better with Revive than Steam builds."))
|
||||
app.page.show_dialog(C.dialog(
|
||||
title, ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
|
||||
title_actions=[C.meta(tr("{remaining} more after this").format(remaining=remaining))] if remaining else None,
|
||||
height=T.px(min(120 + 96 * len(cands), 520)),
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Row([ft.Text(title, weight=ft.FontWeight.W_600, expand=True)]
|
||||
+ ([C.meta(tr("{remaining} more after this").format(remaining=remaining))] if remaining else [])),
|
||||
content=ft.Container(ft.Column([C.body(lead), group], spacing=T.S4, scroll=ft.ScrollMode.AUTO, tight=True),
|
||||
width=T.px(620), height=min(120 + 96 * len(cands), 520)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
modal=True, on_dismiss=pick(lambda e: on_done() if on_done else None), # closed (Esc) = decide later
|
||||
actions=[C.ghost(tr("Decide later"), on_click=pick(later)),
|
||||
C.primary(tr("Use this program"), on_click=pick(use))]))
|
||||
@@ -23,7 +23,7 @@ from .files_dialog import human
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
SHARED_ICONS = {"videos": ft.Icons.MOVIE_OUTLINED, "downloads": ft.Icons.DOWNLOAD_ROUNDED,
|
||||
SHARED_ICONS = {"videos": ft.Icons.MOVIE_OUTLINED, "downloads": ft.Icons.DOWNLOAD_OUTLINED,
|
||||
"documents": ft.Icons.DESCRIPTION_OUTLINED}
|
||||
VIDEO_EXT = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v", ".ts"}
|
||||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
||||
@@ -47,7 +47,7 @@ def dropzone_available() -> bool:
|
||||
|
||||
def file_icon(name: str, is_dir: bool) -> str:
|
||||
if is_dir:
|
||||
return ft.Icons.FOLDER_OUTLINED
|
||||
return ft.Icons.FOLDER_ROUNDED
|
||||
ext = Path(name).suffix.lower()
|
||||
if ext in VIDEO_EXT:
|
||||
return ft.Icons.MOVIE_OUTLINED
|
||||
@@ -84,24 +84,21 @@ class FilesView:
|
||||
self.listing = ft.Column(spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
self.status = C.meta("")
|
||||
self.toolbar = ft.Row([
|
||||
C.primary(tr("Upload files…"), ft.Icons.UPLOAD_FILE_OUTLINED, self.upload_files),
|
||||
C.secondary(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, self.upload_folder),
|
||||
C.ghost(tr("New folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda e: self.new_folder()),
|
||||
C.primary(tr("Upload files"), ft.Icons.UPLOAD_FILE_ROUNDED, self.upload_files),
|
||||
C.secondary(tr("Upload folder"), ft.Icons.DRIVE_FOLDER_UPLOAD_ROUNDED, self.upload_folder),
|
||||
C.ghost(tr("New folder"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, lambda e: self.new_folder()),
|
||||
C.icon_btn(ft.Icons.REFRESH_ROUNDED, tr("Refresh"), lambda e: self.load()),
|
||||
], spacing=T.S2, wrap=True, run_spacing=T.S2)
|
||||
self.hidden_switch = C.switch(tr("Show hidden files"), wrap=False, value=False, on_change=self._toggle_hidden)
|
||||
self.selected: set[str] = set() # paths of checked entries in the current folder
|
||||
self.checks: dict[str, ft.Checkbox] = {}
|
||||
self.rows: dict[str, tuple] = {} # path → (row, quiet checkbox, quiet actions)
|
||||
self.hot: set[str] = set() # rows under the mouse or with keyboard focus inside
|
||||
self._select_mode = False
|
||||
self.select_all = ft.Checkbox(value=False, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
tooltip=tr("Select all"), on_change=self._toggle_all)
|
||||
self.sel_label = C.body("", T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.sel_bar = ft.Container(ft.Row([
|
||||
self.sel_label, ft.Container(expand=True),
|
||||
C.secondary(tr("Download…"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete…"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self._delete_selected()),
|
||||
C.secondary(tr("Download"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self._delete_selected()),
|
||||
C.ghost(tr("Clear"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection()),
|
||||
], spacing=T.S2), padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.ACCENT_SOFT, visible=False)
|
||||
@@ -178,14 +175,12 @@ class FilesView:
|
||||
|
||||
def _location_row(self, loc: dict, icon: str, sub: str = "") -> ft.Control:
|
||||
selected = self.loc is not None and self.loc["id"] == loc["id"]
|
||||
ic = ft.Icon(icon, size=T.px(18))
|
||||
label = C.body(loc["label"], weight=ft.FontWeight.W_500)
|
||||
box = ft.Container(
|
||||
ft.Row([ic, ft.Column([label, *([C.meta(sub)] if sub else [])], spacing=0, expand=True)], spacing=T.S2),
|
||||
return ft.Container(
|
||||
ft.Row([ft.Icon(icon, size=T.px(18), color=T.ACCENT if selected else T.TEXT_2),
|
||||
ft.Column([C.body(loc["label"], T.TEXT if selected else T.TEXT_2, weight=ft.FontWeight.W_500),
|
||||
*([C.meta(sub)] if sub else [])], spacing=0, expand=True)], spacing=T.S2),
|
||||
padding=ft.Padding(T.S2, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=True,
|
||||
on_click=lambda e, loc_=loc: self.open_location(loc_))
|
||||
C.selected_style(box, selected, ic, label) # like the sidebar: the portal fade with a white icon
|
||||
return box
|
||||
bgcolor=T.ACCENT_SOFT if selected else None, on_click=lambda e, loc_=loc: self.open_location(loc_))
|
||||
|
||||
def _render_locations(self) -> None:
|
||||
rows = [self._location_row(loc_, SHARED_ICONS.get(loc_["id"], ft.Icons.FOLDER_OUTLINED), loc_["android"])
|
||||
@@ -284,15 +279,16 @@ class FilesView:
|
||||
for i, (label, p) in enumerate(crumbs(loc["path"], self.path, loc["label"])):
|
||||
if i:
|
||||
self.crumb_row.controls.append(ft.Icon(ft.Icons.CHEVRON_RIGHT_ROUNDED, size=T.px(16), color=T.TEXT_3))
|
||||
self.crumb_row.controls.append(C.ghost(label, on_click=lambda e, p=p: self.cd(p),
|
||||
color=T.TEXT if p == self.path else T.TEXT_2))
|
||||
self.crumb_row.controls.append(ft.TextButton(label, on_click=lambda e, p=p: self.cd(p),
|
||||
style=ft.ButtonStyle(color=T.TEXT if p == self.path
|
||||
else T.TEXT_2)))
|
||||
android = loc.get("android")
|
||||
rel = posixpath.relpath(self.path, loc["path"]) if self.path != loc["path"] else ""
|
||||
self.where.value = (tr("Games see this folder as {value}")
|
||||
.format(value=posixpath.join(android, rel) if rel else android)
|
||||
if android else self.path)
|
||||
rows = []
|
||||
self.checks, self.rows, self.hot = {}, {}, set()
|
||||
self.checks = {}
|
||||
if self.path != loc["path"]:
|
||||
rows.append(self._row(None))
|
||||
rows += [self._row(e) for e in self.entries]
|
||||
@@ -315,34 +311,27 @@ class FilesView:
|
||||
padding=ft.Padding(T.S3, T.px(8), T.S3, T.px(8)), border_radius=T.RADIUS_SM, ink=True,
|
||||
on_click=lambda ev: self.cd(posixpath.dirname(self.path)))
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(e.mtime)) if e.mtime else ""
|
||||
info = ((tr("folder") if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "")
|
||||
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…"),
|
||||
actions = [C.icon_btn(ft.Icons.DOWNLOAD_ROUNDED, tr("Download to this PC"),
|
||||
lambda ev, x=e: self._download_later([x]))]
|
||||
if not self._protected(e):
|
||||
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, tr("Rename…"),
|
||||
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_ROUNDED, tr("Rename"),
|
||||
lambda ev, x=e: self.rename(x)),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete…"), lambda ev, x=e: self.delete([x]))]
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete"), lambda ev, x=e: self.delete([x]))]
|
||||
check = ft.Checkbox(value=e.path in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
on_change=lambda ev, p=e.path: self._toggle(p, ev.control.value),
|
||||
disabled=self._protected(e))
|
||||
self.checks[e.path] = check
|
||||
# quiet rows: the checkbox and the actions show on hover, keyboard focus or when something is selected
|
||||
for c in (check, *actions):
|
||||
c.on_focus = lambda ev, p=e.path: self._focus(p, True)
|
||||
c.on_blur = lambda ev, p=e.path: self._focus(p, False)
|
||||
quiet_check, quiet_actions = C.quiet(check), C.quiet(*actions)
|
||||
row = ft.Container(
|
||||
ft.Row([quiet_check,
|
||||
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),
|
||||
quiet_actions], spacing=T.S3),
|
||||
*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_hover=lambda ev, p=e.path: self._hover(p, ev.data in (True, "true")))
|
||||
self.rows[e.path] = (row, quiet_check, quiet_actions)
|
||||
self._paint_row(e.path)
|
||||
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))
|
||||
|
||||
@@ -351,8 +340,8 @@ class FilesView:
|
||||
"""[(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_OUTLINED, self.upload_files),
|
||||
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, self.upload_folder),
|
||||
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())]
|
||||
@@ -371,7 +360,7 @@ class FilesView:
|
||||
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_OUTLINED, lambda ev: self.rename(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))))
|
||||
@@ -419,35 +408,8 @@ class FilesView:
|
||||
if check is not None and check.value != on:
|
||||
check.value = on
|
||||
C.update(check)
|
||||
if self._paint_row(path):
|
||||
C.update(self.rows[path][0])
|
||||
self._update_selection()
|
||||
|
||||
# ---------------------------------------------------------------- quiet rows
|
||||
def _hover(self, path: str, inside: bool) -> None:
|
||||
self.drag.hover(path, inside)
|
||||
self._set_hot(path, inside)
|
||||
|
||||
def _focus(self, path: str, on: bool) -> None:
|
||||
self._set_hot(path, on)
|
||||
|
||||
def _set_hot(self, path: str, on: bool) -> None:
|
||||
(self.hot.add if on else self.hot.discard)(path)
|
||||
if path in self.rows and self._paint_row(path):
|
||||
C.update(self.rows[path][0])
|
||||
|
||||
def _paint_row(self, path: str) -> bool:
|
||||
"""Properties only: the selected look and whether the checkbox/actions show. True when something changed."""
|
||||
row, quiet_check, quiet_actions = self.rows[path]
|
||||
on = path in self.selected
|
||||
before = (row.bgcolor, row.gradient is not None)
|
||||
C.selected_style(row, on)
|
||||
changed = before != (row.bgcolor, row.gradient is not None)
|
||||
show = on or path in self.hot or bool(self.selected)
|
||||
changed |= C.reveal(quiet_check, show)
|
||||
changed |= C.reveal(quiet_actions, on or path in self.hot)
|
||||
return changed
|
||||
|
||||
def _toggle_all(self, e) -> None:
|
||||
self.selected = {x.path for x in self.entries if self._selectable(x)} if e.control.value else set()
|
||||
self._render_listing()
|
||||
@@ -473,11 +435,6 @@ class FilesView:
|
||||
self.sel_label.value = (tr("{n} selected").format(n=n)
|
||||
+ (tr(" · {human} in files").format(human=human(size)) if size else ""))
|
||||
self.sel_bar.visible = bool(n)
|
||||
if bool(n) != self._select_mode: # select mode starts/ends: every row's checkbox shows/hides
|
||||
self._select_mode = bool(n)
|
||||
changed = [path for path in self.rows if self._paint_row(path)]
|
||||
if render and changed:
|
||||
C.update(self.listing)
|
||||
self.select_all.value = bool(self.entries) and n == len([x for x in self.entries if self._selectable(x)])
|
||||
if render:
|
||||
for c in (self.sel_bar, self.select_all):
|
||||
@@ -552,7 +509,7 @@ class FilesView:
|
||||
|
||||
def delete(self, items: list) -> None:
|
||||
names = ", ".join(x.name for x in items[:3]) + (" …" if len(items) > 3 else "")
|
||||
what = tr("folder and everything in it") if any(x.is_dir for x in items) else tr("file")
|
||||
what = tr("folder and everything in it") if any(x.is_dir for x in items) else "file"
|
||||
C.confirm(self.app.page, tr("Delete {names}?").format(names=names),
|
||||
tr("This deletes the {what} on the Frame. It can't be undone.").format(what=what),
|
||||
tr("Delete"), lambda: (self.selected.difference_update(x.path for x in items),
|
||||
@@ -574,12 +531,14 @@ class FilesView:
|
||||
|
||||
def _ask_name(self, heading: str, label: str, value: str, ok: str, on_ok) -> None:
|
||||
page = self.app.page
|
||||
field = C.field(label=label, value=value, autofocus=True, width=T.DIALOG_S)
|
||||
field = ft.TextField(label=label, value=value, autofocus=True, width=T.px(380))
|
||||
|
||||
def go(e=None):
|
||||
page.pop_dialog()
|
||||
if (field.value or "").strip():
|
||||
on_ok(field.value.strip())
|
||||
field.on_submit = go
|
||||
page.show_dialog(C.dialog(heading, field, [C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.primary(ok, on_click=go)], size="s"))
|
||||
page.show_dialog(ft.AlertDialog(title=ft.Text(heading, color=T.TEXT, weight=ft.FontWeight.W_600),
|
||||
bgcolor=T.SURFACE_2, content=field,
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.primary(ok, on_click=go)]))
|
||||
@@ -96,11 +96,14 @@ def human(n: int) -> str:
|
||||
|
||||
def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
|
||||
status = C.meta(tr("Reading the file list from the Frame…"))
|
||||
ring = C.spinner()
|
||||
ring = ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT)
|
||||
body = ft.Column([ft.Row([ring, status],
|
||||
spacing=T.S2)], spacing=T.S3, expand=True)
|
||||
dialog = C.dialog(tr("Files on the Frame — {title}").format(title=title), body,
|
||||
[C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())], size="l", height=T.px(560))
|
||||
dialog = ft.AlertDialog(
|
||||
title=ft.Text(tr("Files on the Frame — {title}").format(title=title), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(body, width=T.px(760), height=T.px(560)),
|
||||
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
|
||||
actions=[C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())])
|
||||
app.page.show_dialog(dialog)
|
||||
|
||||
def load():
|
||||
@@ -110,15 +113,19 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
|
||||
trees = [(r, build_tree(r["name"], r["files"])) for r in result["roots"]]
|
||||
expanded: set[str] = set()
|
||||
rows = ft.ListView(spacing=0, expand=True)
|
||||
search = C.search(hint_text=tr("Filter files (for example .pak, Binaries)"), expand=True)
|
||||
search = ft.TextField(hint_text=tr("Filter files (e.g. .pak, Binaries)"), dense=True, expand=True,
|
||||
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)),
|
||||
text_size=T.T_BODY, prefix_icon=ft.Icons.SEARCH_ROUNDED)
|
||||
|
||||
def row(depth: int, node: Node | int, root_key: str) -> ft.Control:
|
||||
pad = ft.Padding(T.px(8 + depth * 18), T.px(3), T.px(8), T.px(3))
|
||||
pad = ft.Padding(8 + depth * 18, T.px(3), T.px(8), T.px(3))
|
||||
if isinstance(node, int):
|
||||
return ft.Container(C.meta(tr("… {node} more (use the filter to find them)").format(node=node)),
|
||||
padding=pad)
|
||||
key = f"{root_key}\0{node.path}"
|
||||
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_OUTLINED) if node.is_dir \
|
||||
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_ROUNDED) if node.is_dir \
|
||||
else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||
chevron = ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED if key in expanded else ft.Icons.CHEVRON_RIGHT_ROUNDED,
|
||||
size=T.px(16), color=T.TEXT_3) if node.is_dir else ft.Container(width=T.px(16))
|
||||
@@ -131,7 +138,7 @@ def show_files_dialog(app: FramePortApp, package: str, title: str) -> None:
|
||||
ft.Container(C.meta(note, T.TEXT_3, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
tooltip=note or None, expand=True),
|
||||
C.meta(info),
|
||||
], spacing=T.px(6)), padding=pad, border_radius=T.RADIUS_XS, ink=node.is_dir,
|
||||
], spacing=T.px(6)), padding=pad, border_radius=T.px(6), ink=node.is_dir,
|
||||
on_click=(lambda e, k=key: toggle(k)) if node.is_dir else None)
|
||||
|
||||
def render():
|
||||
|
||||
@@ -7,9 +7,9 @@ from typing import TYPE_CHECKING
|
||||
import flet as ft
|
||||
|
||||
from ...core import library
|
||||
from ...i18n import tr
|
||||
from ...errors import explain
|
||||
from ...i18n import tr, tr_n
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
from ..battery import label as battery_label
|
||||
from ..help import HELP
|
||||
@@ -28,11 +28,11 @@ class FrameView:
|
||||
t = app.target
|
||||
free = (info.get("free_bytes") or 0) / 2**30
|
||||
return C.card(ft.Row([
|
||||
ft.Container(C.as_icon(G.FRAME, T.px(34), T.ACCENT), width=T.px(72),
|
||||
height=T.px(72), border_radius=T.RADIUS, bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(34), color=T.ACCENT), width=T.px(72),
|
||||
height=T.px(72), border_radius=T.px(18), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
|
||||
ft.Column([
|
||||
ft.Row([C.title(info.get("hostname") or t.label, T.T_DISPLAY),
|
||||
C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE)], spacing=T.S3),
|
||||
ft.Row([C.title(info.get("hostname") or t.label, 22), C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE)],
|
||||
spacing=T.S3),
|
||||
C.body(tr("{user}@{host} · {get} {get2} (build {get3})")
|
||||
.format(user=t.target.user, host=t.target.host, get=info.get('os'),
|
||||
get2=info.get('os_version'), get3=info.get('build_id'))),
|
||||
@@ -43,78 +43,103 @@ class FrameView:
|
||||
], spacing=T.px(4), expand=True),
|
||||
ft.Column([
|
||||
C.secondary(tr("Refresh"), ft.Icons.REFRESH_ROUNDED, lambda e: app.refresh_frame()),
|
||||
C.secondary(tr("Type on Frame"), G.KEYS, lambda e: app.type_on_frame(),
|
||||
tooltip=tr("Use this PC's keyboard on the Frame")),
|
||||
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: self.switch_frame()),
|
||||
C.ghost(tr("Disconnect"), ft.Icons.LINK_OFF_ROUNDED, lambda e: app.disconnect()),
|
||||
C.secondary(tr("Type on Frame"), ft.Icons.KEYBOARD_ROUNDED, lambda e: app.type_on_frame(),
|
||||
tooltip=tr("Use this computer's keyboard on the Frame")),
|
||||
C.ghost(tr("Switch Frame…"), ft.Icons.SWAP_HORIZ_ROUNDED, lambda e: app.disconnect()),
|
||||
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.END),
|
||||
], spacing=T.S4), padding=T.S5)
|
||||
|
||||
def switch_frame(self) -> None:
|
||||
"""A small picker of the remembered Frames (click one to connect), or "Find another Frame…" = the setup and
|
||||
discovery page."""
|
||||
from ...frame.connection import saved_targets
|
||||
|
||||
app = self.app
|
||||
page = app.page
|
||||
current = getattr(getattr(app.target, "target", None), "host", None)
|
||||
|
||||
def pick(target):
|
||||
page.pop_dialog()
|
||||
app.connect(target)
|
||||
|
||||
def find(e):
|
||||
page.pop_dialog()
|
||||
app.disconnect()
|
||||
rows = []
|
||||
for f in saved_targets():
|
||||
here = f.host == current
|
||||
rows.append(ft.Container(ft.Row([
|
||||
C.as_icon(G.FRAME, T.px(22), T.ACCENT),
|
||||
ft.Column([C.body(f.label, T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(f"{f.user}@{f.host}")], spacing=T.px(2), expand=True),
|
||||
C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE) if here else
|
||||
C.secondary(tr("Connect"), on_click=lambda e, f=f: pick(f)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_3,
|
||||
border_radius=T.RADIUS_SM))
|
||||
if not rows:
|
||||
rows = [C.body(tr("No other Frames remembered yet."))]
|
||||
page.show_dialog(C.dialog(
|
||||
tr("Switch Frame"), ft.Column(rows, spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO),
|
||||
[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.secondary(tr("Find another Frame…"), ft.Icons.SEARCH_ROUNDED, find)], size="s"))
|
||||
|
||||
def readiness(self, info: dict) -> ft.Control:
|
||||
app = self.app
|
||||
items = []
|
||||
rows = []
|
||||
lepton = info.get("lepton")
|
||||
items.append(C.Check(bool(lepton), tr("Quest games (Lepton)"),
|
||||
tr("Ready") if lepton
|
||||
else tr("Valve's Android runtime isn't installed (needs Developer Mode)"),
|
||||
"lepton", (tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, app.install_lepton)))
|
||||
rows.append(C.status_row(bool(lepton), tr("Quest games (Lepton)"),
|
||||
tr("Ready") if lepton
|
||||
else tr("Valve's Android runtime isn't installed (needs Developer Mode)"),
|
||||
None if lepton else C.secondary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.install_lepton()),
|
||||
help="lepton"))
|
||||
pr = info.get("proton") or {}
|
||||
ready, sug = pr.get("ready"), pr.get("suggested")
|
||||
if pr:
|
||||
items.append(C.Check(
|
||||
rows.append(C.status_row(
|
||||
bool(ready), tr("PC VR games (Proton)"),
|
||||
tr("{display_name} installed").format(display_name=ready['display_name']) if ready else
|
||||
(tr("{display_name} can be installed (about 1 GiB; Steam restarts once)")
|
||||
.format(display_name=sug['display_name']) if sug else
|
||||
tr("Not offered by Steam on this Frame yet")),
|
||||
"proton", (tr("Install…"), ft.Icons.DOWNLOAD_ROUNDED, app.install_proton) if sug else None,
|
||||
C.secondary(tr("Test"), G.TEST, lambda e: app.test_proton()) if ready else None))
|
||||
C.secondary(tr("Test"), ft.Icons.SCIENCE_OUTLINED, lambda e: app.test_proton()) if ready else
|
||||
C.primary(tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.install_proton(), disabled=not sug),
|
||||
help="proton"))
|
||||
xr = (pr.get("openxr") or {}).get("name")
|
||||
items.append(C.Check(bool(xr), tr("OpenXR runtime"), xr or tr("None found"), "openxr"))
|
||||
rows.append(C.status_row(bool(xr), tr("OpenXR runtime"), xr or tr("None found"), help="openxr"))
|
||||
keys = info.get("kernel_keys") or {}
|
||||
if keys.get("max_keys"):
|
||||
high = keys["keys"] > keys["max_keys"] * 0.75
|
||||
items.append(C.Check(not high if keys["keys"] < keys["max_keys"] else False, tr("Game launch capacity"),
|
||||
tr("{used}/{max} kernel keys used").format(used=keys["keys"], max=keys["max_keys"])
|
||||
+ (tr(" · restart the Frame soon to reset it") if high else ""),
|
||||
"kernel_keys",
|
||||
(tr("Restart the Frame…"), ft.Icons.RESTART_ALT_ROUNDED,
|
||||
lambda: app.frame_power("restart"))))
|
||||
return C.section(tr("Ready to play"), C.card(C.checklist(items), padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
|
||||
rows.append(C.status_row(not high if keys["keys"] < keys["max_keys"] else False, tr("Game launch capacity"),
|
||||
f"{keys['keys']}/{keys['max_keys']} kernel keys used"
|
||||
+ (tr(" · restart the Frame soon to reset it") if high else ""),
|
||||
help="kernel_keys"))
|
||||
return C.section(tr("Ready to play"), C.card(ft.Column(rows, spacing=0),
|
||||
padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)))
|
||||
|
||||
def storage(self, info: dict) -> ft.Control:
|
||||
"""Drives games can go on (GitHub #90) and where new games go. The list comes from the Frame in the
|
||||
background."""
|
||||
from ...install import drives
|
||||
|
||||
app = self.app
|
||||
current = drives.install_dest()
|
||||
pick = ft.Dropdown(label=tr("Install new games to"), value=current or drives.INTERNAL, width=T.px(380),
|
||||
dense=True, border_color=T.BORDER, text_size=T.T_BODY, disabled=True,
|
||||
options=[ft.DropdownOption(key=drives.INTERNAL, text=tr("Internal storage"))])
|
||||
rows = ft.Column([C.meta(tr("Looking for drives…"))], spacing=T.px(6))
|
||||
|
||||
def choose(e):
|
||||
value = e.control.value or drives.INTERNAL
|
||||
drives.set_install_dest(None if value == drives.INTERNAL else value)
|
||||
label = next((o.text for o in pick.options if o.key == value), value)
|
||||
app.toast(tr("New games go to {label}").format(label=label))
|
||||
|
||||
pick.on_select = choose
|
||||
|
||||
def name(d: dict) -> str:
|
||||
return tr("Internal storage") if d["internal"] else d["label"]
|
||||
|
||||
def fill():
|
||||
try:
|
||||
found = app.target.drives() if app.target else []
|
||||
except Exception as exc: # noqa: BLE001 - shown in place of the list
|
||||
rows.controls = [C.meta(tr("Couldn't list the Frame's drives: {error}").format(error=explain(exc)))]
|
||||
C.update(rows)
|
||||
return
|
||||
opts, lines = [], []
|
||||
for d in found:
|
||||
free = drives.free_text(d)
|
||||
if d.get("usable"):
|
||||
opts.append(ft.DropdownOption(key=drives.INTERNAL if d["internal"] else d["install_dir"],
|
||||
text=" · ".join(filter(None, [name(d), free]))))
|
||||
state = tr_n("{n} game", "{n} games", d.get("games", 0)) if d.get("usable") else \
|
||||
tr("can't hold games: {reason}").format(reason=d.get("reason") or "")
|
||||
lines.append(ft.Row([
|
||||
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_ROUNDED,
|
||||
size=T.px(18), color=T.TEXT_2 if d.get("usable") else T.TEXT_3),
|
||||
C.body(name(d), T.TEXT if d.get("usable") else T.TEXT_3, expand=True),
|
||||
C.meta(" · ".join(filter(None, [d.get("fstype"), free, state]))),
|
||||
], spacing=T.S2))
|
||||
if current and not any(o.key == current for o in opts): # the chosen card isn't inserted now
|
||||
label = current.rstrip("/").rsplit("/", 2)[-2] if current.rstrip("/").endswith("/FramePort") \
|
||||
else current
|
||||
opts.append(ft.DropdownOption(key=current, text=tr("{label} (not inserted)").format(label=label)))
|
||||
pick.options = opts or pick.options
|
||||
pick.disabled = len(opts) < 2
|
||||
rows.controls = lines or [C.meta(tr("Only internal storage"))]
|
||||
C.update(pick)
|
||||
C.update(rows)
|
||||
|
||||
app.run_bg(fill)
|
||||
return C.section(tr("Storage"), C.card(ft.Column([pick, rows], spacing=T.S3), padding=T.S4),
|
||||
help="install_drive")
|
||||
|
||||
def installed(self, info: dict) -> ft.Control:
|
||||
"""The list fills in the background (icon thumbnails may need creating the first time)."""
|
||||
@@ -127,7 +152,7 @@ class FrameView:
|
||||
send = C.ghost(tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda e: self.app.go("files"),
|
||||
tooltip=C.tip(HELP["files"]))
|
||||
return C.section(tr("Installed games ({len})").format(len=len(items)), body,
|
||||
action=ft.Row([send, C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED,
|
||||
action=ft.Row([send, C.ghost(tr("Free up space"), ft.Icons.CLEANING_SERVICES_ROUNDED,
|
||||
lambda e: self.app.cleanup_frame(),
|
||||
tooltip=C.tip(HELP["free_space"]))], spacing=T.S2, tight=True)
|
||||
if items else send)
|
||||
@@ -139,6 +164,7 @@ class FrameView:
|
||||
return spacer
|
||||
|
||||
from ...artwork import thumbs
|
||||
from ...install.drives import game_drive as drive_of
|
||||
from .files_dialog import show_files_dialog
|
||||
from .library import display_title, twins
|
||||
|
||||
@@ -155,10 +181,12 @@ class FrameView:
|
||||
sub = (tr("PC VR · Proton") + (tr(" + Revive") if d.get("revive") else "")) if pcvr else \
|
||||
(C.platform(games[pkg])[0] if in_lib else tr("Linux") if d.get("kind") == "linux" else tr("Quest"))
|
||||
sub += (tr(" · {value:.1f} GiB").format(value=size / 2**30) if size >= 2**30
|
||||
else tr(" · {value:.0f} MiB").format(value=size / 2**20))
|
||||
else tr(" · {value:.0f} MiB").format(value=size / 2**20)) if size or not d.get("drive_missing") \
|
||||
else ""
|
||||
sub += C.drive_note(drive_of(d))
|
||||
title = display_title(games[pkg], tw) if in_lib else (d.get("title") or pkg)
|
||||
rows.append(ft.Container(ft.Row([
|
||||
C.art_fill(art, radius=T.RADIUS_SM, width=T.px(44), height=T.px(44)),
|
||||
C.art_fill(art, radius=T.px(8), width=T.px(44), height=T.px(44)),
|
||||
ft.Column([C.body(title, T.TEXT, weight=ft.FontWeight.W_500), C.meta(sub)],
|
||||
spacing=T.px(2), expand=True),
|
||||
# same slots on every row: games without settings (PC VR, 2D apps) get an invisible spacer
|
||||
@@ -169,7 +197,7 @@ class FrameView:
|
||||
lambda e, p=pkg, t=title: show_files_dialog(app, p, t)),
|
||||
C.icon_btn(ft.Icons.PLAY_ARROW_ROUNDED, tr("Play: starts the game through the Frame's Steam (put the "
|
||||
"headset on)"), lambda e, p=pkg: app.play(p, "frame"), color=T.ACCENT),
|
||||
C.icon_btn(G.TEST, C.tip(tr("Launch test. ") + HELP["launch_test"]),
|
||||
C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip(tr("Launch test. ") + HELP["launch_test"]),
|
||||
lambda e, p=pkg: app.test_game(p, "frame")),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip(tr("Uninstall. ") + HELP["uninstall"]),
|
||||
lambda e, p=pkg: app.uninstall(p, "frame")),
|
||||
@@ -185,7 +213,7 @@ class FrameView:
|
||||
|
||||
app = self.app
|
||||
found = ft.Column(spacing=T.S2)
|
||||
searching = ft.Row([C.spinner(),
|
||||
searching = ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
|
||||
C.meta(tr("Looking for Frames on your network…"))], spacing=T.S2)
|
||||
|
||||
def discover():
|
||||
@@ -196,20 +224,20 @@ class FrameView:
|
||||
res = browse(4)
|
||||
items = []
|
||||
for f in res:
|
||||
label = {"devkit": tr("SteamOS · Developer Mode"), "frameport": tr("Set up for FramePort"),
|
||||
"saved": tr("Remembered"), "scan": tr("SSH found by network scan")}.get(f.source, f.source)
|
||||
label = {"devkit": "SteamOS · Developer Mode", "frameport": "Set up for FramePort",
|
||||
"saved": "Remembered", "scan": "SSH found by network scan"}.get(f.source, f.source)
|
||||
items.append(ft.Container(ft.Row([
|
||||
C.as_icon(G.FRAME if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED, T.px(24), T.ACCENT),
|
||||
ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED,
|
||||
color=T.ACCENT),
|
||||
ft.Column([C.body(f.name if f.source != "scan" else f.host, T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(f"{f.host} · {label}")], spacing=T.px(2), expand=True),
|
||||
C.primary(tr("Connect"), on_click=lambda e, f=f: app.connect(parse_target(f"{f.user}@{f.host}"),
|
||||
devkit=f.source == "devkit")),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
|
||||
border_radius=T.RADIUS_SM))
|
||||
found.controls = items or [ft.Row([C.body(tr("No Frames found. New Frame? Use first-time setup (step "
|
||||
"1). Otherwise turn on Developer Mode and keep the Frame on "
|
||||
"the same network."), expand=True),
|
||||
C.help_icon("developer_mode")], spacing=T.px(4))]
|
||||
found.controls = items or [C.body(tr("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."))]
|
||||
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
|
||||
C.update(found)
|
||||
|
||||
@@ -239,7 +267,7 @@ class FrameView:
|
||||
if server.requests or not server.running or app.pairing is not server:
|
||||
return
|
||||
server.hint = firewall_hint(server.port) or tr(
|
||||
"Check that the Frame and this PC are on the same network.")
|
||||
"Check that the Frame and this computer are on the same network.")
|
||||
if app.route[0] == "frame" and app.pairing is server:
|
||||
show_command()
|
||||
app.run_bg(check_network)
|
||||
@@ -258,12 +286,12 @@ class FrameView:
|
||||
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 PC with a USB cable (a data cable, not a charge-only "
|
||||
tr("Connect the Frame's USB-C port to this computer with a USB cable (a data cable, not a charge-only "
|
||||
"one)."),
|
||||
]
|
||||
dlg = C.dialog(
|
||||
tr("Turn on Developer Mode first"),
|
||||
ft.Column([
|
||||
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),
|
||||
@@ -272,7 +300,8 @@ class FrameView:
|
||||
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), size="s",
|
||||
], 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)
|
||||
@@ -284,11 +313,11 @@ class FrameView:
|
||||
from ...frame import usb
|
||||
from ...frame.connection import FrameTarget, server_key
|
||||
|
||||
status = ft.Row([C.spinner(),
|
||||
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 "
|
||||
"PC)."), T.TEXT),
|
||||
"computer)."), T.TEXT),
|
||||
status,
|
||||
]
|
||||
C.update(pair_box)
|
||||
@@ -327,7 +356,7 @@ class FrameView:
|
||||
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 PC over the USB cable):") if over_usb else
|
||||
"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),
|
||||
@@ -335,11 +364,12 @@ class FrameView:
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy"), lambda e: app.copy(line))]),
|
||||
padding=ft.Padding(T.S3, T.S2, T.S2, T.S2), bgcolor=T.BG, border_radius=T.RADIUS_SM,
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
ft.Row([C.spinner(),
|
||||
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 your Frame… (code {code})").format(code=app.pairing.code))],
|
||||
spacing=T.S2),
|
||||
ft.Row([C.meta(tr("You only do this once: FramePort connects by itself when the setup has finished."),
|
||||
expand=True), C.help_icon("first_time_setup")], spacing=T.px(4)),
|
||||
C.meta(tr("It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on "
|
||||
"Developer Mode closes the desktop; the setup finishes on its own and FramePort connects "
|
||||
"by itself. You only do this once.")),
|
||||
]
|
||||
hint = getattr(app.pairing, "hint", "")
|
||||
if hint and not app.pairing.requests: # nothing reached us yet: what may block it, and the other way in
|
||||
@@ -347,9 +377,9 @@ class FrameView:
|
||||
C.body(tr("Nothing has reached FramePort from the Frame yet (curl says “timed out”)?"), T.TEXT,
|
||||
weight=ft.FontWeight.W_600),
|
||||
C.body(hint, T.TEXT),
|
||||
ft.Row([C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System → Enable Developer "
|
||||
"Mode) and pair it under step 2: that needs no connection into this PC."),
|
||||
expand=True), C.help_icon("developer_mode")], spacing=T.px(4)),
|
||||
C.meta(tr("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.")),
|
||||
], spacing=T.S2), "warn"))
|
||||
if update:
|
||||
C.update(pair_box) # (a redraw of the page may have replaced it)
|
||||
@@ -357,9 +387,12 @@ class FrameView:
|
||||
if app.pairing and app.pairing.running:
|
||||
show_command(update=False)
|
||||
|
||||
addr = C.field(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320))
|
||||
pw = C.field(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
|
||||
width=T.px(240), tooltip=C.tip(HELP["password"]))
|
||||
style = dict(dense=True, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(10), T.px(12), T.px(10)),
|
||||
text_size=T.T_BODY)
|
||||
addr = ft.TextField(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320), **style)
|
||||
pw = ft.TextField(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
|
||||
width=T.px(240), tooltip=C.tip(HELP["password"]), **style)
|
||||
saved = saved_targets()
|
||||
offline = None
|
||||
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
|
||||
@@ -374,8 +407,9 @@ class FrameView:
|
||||
C.secondary(tr("Try again"), ft.Icons.REFRESH_ROUNDED,
|
||||
lambda e: app.connect(saved[0]))]), "warn")
|
||||
elif app.frame_state == "connecting":
|
||||
connecting = tr("Connecting to {value}…").format(value=saved[0].label if saved else tr("your Frame"))
|
||||
offline = C.callout(ft.Row([C.spinner(),
|
||||
connecting = tr("Connecting to {value}…").format(value=saved[0].label if saved else 'your Frame')
|
||||
offline = C.callout(ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ACCENT),
|
||||
C.body(connecting, T.TEXT)],
|
||||
spacing=T.S3), "info")
|
||||
import time as _time
|
||||
@@ -419,5 +453,5 @@ class FrameView:
|
||||
return self.wizard()
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Steam Frame"), tr("Your headset, what it's ready for and what's installed")),
|
||||
self.device_card(info), self.readiness(info), self.installed(info),
|
||||
self.device_card(info), self.readiness(info), self.storage(info), self.installed(info),
|
||||
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
@@ -9,13 +9,13 @@ from typing import TYPE_CHECKING
|
||||
import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...analysis.detect import android_version, too_new_android
|
||||
from ...artwork import thumbs
|
||||
from ...core import library
|
||||
from ...i18n import tr, tr_n
|
||||
from ...patches import base
|
||||
from ...recommend import catalog, engine
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
from ..help import HELP
|
||||
|
||||
@@ -93,15 +93,13 @@ class GameView:
|
||||
if self.rift and pkg in app.pc_installs():
|
||||
chips.append(C.install_badge("on_pc"))
|
||||
actions = self.actions()
|
||||
self._wash = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS) # cover tint (_tint)
|
||||
self._hero = ft.Container(
|
||||
return ft.Container(
|
||||
ft.Stack([
|
||||
C.art_fill(art, radius=T.RADIUS, hero=True, left=0, right=0, top=0, bottom=0,
|
||||
C.art_fill(art, radius=T.px(16), hero=True, left=0, right=0, top=0, bottom=0,
|
||||
placeholder_icon=C.platform_icon(g)),
|
||||
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS, gradient=ft.LinearGradient(
|
||||
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.px(16), gradient=ft.LinearGradient(
|
||||
begin=ft.Alignment.CENTER_LEFT, end=ft.Alignment.CENTER_RIGHT,
|
||||
colors=[T.soft(T.BG, 0.97), T.soft(T.BG, 0.80), T.soft(T.BG, 0.25)], stops=[0.0, 0.45, 1.0])),
|
||||
self._wash,
|
||||
ft.Container(ft.Row([
|
||||
ft.Column([
|
||||
C.ghost(tr("Library"), ft.Icons.ARROW_BACK_ROUNDED, lambda e: app.go("library")),
|
||||
@@ -115,46 +113,24 @@ class GameView:
|
||||
], spacing=T.S2, expand=True),
|
||||
]), padding=ft.Padding(T.S4, T.S3, T.S5, T.S5), left=0, right=0, top=0, bottom=0),
|
||||
]),
|
||||
height=T.px(330), border_radius=T.RADIUS, border=ft.Border.all(1, T.BORDER))
|
||||
return self._hero
|
||||
|
||||
def _tint(self, tint: str | None) -> None:
|
||||
"""The cover's colour (thumbs.compute_tint, already clamped dark): a soft glow around and inside the hero and
|
||||
a very faint wash at the top of the page. Properties only, so it can arrive after the page is on screen."""
|
||||
if not tint:
|
||||
return
|
||||
self._hero.shadow = ft.BoxShadow(blur_radius=T.px(90), spread_radius=0, color=T.soft(tint, 0.18),
|
||||
offset=ft.Offset(0, T.px(10)))
|
||||
# from the lower left, behind the title and buttons (dark enough there for the text: ≤ 0.18 on the BG fade)
|
||||
self._wash.gradient = ft.RadialGradient(center=ft.Alignment(-0.7, 1.0), radius=1.3,
|
||||
colors=[T.soft(tint, 0.18), T.soft(tint, 0.0)])
|
||||
self._backdrop.gradient = ft.LinearGradient(begin=ft.Alignment.TOP_CENTER, end=ft.Alignment.BOTTOM_CENTER,
|
||||
colors=[T.soft(tint, 0.08), T.soft(tint, 0.0)])
|
||||
|
||||
def _fill_tint(self) -> None:
|
||||
"""Background thread: compute a missing tint, then colour this page if it is still the one shown."""
|
||||
tint = thumbs.compute_tint(self.package)
|
||||
app = self.app
|
||||
if tint and app.route[0] == "game" and app.route[1:2] == (self.package,) and app.game_view is self:
|
||||
self._tint(tint)
|
||||
C.update(self._hero, self._backdrop)
|
||||
height=T.px(330), border_radius=T.px(16), border=ft.Border.all(1, T.BORDER))
|
||||
|
||||
def actions(self) -> ft.Control:
|
||||
app, g, pkg = self.app, self.g, self.package
|
||||
job = app.jobs.busy_with(pkg)
|
||||
if job:
|
||||
# the install as a transit: the game crossing the portal; app._on_job updates it on every tick
|
||||
transit = C.Transit()
|
||||
transit.set(job, **app.transit_info(job))
|
||||
app.hero_transit = (pkg, transit)
|
||||
pct = (f" {job.fraction:.0%}" if job.fraction is not None else "") + \
|
||||
(f" · {job.speed.split(' · ')[0]}" if job.speed else "")
|
||||
return ft.Row([
|
||||
ft.Container(transit.control, width=T.px(460), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3),
|
||||
bgcolor=T.soft(T.SURFACE, 0.92), border=ft.Border.all(1, T.BORDER),
|
||||
border_radius=T.RADIUS_SM, ink=True, tooltip=tr("Activity"),
|
||||
on_click=lambda e: app.show_activity(True)),
|
||||
ft.FilledButton(content=ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ON_ACCENT),
|
||||
ft.Text(f"{job.stage or 'Queued'}{pct}", color=T.ON_ACCENT,
|
||||
weight=ft.FontWeight.W_600)], spacing=T.px(10), tight=True),
|
||||
on_click=lambda e: app.show_activity(True),
|
||||
style=ft.ButtonStyle(bgcolor=T.ACCENT, shape=ft.RoundedRectangleBorder(radius=T.px(8)),
|
||||
padding=ft.Padding(T.px(22), T.px(18), T.px(22), T.px(18)))),
|
||||
C.ghost(tr("Cancel"), ft.Icons.CLOSE_ROUNDED, lambda e: app.jobs.cancel(job)),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.END)
|
||||
app.hero_transit = None
|
||||
], spacing=T.S2)
|
||||
buttons: list[ft.Control] = []
|
||||
for i, (label, icon, handler, disabled, tip) in enumerate(app.play_options(g) + app.install_options(g)):
|
||||
buttons.append(C.primary(label, icon, handler, disabled, tip, big=True) if i == 0 else
|
||||
@@ -165,8 +141,8 @@ class GameView:
|
||||
lambda e: app.go("files", pkg), False,
|
||||
tr("Opens this player's storage on the Frame (Files tab): upload videos "
|
||||
"into the folder it lists")))
|
||||
more = C.menu_button(C.menu_items(app.game_actions(pkg, quick=False)), icon=ft.Icons.MORE_HORIZ_ROUNDED,
|
||||
icon_color=T.TEXT_2, tooltip=tr("More actions"))
|
||||
more = ft.PopupMenuButton(icon=ft.Icons.MORE_HORIZ_ROUNDED, icon_color=T.TEXT_2, bgcolor=T.SURFACE_2,
|
||||
tooltip=tr("More actions"), items=C.menu_items(app.game_actions(pkg, quick=False)))
|
||||
return ft.Row(buttons + [more], spacing=T.S2, wrap=True)
|
||||
|
||||
# ---------------------------------------------------------------- sections
|
||||
@@ -181,10 +157,10 @@ class GameView:
|
||||
return ", ".join(tr(base.get(i).title) if i in base.REGISTRY else i for i in ids)
|
||||
changed = "; ".join(filter(None, [tr("on: {names}").format(names=names(diff[0])) if diff[0] else "",
|
||||
tr("off: {names}").format(names=names(diff[1])) if diff[1] else ""]))
|
||||
frame_line = {"installed": tr("Installed"),
|
||||
"outdated": (tr("Installed · patch settings changed since ({changed}) — update to apply")
|
||||
.format(changed=changed) if diff else tr("Installed · a newer build is ready")),
|
||||
"missing": tr("Not installed"), None: tr("Frame not connected")}[st]
|
||||
frame_line = {"installed": "Installed",
|
||||
"outdated": (f"Installed · patch settings changed since ({changed}) — update to apply" if diff
|
||||
else "Installed · a newer build is ready"),
|
||||
"missing": "Not installed", None: "Frame not connected"}[st]
|
||||
frame_ok = st in ("installed", "outdated")
|
||||
# Oculus/LibOVR Rift games need Revive, which can't run on the Frame — be honest about it
|
||||
patches = (g.get("recipe") or {}).get("patches", {})
|
||||
@@ -193,6 +169,10 @@ class GameView:
|
||||
rift_platform = self.rift and (g["analysis"].get("extra") or {}).get("platform_sdk")
|
||||
frame_color = (T.TEXT_3 if (rift_oculus or rift_platform) and not frame_ok else
|
||||
T.OK if st == "installed" else T.WARN if st == "outdated" else T.TEXT_3)
|
||||
drive = C.frame_drive(g, app.frame_info) if frame_ok else None # a microSD card (GitHub #90)
|
||||
frame_line += C.drive_note(drive)
|
||||
if drive and drive.get("missing"):
|
||||
frame_color = T.WARN
|
||||
frame_sub = (tr("Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).")
|
||||
if rift_oculus else
|
||||
tr("Needs the Oculus Platform (Meta Horizon app) for its license check, which the Frame doesn't "
|
||||
@@ -203,15 +183,15 @@ class GameView:
|
||||
else tr("Runs directly — no Revive needed") if self.rift else
|
||||
tr("Runs natively on SteamOS, started from the Frame's Steam library") if self.linux else "")
|
||||
cards.append(self.target_card(
|
||||
G.FRAME, tr("Steam Frame"), frame_line, frame_color, frame_sub,
|
||||
[C.icon_btn(G.TEST, C.tip(tr("Launch test on the Frame. ") + HELP["launch_test"]),
|
||||
ft.Icons.VIEW_IN_AR_ROUNDED, tr("Steam Frame"), frame_line, frame_color, frame_sub,
|
||||
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, C.tip(tr("Launch test on the Frame. ") + HELP["launch_test"]),
|
||||
lambda e: app.test_game(pkg, "frame"), not frame_ok),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, C.tip(tr("Uninstall from the Frame. ") + HELP["uninstall"]),
|
||||
lambda e: app.uninstall(pkg, "frame"), not frame_ok)] if frame_ok else []))
|
||||
if self.rift:
|
||||
dep = app.pc_installs().get(pkg)
|
||||
cards.append(self.target_card(
|
||||
G.PC, tr("This PC (Steam + Revive)"),
|
||||
ft.Icons.COMPUTER_ROUNDED, tr("This PC (Steam + Revive)"),
|
||||
(tr("In your Steam library · launch settings changed — update it") if C.pc_outdated(g, dep) else
|
||||
tr("In your Steam library")) if dep else tr("Not installed"),
|
||||
(T.WARN if C.pc_outdated(g, dep) else T.PC) if dep else T.TEXT_3,
|
||||
@@ -219,15 +199,15 @@ class GameView:
|
||||
.format(value=dep.get('revive_version') or '', value2=dep.get('backend') or 'openxr')
|
||||
if dep.get("revive_win") else tr("The repack's own Revive · runs the game directly")
|
||||
if dep.get("launch") == "repack" else tr("Runs the game directly")) if dep else "",
|
||||
[C.icon_btn(G.TEST, tr("Launch test on this PC"),
|
||||
[C.icon_btn(ft.Icons.SCIENCE_OUTLINED, tr("Launch test on this PC"),
|
||||
lambda e: app.test_game(pkg, "pc")),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Uninstall from this PC…"),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove from this PC's Steam library"),
|
||||
lambda e: app.uninstall(pkg, "pc"))] if dep else []))
|
||||
return C.section(tr("Where it's installed"), ft.Row(cards, spacing=T.S3), help="where")
|
||||
|
||||
def target_card(self, icon, name, line, color, sub, buttons) -> ft.Control:
|
||||
return C.card(ft.Row([
|
||||
ft.Container(C.as_icon(icon, T.px(22), color), width=T.px(44), height=T.px(44),
|
||||
ft.Container(ft.Icon(icon, color=color, size=T.px(22)), width=T.px(44), height=T.px(44),
|
||||
border_radius=T.px(10), bgcolor=T.soft(color, 0.14), alignment=ft.Alignment.CENTER),
|
||||
ft.Column([C.body(name, T.TEXT, weight=ft.FontWeight.W_600), C.body(line, color, size=T.T_META)]
|
||||
+ ([C.meta(sub)] if sub else []), spacing=T.px(2), expand=True),
|
||||
@@ -247,18 +227,18 @@ class GameView:
|
||||
"them. Look for a build of the app that includes them, then install that one."),
|
||||
T.TEXT),
|
||||
C.meta(", ".join(missing), T.TEXT_2, selectable=True)], spacing=T.px(4)), "error",
|
||||
ft.Icons.EXTENSION_OFF_OUTLINED))
|
||||
ft.Icons.EXTENSION_OFF_ROUNDED))
|
||||
if self.rift and g.get("exe_confirmed") is False:
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("FramePort picked {value} to start this game, but there are other candidates. "
|
||||
"Check it before installing.").format(value=g.get('exe', '').rsplit('/', 1)[-1]),
|
||||
T.TEXT, expand=True),
|
||||
C.secondary(tr("Check…"), ft.Icons.TERMINAL_ROUNDED, lambda e: self.app.choose_exe(pkg))]), "warn",
|
||||
C.secondary(tr("Check"), ft.Icons.TERMINAL_ROUNDED, lambda e: self.app.choose_exe(pkg))]), "warn",
|
||||
ft.Icons.HELP_OUTLINE_ROUNDED))
|
||||
if self.rift and extra.get("platform_sdk"):
|
||||
out.append(C.callout(tr("Uses the Oculus Platform SDK, which checks your Oculus license: it needs the "
|
||||
"Oculus app on this PC and may quit right after starting. You can still try it."),
|
||||
"warn"))
|
||||
out.append(C.callout(tr("Uses the Oculus Platform SDK: it checks your Oculus license. Normally that "
|
||||
"needs the Oculus app on this PC with a license you own, so it may quit right "
|
||||
"after starting on the headset. You can still try it."), "warn"))
|
||||
installed = C.install_state(g, self.app.frame_info) in ("installed", "outdated")
|
||||
if should_ask_to_share(g, installed):
|
||||
def dismiss(e):
|
||||
@@ -282,19 +262,44 @@ class GameView:
|
||||
from ... import pipeline
|
||||
|
||||
pipeline.apply_catalog_update(pkg)
|
||||
self.app.toast(tr("New recipe applied: use Update on Frame to install it"))
|
||||
self.app.toast(tr("New config applied: use Update on Frame to install it"))
|
||||
self.app.render()
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("A newer known-good recipe for this game is available (you changed this game's settings, "
|
||||
C.body(tr("A newer known-good config for this game is available (you changed this game's settings, "
|
||||
"so it wasn't applied by itself)."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Use the new recipe"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, take_update)],
|
||||
C.secondary(tr("Use the new config"), ft.Icons.AUTO_FIX_HIGH_OUTLINED, take_update)],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER), "info", ft.Icons.NEW_RELEASES_OUTLINED))
|
||||
if g.get("steam_art_stale") and installed:
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("The Frame's Steam library still shows the old artwork."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Update Steam art on Frame"), ft.Icons.IMAGE_OUTLINED,
|
||||
lambda e: self.app.update_steam_art(pkg))]), "info", ft.Icons.IMAGE_OUTLINED))
|
||||
if recipe.status == "unsupported":
|
||||
min_sdk = extra.get("min_sdk")
|
||||
too_new = not self.rift and not self.linux and too_new_android(min_sdk)
|
||||
if too_new:
|
||||
# whatever the recipe says: the manifest asks for a newer Android (a warning only, a launch test decides)
|
||||
out.append(C.callout(tr("Its manifest asks for Android {version} (API {api}); the Frame's Android is 11 "
|
||||
"(API 30). Apps like this often crash at start, but not always: try it, a "
|
||||
"launch test tells.").format(version=android_version(min_sdk), api=min_sdk),
|
||||
"warn", ft.Icons.PHONELINK_ERASE_ROUNDED))
|
||||
if pipeline.missing_obb(g):
|
||||
out.append(C.callout(tr("This game's data file (.obb) wasn't found next to the APK. Put the .obb files "
|
||||
"in a folder named {package} (or obb/) next to the APK and add the folder again; "
|
||||
"without it the game hangs at start.").format(package=pkg), "error",
|
||||
ft.Icons.FOLDER_OFF_ROUNDED))
|
||||
web = (extra.get("web_wrapper") or None) if not self.rift and not self.linux else None
|
||||
if web:
|
||||
out.append(C.callout(ft.Column([
|
||||
C.body(tr("This app looks like a website in an Android wrapper: it opens the site in Meta's "
|
||||
"browser, which the Frame doesn't have. If nothing opens, use the website in a browser "
|
||||
"instead."), T.TEXT)]
|
||||
+ ([C.meta(web["url"], T.TEXT_2, selectable=True)] if web.get("url") else []), spacing=T.px(4)),
|
||||
"warn", ft.Icons.LANGUAGE_ROUNDED))
|
||||
static = " ".join(engine.blocker_notes(library.analysis_from_dict(g["analysis"]))) \
|
||||
if (too_new or web) else ""
|
||||
if static and recipe.notes.strip() == static:
|
||||
pass # the recipe's note says the same (shown above)
|
||||
elif recipe.status == "unsupported":
|
||||
out.append(C.callout(recipe.notes or tr("This game can't run on the Steam Frame."), "error"))
|
||||
elif recipe.notes and not (self.rift and extra.get("platform_sdk")):
|
||||
out.append(C.callout(recipe.notes, "info"))
|
||||
@@ -307,8 +312,8 @@ class GameView:
|
||||
for r in counterparts(g, self.games):
|
||||
other_rift = r.get("kind") == "rift"
|
||||
links.append(C.ghost(tr("Also in your library: {value} version")
|
||||
.format(value=tr("Rift") if other_rift else tr("Quest")),
|
||||
G.PC if other_rift else G.FRAME,
|
||||
.format(value='Rift' if other_rift else 'Quest'),
|
||||
ft.Icons.COMPUTER_ROUNDED if other_rift else ft.Icons.VIEW_IN_AR_ROUNDED,
|
||||
lambda e, p=r["package"]: self.app.open_game(p), color=T.ACCENT))
|
||||
if links:
|
||||
out.append(ft.Row(links, spacing=T.S2, wrap=True))
|
||||
@@ -326,7 +331,7 @@ class GameView:
|
||||
if "details" in self.g:
|
||||
return None
|
||||
return C.section(tr("About this game"), C.card(ft.Row([
|
||||
C.spinner(),
|
||||
ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
|
||||
C.meta(tr("Looking up the store description and screenshots…"))], spacing=T.S2)))
|
||||
parts: list[ft.Control] = []
|
||||
if shots:
|
||||
@@ -357,14 +362,15 @@ class GameView:
|
||||
def more(e):
|
||||
state["open"] = not state["open"]
|
||||
body.value = text if state["open"] else short
|
||||
e.control.content = tr("Show less") if state["open"] else tr("Show more")
|
||||
e.control.text = tr("Show less") if state["open"] else tr("Show more")
|
||||
body.update()
|
||||
e.control.update()
|
||||
parts.append(C.ghost(tr("Show more"), on_click=more, color=T.ACCENT))
|
||||
parts.append(ft.TextButton(tr("Show more"), on_click=more, style=ft.ButtonStyle(color=T.ACCENT)))
|
||||
links = det.store_links(d)
|
||||
if links:
|
||||
parts.append(ft.Row([C.ghost(label, ft.Icons.OPEN_IN_NEW_ROUNDED, color=T.ACCENT, url=url)
|
||||
for label, url in links], spacing=T.S2, wrap=True))
|
||||
parts.append(ft.Row([ft.TextButton(label, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, url=url,
|
||||
style=ft.ButtonStyle(color=T.ACCENT)) for label, url in links],
|
||||
spacing=T.S2, wrap=True))
|
||||
src = ", ".join({"oculusdb": "Meta store (OculusDB)", "steam": "Steam store"}.get(s, s)
|
||||
for s in d.get("sources") or [])
|
||||
return C.section(tr("About this game"), C.card(ft.Column(parts, spacing=T.S4)),
|
||||
@@ -383,12 +389,13 @@ class GameView:
|
||||
counter.value = f"{state['i'] + 1} / {len(shots)}"
|
||||
img.update()
|
||||
counter.update()
|
||||
page.show_dialog(C.viewer(
|
||||
ft.Container(ft.Column([img, ft.Row([
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
content=ft.Container(ft.Column([img, ft.Row([
|
||||
C.icon_btn(ft.Icons.CHEVRON_LEFT_ROUNDED, tr("Previous"), lambda e: show(-1)), counter,
|
||||
C.icon_btn(ft.Icons.CHEVRON_RIGHT_ROUNDED, tr("Next"), lambda e: show(1)),
|
||||
ft.Container(expand=True), C.ghost(tr("Close"), on_click=lambda e: page.pop_dialog())])],
|
||||
spacing=T.S2, tight=True), width=T.px(1100))))
|
||||
spacing=T.S2, tight=True), width=T.px(1100)),
|
||||
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
|
||||
|
||||
def tags(self) -> ft.Control:
|
||||
from .library import all_tags, auto_tags, normalize_tag, user_tags
|
||||
@@ -413,8 +420,10 @@ class GameView:
|
||||
else:
|
||||
e.control.update()
|
||||
|
||||
field = C.field(hint_text=tr("Add a tag"), width=T.px(150), text_size=T.T_META, border_radius=T.px(20),
|
||||
content_padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), on_submit=add)
|
||||
field = ft.TextField(hint_text=tr("Add a tag"), dense=True, width=T.px(150), text_size=T.T_META,
|
||||
border_radius=T.px(20), bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT,
|
||||
content_padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), on_submit=add)
|
||||
|
||||
def fill():
|
||||
mine = user_tags(self.g)
|
||||
@@ -423,7 +432,7 @@ class GameView:
|
||||
spacing=T.px(4), tight=True),
|
||||
bgcolor=T.ACCENT_SOFT, border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(8), T.px(5)),
|
||||
on_click=lambda e, t=t: remove(t), tooltip=tr("Remove tag"), ink=True)
|
||||
on_click=lambda e, t=t: remove(t), tooltip=tr("Remove tag"))
|
||||
for t in mine]
|
||||
chips += [ft.Container(C.meta(t), border=ft.Border.all(1, T.BORDER), border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)),
|
||||
@@ -433,8 +442,7 @@ class GameView:
|
||||
suggestions = [t for t in all_tags(self.games) if t in used and t not in mine][:6]
|
||||
chips.append(field)
|
||||
chips += [ft.Container(C.meta("+ " + t, T.ACCENT), padding=ft.Padding(T.px(6), T.px(5), T.px(6), T.px(5)),
|
||||
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip=tr("Add this tag"),
|
||||
border_radius=T.px(20), ink=True)
|
||||
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip=tr("Add this tag"))
|
||||
for t in suggestions]
|
||||
row.controls = chips
|
||||
fill()
|
||||
@@ -458,12 +466,28 @@ class GameView:
|
||||
C.kv(tr("VR (OpenXR)"), tr("Yes: uses the Frame's OpenXR runtime") if vr else
|
||||
tr("No: a 2D app")),
|
||||
C.kv(tr("Source"), extra.get("source") or g.get("game_dir") or ""),
|
||||
C.kv(tr("Desktop Mode"), C.switch(tr("In the menu and on the desktop"),
|
||||
value=g.get("desktop_entry", True) is not False,
|
||||
on_change=self.set_desktop_entry), "desktop_entry"),
|
||||
]
|
||||
return C.section(tr("What FramePort will do"), C.card(ft.Column([
|
||||
ft.Row([ft.Icon(ft.Icons.TERMINAL_ROUNDED, color=T.PC, size=T.px(18)),
|
||||
C.body(lead, T.TEXT, weight=ft.FontWeight.W_500, expand=True)], spacing=T.S2),
|
||||
*rows], spacing=T.S3)), help="linux_app")
|
||||
|
||||
def set_desktop_entry(self, e) -> None:
|
||||
"""A Linux app's Desktop Mode entry (GitHub #84): kept with the game, applied on the Frame at once when it's
|
||||
installed there (else at the next install)."""
|
||||
app, pkg, on = self.app, self.package, bool(e.control.value)
|
||||
|
||||
def work():
|
||||
library.upsert_game(pkg, desktop_entry=on)
|
||||
if app.frame_state == "connected" and app.target and \
|
||||
C.install_state(self.g, app.frame_info) in ("installed", "outdated"):
|
||||
app.target.set_desktop_entry(pkg, on)
|
||||
app.toast(tr("Added to Desktop Mode") if on else tr("Removed from Desktop Mode"))
|
||||
app.run_bg(work)
|
||||
|
||||
def recipe_summary(self) -> ft.Control:
|
||||
if self.linux:
|
||||
return self.linux_summary()
|
||||
@@ -473,7 +497,7 @@ class GameView:
|
||||
visible = [p for p in on if not p.default_on or p.category in ("pcvr",)]
|
||||
if entry and recipe.source.startswith("catalog"):
|
||||
lead = f"Known-good recipe for this game, tested {entry.verified.get('date', '')}".strip(", ")
|
||||
icon, color = G.RECIPE, T.OK
|
||||
icon, color = ft.Icons.VERIFIED_ROUNDED, T.OK
|
||||
elif recipe.source == "user":
|
||||
lead, icon, color = tr("Your custom recipe"), ft.Icons.TUNE_ROUNDED, T.ACCENT
|
||||
else:
|
||||
@@ -481,7 +505,7 @@ class GameView:
|
||||
ft.Icons.AUTO_AWESOME_ROUNDED, T.ACCENT
|
||||
chips = [ft.Container(C.body(tr(p.title), T.TEXT, size=T.T_META),
|
||||
tooltip=C.tip(tr(recipe.reasons.get(p.id) or p.description)),
|
||||
bgcolor=T.SURFACE_3, border_radius=T.RADIUS_XS,
|
||||
bgcolor=T.SURFACE_3, border_radius=T.px(6),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)))
|
||||
for p in visible]
|
||||
base_count = len(on) - len(visible)
|
||||
@@ -517,7 +541,7 @@ class GameView:
|
||||
return C.section(
|
||||
tr("What FramePort will do"),
|
||||
C.card(ft.Column([
|
||||
ft.Row([C.as_icon(icon, T.px(18), color), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
|
||||
ft.Row([ft.Icon(icon, color=color, size=T.px(18)), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
|
||||
expand=True)], spacing=T.S2),
|
||||
ft.Row(chips, spacing=T.S2, run_spacing=T.S2, wrap=True) if chips else
|
||||
(ft.Container() if as_is else C.meta(tr("Nothing to patch: it runs as is."))),
|
||||
@@ -537,6 +561,9 @@ class GameView:
|
||||
state = {"recipe": recipe}
|
||||
warn = C.body("", T.WARN)
|
||||
technical = bool(library.setting("ui.patch_details", False)) # remembered for every game
|
||||
# workarounds the last build left out because OVRPort's output already had the fix (patches/upstream.py)
|
||||
superseded = (g.get("build") or {}).get("superseded") or {}
|
||||
upstream_note = tr("Not needed in the last build: OVRPort fixed this itself.")
|
||||
|
||||
def save(r):
|
||||
r.source = "user"
|
||||
@@ -596,12 +623,16 @@ class GameView:
|
||||
|
||||
vals = recipe_values(g)
|
||||
changed = sum(1 for k, v in vals.items() if v != default(k))
|
||||
fixed = [base.get(pid).title for pid in superseded if pid.startswith("adapter.")]
|
||||
fixed_note = [C.meta(tr("Not needed in the last build, OVRPort fixed it itself: {names}").format(
|
||||
names=", ".join(tr(t) for t in fixed)), T.TEXT_3)] if fixed else []
|
||||
sections.append(C.card(ft.Row([
|
||||
ft.Column([ft.Row([C.body(CATEGORY_TITLES[cat], T.TEXT, weight=ft.FontWeight.W_600),
|
||||
C.help_icon("cat_adapter")], spacing=T.S2),
|
||||
C.meta(tr_n("{n} setting changed from the default",
|
||||
"{n} settings changed from the default", changed) if changed
|
||||
else tr("All at their defaults"))], spacing=T.px(2), expand=True),
|
||||
else tr("All at their defaults")), *fixed_note], spacing=T.px(2),
|
||||
expand=True),
|
||||
C.secondary(tr("Change settings…"), ft.Icons.TUNE_ROUNDED,
|
||||
lambda e: app.settings_dialog(package)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)))
|
||||
@@ -618,6 +649,8 @@ class GameView:
|
||||
shown = tr(reason).replace("overport", "OVRPort") if technical else \
|
||||
plain_reason(reason, p.default_on)
|
||||
sub.insert(0, C.meta(shown, T.ACCENT))
|
||||
if on and p.id in superseded:
|
||||
sub.append(C.meta(upstream_note, T.TEXT_3))
|
||||
extra = None
|
||||
choices = getattr(p, "CHOICES", None)
|
||||
if choices: # a plain choice instead of a switch + text field (e.g. Proton: Experimental / Stable)
|
||||
@@ -628,9 +661,10 @@ class GameView:
|
||||
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),
|
||||
C.dropdown(value=current, width=T.px(280),
|
||||
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
|
||||
on_select=choose(p.id, p.params[0].key)),
|
||||
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
|
||||
@@ -638,13 +672,15 @@ class GameView:
|
||||
pass
|
||||
elif cat == "adapter":
|
||||
val = recipe.params(p.id).get("value", p.params[0].default)
|
||||
extra = C.field(value=str(val), width=T.px(90), on_blur=set_value(p.id, p.params[0].kind))
|
||||
extra = ft.TextField(value=str(val), width=T.px(90), dense=True, text_size=T.T_BODY,
|
||||
border_color=T.BORDER, on_blur=set_value(p.id, p.params[0].kind))
|
||||
elif p.params:
|
||||
q = p.params[0]
|
||||
multi = q.kind == "text"
|
||||
extra = C.field(value=str(recipe.params(p.id).get(q.key, q.default) or ""), hint_text=q.help,
|
||||
width=T.px(260), multiline=multi, min_lines=1, max_lines=4 if multi else 1,
|
||||
on_blur=set_param(p.id, q.key))
|
||||
extra = ft.TextField(value=str(recipe.params(p.id).get(q.key, q.default) or ""), hint_text=q.help,
|
||||
width=T.px(260), dense=True, multiline=multi, min_lines=1,
|
||||
max_lines=4 if multi else 1, text_size=T.T_BODY, border_color=T.BORDER,
|
||||
on_blur=set_param(p.id, q.key))
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([ft.Row([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500)]
|
||||
+ ([C.pill(tr("experimental"), T.WARN, tooltip=C.tip(HELP["experimental"]))]
|
||||
@@ -701,7 +737,7 @@ class GameView:
|
||||
ft.Container(ft.Column(rows, spacing=T.S2), padding=ft.Padding(T.S4, 0, T.S4, T.S4))],
|
||||
bgcolor=T.SURFACE, collapsed_bgcolor=T.SURFACE)
|
||||
if self.rift:
|
||||
rows += [C.kv(tr("Folder"), g.get("game_dir") or ""), C.kv(tr("Program"), g.get("exe") or ""),
|
||||
rows += [C.kv(tr("Folder"), g.get("game_dir") or ""), C.kv(tr("Executable"), g.get("exe") or ""),
|
||||
C.kv(tr("Type"), f"{a['abis'][0]} · {a['graphics']}"),
|
||||
C.kv(tr("Size"), tr("{value:.1f} GiB")
|
||||
.format(value=(a.get('extra', {}).get('data_bytes') or 0) / 2**30))]
|
||||
@@ -725,7 +761,6 @@ class GameView:
|
||||
tr("It was removed from the library."),
|
||||
C.primary(tr("Back to library"), on_click=lambda e: self.app.go("library")))
|
||||
about = self.about()
|
||||
self._backdrop = ft.Container(left=0, right=0, top=0, height=T.px(560)) # the cover tint's page wash
|
||||
body = [self.hero(), *self.notes(), self.where(), *([about] if about else []), self.tags(),
|
||||
self.recipe_summary()]
|
||||
if self.advanced and not self.linux: # (a Linux app has no patches)
|
||||
@@ -736,13 +771,7 @@ class GameView:
|
||||
def remember(e):
|
||||
app.game_scroll = e.pixels
|
||||
col = ft.Column([ft.Container(ft.Column(body, spacing=T.S5), padding=ft.Padding(0, 0, T.S3, T.S6))],
|
||||
scroll=ft.ScrollMode.AUTO, on_scroll=remember, scroll_interval=100,
|
||||
left=0, right=0, top=0, bottom=0)
|
||||
app.game_view = self
|
||||
known, tint = thumbs.cached_tint(self.package)
|
||||
self._tint(tint)
|
||||
if not known:
|
||||
app.page.run_thread(self._fill_tint)
|
||||
scroll=ft.ScrollMode.AUTO, expand=True, on_scroll=remember, scroll_interval=100)
|
||||
offset = getattr(app, "game_scroll", 0.0)
|
||||
if offset > 0: # back to where the user was, once the new page is on screen
|
||||
def restore():
|
||||
@@ -751,7 +780,7 @@ class GameView:
|
||||
except Exception: # noqa: BLE001 - the page changed again meanwhile
|
||||
pass
|
||||
threading.Timer(0.08, restore).start()
|
||||
return ft.Stack([self._backdrop, col], expand=True)
|
||||
return col
|
||||
|
||||
|
||||
def _known(pid: str) -> bool:
|
||||
|
||||
@@ -14,7 +14,6 @@ import flet as ft
|
||||
from ...errors import explain
|
||||
from ...i18n import tr
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -40,18 +39,18 @@ class KeyboardView:
|
||||
on_click=lambda e: self.focus_keys())
|
||||
self.listener = ft.KeyboardListener(self.pad, autofocus=True, on_key_down=self._forward("down"),
|
||||
on_key_up=self._forward("up"), on_key_repeat=self._forward("repeat"))
|
||||
self.paste = C.field(hint_text=tr("Or paste text to type it in one go"), expand=True,
|
||||
on_submit=self._send_text)
|
||||
self.paste = ft.TextField(hint_text=tr("Or paste text to type it in one go"), expand=True, dense=True,
|
||||
border_color=T.BORDER, on_submit=self._send_text)
|
||||
self.reconnect = C.secondary(tr("Reconnect"), ft.Icons.REFRESH_ROUNDED, lambda e: self.start())
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
def mount(self) -> ft.Control:
|
||||
app = self.app
|
||||
heading, sub = tr("Type on Frame"), tr("Use this PC's keyboard on the Frame")
|
||||
heading, sub = tr("Type on Frame"), tr("Use this computer's keyboard on the Frame")
|
||||
if not (app.target and app.frame_state == "connected"):
|
||||
return ft.Column([
|
||||
app.top_bar(heading, sub),
|
||||
C.empty_state(G.KEYS, tr("Connect your Frame first"),
|
||||
C.empty_state(ft.Icons.KEYBOARD_ROUNDED, tr("Connect your Frame first"),
|
||||
tr("Typing on the Frame works 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:
|
||||
@@ -61,12 +60,12 @@ class KeyboardView:
|
||||
ft.Row([self.status, self.reconnect], spacing=T.S3,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.listener,
|
||||
ft.Row([self.paste, C.secondary(tr("Type it"), ft.Icons.SEND_OUTLINED, self._send_text)],
|
||||
ft.Row([self.paste, C.secondary(tr("Type it"), ft.Icons.SEND_ROUNDED, self._send_text)],
|
||||
spacing=T.S2),
|
||||
ft.Row([C.meta(tr("In the headset, select a text field, then type here."), T.TEXT_2),
|
||||
C.help_icon("type_on_frame", 16)], spacing=T.px(2), tight=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
], spacing=T.S3)),
|
||||
C.callout(tr("This computer's keyboard works as a keyboard on your Frame while this tab is open. In "
|
||||
"the headset, select a text field (in a game or app, in Steam or on the desktop), then "
|
||||
"type here.")),
|
||||
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
self.start()
|
||||
return self.root
|
||||
|
||||
@@ -13,7 +13,6 @@ from ...artwork import thumbs
|
||||
from ...core import library
|
||||
from ...i18n import tr, tr_n
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
from ..help import HELP
|
||||
|
||||
@@ -141,57 +140,6 @@ def filter_games(games: list[dict], f: dict, frame_info: dict | None = None,
|
||||
return sorted(out, key=key)
|
||||
|
||||
|
||||
# type to search: Flet sends Flutter's key labels ("A" for a and A, "1", " ", "-", "Arrow Left", "Numpad 1", "F5"),
|
||||
# not the typed character, so shifted symbols follow the US layout (what the label alone can tell)
|
||||
_SHIFTED = dict(zip("1234567890-=[];'`\\,./", "!@#$%^&*()_+{}:\"~|<>?", strict=True))
|
||||
_NAMED_CHARS = {"space": " ", **{f"numpad{d}": d for d in "0123456789"}, "numpadadd": "+",
|
||||
"numpadsubtract": "-", "numpadmultiply": "*", "numpaddivide": "/", "numpaddecimal": "."}
|
||||
|
||||
|
||||
def typed_char(key: str, shift: bool = False, ctrl: bool = False, alt: bool = False,
|
||||
meta: bool = False) -> str | None:
|
||||
"""The character a key press types (for the Library's type-to-search), None for shortcuts (Ctrl/Alt/Meta held),
|
||||
modifiers, navigation, function and other named keys."""
|
||||
if not key or ctrl or alt or meta:
|
||||
return None
|
||||
if len(key) == 1:
|
||||
if key.isalpha():
|
||||
return key.upper() if shift else key.lower()
|
||||
if shift and key in _SHIFTED:
|
||||
return _SHIFTED[key]
|
||||
return key if key.isprintable() else None
|
||||
return _NAMED_CHARS.get(key.lower().replace(" ", "").replace("_", ""))
|
||||
|
||||
|
||||
def should_capture(route: str, dialog_open: bool, field_focused: bool, char: str | None, query: str = "") -> bool:
|
||||
"""Whether a typed character goes to the Library's search: only on the Library itself, with no dialog open and
|
||||
the search not already focused (it types there itself); a leading space is ignored (Space on a focused button)."""
|
||||
return (route == "library" and not dialog_open and not field_focused and char is not None
|
||||
and not (char == " " and not query))
|
||||
|
||||
|
||||
SHELF_MAX = 8
|
||||
|
||||
|
||||
def filters_active(f: dict) -> bool:
|
||||
"""A search or any filter other than the defaults (the sort order isn't a filter)."""
|
||||
return bool((f.get("q") or "").strip() or f.get("tags")) or any(
|
||||
f.get(k, DEFAULT_FILTERS[k]) != DEFAULT_FILTERS[k] for k in ("where", "platform", "status"))
|
||||
|
||||
|
||||
def shelf_games(games: list[dict], f: dict, frame_info: dict | None, connected: bool, select_mode: bool = False,
|
||||
limit: int = SHELF_MAX) -> list[dict]:
|
||||
"""The "On your Frame" shelf: games installed on the connected Frame (outdated ones too), most recently used
|
||||
first, at most `limit`. Empty (the shelf hides) without a connected Frame and while searching or filtering. It
|
||||
stays in select mode (`select_mode` is ignored): hiding it when a drag-select starts moved the grid up under the
|
||||
mouse, and the drag selected the wrong cards."""
|
||||
if not connected or filters_active(f):
|
||||
return []
|
||||
on = [g for g in games if C.install_state(g, frame_info) in ("installed", "outdated")]
|
||||
on.sort(key=lambda g: (-last_used(g), (g.get("title") or g["package"]).lower()))
|
||||
return on[:limit]
|
||||
|
||||
|
||||
def load_filters() -> dict:
|
||||
saved = library.setting("ui.library") or {}
|
||||
return {**DEFAULT_FILTERS, "tags": [], **{k: v for k, v in saved.items() if k in DEFAULT_FILTERS and k != "q"}}
|
||||
@@ -204,7 +152,6 @@ def save_filters(f: dict) -> None:
|
||||
# ------------------------------------------------------------------------------------------ view
|
||||
BATCH = 8
|
||||
CARD_ART = ("portrait", "square", "cover", "icon") # store art first; cover = FramePort's own (no store art)
|
||||
SHELF_ART = ("landscape", "hero", "banner", "portrait", "square", "cover", "icon") # wide first
|
||||
def card_shadow(hover: bool = False) -> ft.BoxShadow:
|
||||
"""Library cards float on the dark background; hovering lifts them further."""
|
||||
if hover:
|
||||
@@ -226,22 +173,12 @@ class LibraryView:
|
||||
self.f = app.lib_filters
|
||||
self.cards: dict[str, tuple[tuple, ft.Control]] = {} # package -> (state key, card)
|
||||
self.checks: dict[str, ft.Control] = {} # package -> selection checkbox overlay
|
||||
self.marks: dict[str, tuple] = {} # package -> (card tile, selection fade overlay, hovered?)
|
||||
self.selected: set[str] = set()
|
||||
self.select_mode = False
|
||||
self.sel_bar = ft.Container(visible=False)
|
||||
self.resume_bar = ft.Container(visible=False)
|
||||
self.update_bar = ft.Container(visible=False) # "FramePort x.y is available" (ui/updater.py)
|
||||
self.games: list[dict] = []
|
||||
self.tw: set[str] = set()
|
||||
# "On your Frame": a row of wide cards above the grid (shelf_games); cards are kept per game and only rebuilt
|
||||
# when what they show changes
|
||||
self.shelf_cards: dict[str, tuple[tuple, ft.Control]] = {}
|
||||
self.shelf_row = ft.Row(spacing=T.S4, scroll=ft.ScrollMode.AUTO)
|
||||
self.shelf = ft.Container(ft.Column([
|
||||
C.h2(tr("On your Frame")),
|
||||
ft.Container(self.shelf_row, padding=ft.Padding(0, 0, 0, T.S2)),
|
||||
], spacing=T.S3, tight=True), visible=False)
|
||||
self._lock = threading.Lock()
|
||||
self._search_timer: threading.Timer | None = None
|
||||
self._gen = 0
|
||||
@@ -249,11 +186,12 @@ class LibraryView:
|
||||
run_spacing=T.S4, padding=ft.Padding(0, T.S2, T.S2, T.S5))
|
||||
self.count = C.meta("")
|
||||
self.subtitle = C.body("", max_lines=1, overflow=ft.TextOverflow.ELLIPSIS)
|
||||
# typing anywhere on the Library goes here (app._on_key); search_focused tracks where keys already land
|
||||
self.search_focused = False
|
||||
self.search = C.search(value=self.f["q"], hint_text=tr("Type to search games and tags"), width=T.px(260),
|
||||
on_change=self._on_search, on_focus=lambda e: self._focus(True),
|
||||
on_blur=lambda e: self._focus(False))
|
||||
self.search = ft.TextField(
|
||||
value=self.f["q"], hint_text=tr("Search games and tags"), prefix_icon=ft.Icons.SEARCH_ROUNDED, dense=True,
|
||||
width=T.px(260), border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, border_color=ft.Colors.TRANSPARENT,
|
||||
focused_border_color=T.ACCENT, content_padding=ft.Padding(T.px(12), T.px(8), T.px(12), T.px(8)),
|
||||
text_size=T.T_BODY,
|
||||
on_change=self._on_search)
|
||||
# an X that clears the search at once (shown only while there is text)
|
||||
# a small clickable icon, not an IconButton: its 40 px minimum size made the field taller and pushed the
|
||||
# text off-centre
|
||||
@@ -270,22 +208,33 @@ class LibraryView:
|
||||
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.Column([
|
||||
self.shelf,
|
||||
ft.GestureDetector(content=self.grid, expand=True, on_pan_start=self.drag.start, on_pan_end=self.drag.end),
|
||||
], spacing=T.S4, expand=True), secondary_trigger=None, tertiary_trigger=None, expand=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 = C.primary_menu(tr("Add games"), ft.Icons.ADD_ROUNDED, C.menu_items([
|
||||
(tr("Scan a folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
(tr("Add a PC game folder…"), G.PC, app.pick_game_folder),
|
||||
(tr("Add an APK…"), ft.Icons.ANDROID_ROUNDED, app.pick_apk),
|
||||
(tr("Add a Windows program…"), ft.Icons.WEB_ASSET_ROUNDED, app.pick_windows_exe),
|
||||
(tr("Add from a link…"), ft.Icons.LINK_ROUNDED, app.pick_link),
|
||||
None, # native Linux apps (GitHub #31)
|
||||
(tr("Add a Linux app…"), ft.Icons.TERMINAL_ROUNDED, app.pick_linux_app),
|
||||
(tr("Add a Linux app folder…"), ft.Icons.FOLDER_OUTLINED, app.pick_linux_folder),
|
||||
None,
|
||||
(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan)]))
|
||||
add = ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.ADD_ROUNDED, color=T.ON_ACCENT, size=T.px(18)),
|
||||
ft.Text(tr("Add games"), color=T.ON_ACCENT, weight=ft.FontWeight.W_600,
|
||||
size=T.px(13))],
|
||||
spacing=T.px(6), tight=True),
|
||||
bgcolor=T.ACCENT, border_radius=T.RADIUS_SM,
|
||||
padding=ft.Padding(T.px(14), T.px(9), T.px(16), T.px(9))),
|
||||
items=[ft.PopupMenuItem(content=ft.Text(tr("Scan a folder…")), icon=ft.Icons.FOLDER_OPEN_ROUNDED,
|
||||
on_click=app.pick_folder),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add one game folder…")),
|
||||
icon=ft.Icons.CREATE_NEW_FOLDER_ROUNDED, on_click=app.pick_game_folder),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add an APK file…")), icon=ft.Icons.ANDROID_ROUNDED,
|
||||
on_click=app.pick_apk),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add a Windows program (.exe)…")),
|
||||
icon=ft.Icons.DESKTOP_WINDOWS_ROUNDED, on_click=app.pick_windows_exe),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Install from a link…")), icon=ft.Icons.LINK_ROUNDED,
|
||||
on_click=app.pick_link),
|
||||
ft.PopupMenuItem(), # divider: native Linux apps (GitHub #31)
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add a Linux app…")), icon=ft.Icons.TERMINAL_ROUNDED,
|
||||
on_click=app.pick_linux_app),
|
||||
ft.PopupMenuItem(content=ft.Text(tr("Add a Linux app folder…")), icon=ft.Icons.FOLDER_ROUNDED,
|
||||
on_click=app.pick_linux_folder)],
|
||||
bgcolor=T.SURFACE_2, tooltip="")
|
||||
self.rescan_btn = C.secondary(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan,
|
||||
tooltip=C.tip(HELP["rescan"]))
|
||||
self.update_all_btn = C.secondary(tr("Update all"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED,
|
||||
@@ -344,17 +293,18 @@ class LibraryView:
|
||||
games = library.games()
|
||||
self.games = games
|
||||
pc = set(local_installs())
|
||||
tw = self.tw = twins(games)
|
||||
tw = twins(games)
|
||||
self._update_header(games, pc)
|
||||
self.update_update_bar()
|
||||
self.update_resume_bar() # also after Dismiss / Resume and when installs finish or fail
|
||||
if not games:
|
||||
self.body.content = C.empty_state(
|
||||
ft.Icons.LIBRARY_ADD_OUTLINED, tr("Add your games"),
|
||||
tr("Point FramePort at a folder with Quest or Android games (APK + OBB) or PC VR games. It "
|
||||
"finds them, works out what each needs and fetches artwork."),
|
||||
C.primary(tr("Scan a folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
|
||||
C.secondary(tr("Add an APK file…"), ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
|
||||
ft.Icons.LIBRARY_ADD_ROUNDED, tr("Add your games"),
|
||||
tr("Point FramePort at a folder with Android games (APK + OBB, e.g. Quest games) or PC VR games "
|
||||
"(one folder per game, or a folder of them). It finds them, works out what each needs and "
|
||||
"fetches artwork."),
|
||||
C.primary(tr("Scan a folder"), ft.Icons.FOLDER_OPEN_ROUNDED, self.app.pick_folder, big=True),
|
||||
C.secondary(tr("Add an APK file"), ft.Icons.ANDROID_ROUNDED, self.app.pick_apk))
|
||||
self.cards.clear()
|
||||
C.update(self.root)
|
||||
return
|
||||
@@ -391,11 +341,8 @@ class LibraryView:
|
||||
for pkg, chk in self.checks.items():
|
||||
chk.visible = on
|
||||
chk.content.value = pkg in self.selected
|
||||
for pkg in self.marks:
|
||||
self._paint_card(pkg)
|
||||
self._update_sel_bar()
|
||||
self._update_shelf()
|
||||
C.update(self.grid, self.sel_bar, self.select_btn, self.shelf)
|
||||
C.update(self.grid, self.sel_bar, self.select_btn)
|
||||
|
||||
def toggle_selected(self, pkg: str) -> None:
|
||||
self.selected.symmetric_difference_update({pkg})
|
||||
@@ -403,19 +350,8 @@ class LibraryView:
|
||||
if chk:
|
||||
chk.content.value = pkg in self.selected
|
||||
C.update(chk)
|
||||
if pkg in self.marks:
|
||||
self._paint_card(pkg)
|
||||
C.update(self.marks[pkg][0])
|
||||
self._update_sel_bar()
|
||||
|
||||
def _paint_card(self, pkg: str) -> None:
|
||||
"""A card's border + selection fade (properties only): selected = 2 px accent + the portal fade, hovered =
|
||||
1 px accent, else the plain border."""
|
||||
tile, overlay, hovered = self.marks[pkg]
|
||||
on = self.select_mode and pkg in self.selected
|
||||
C.selected_style(overlay, on, subtle=True)
|
||||
tile.border = ft.Border.all(2 if on else 1, T.ACCENT if on or hovered else T.BORDER)
|
||||
|
||||
def select_visible(self) -> None:
|
||||
shown = filter_games(self.games, self.f, self.app.frame_info, set(self.app.pc_installs()))
|
||||
self.selected |= {g["package"] for g in shown if g["package"] in self.cards}
|
||||
@@ -436,11 +372,11 @@ class LibraryView:
|
||||
ft.Container(expand=True),
|
||||
C.ghost(tr("Select all shown"), on_click=lambda e: self.select_visible()),
|
||||
C.ghost(tr("Clear"), on_click=lambda e: (self.selected.clear(), self.set_select_mode(True))),
|
||||
*([C.secondary(tr("Install {len} on this PC").format(len=len(rift)), G.PC,
|
||||
*([C.secondary(tr("Install {len} on this PC").format(len=len(rift)), ft.Icons.COMPUTER_ROUNDED,
|
||||
lambda e: app.install_many(sorted(rift), "pc"), disabled=not winhost.available())]
|
||||
if rift else []),
|
||||
C.primary(tr("Install {n} on Frame").format(n=n) if n else tr("Install on Frame"),
|
||||
G.FRAME,
|
||||
ft.Icons.VIEW_IN_AR_ROUNDED,
|
||||
lambda e: app.install_many(sorted(self.selected), "frame"),
|
||||
disabled=not n or app.frame_state != "connected",
|
||||
tooltip=None if app.frame_state == "connected" else tr("Connect your Frame first")),
|
||||
@@ -485,36 +421,8 @@ class LibraryView:
|
||||
C.update(self.search)
|
||||
self._apply(update=True)
|
||||
|
||||
def _focus(self, on: bool) -> None:
|
||||
self.search_focused = on
|
||||
|
||||
def type_key(self, char: str | None, backspace: bool = False) -> None:
|
||||
"""A key typed while the search isn't focused (app._on_key): add it (or remove the last character), filter
|
||||
like typing in the field, and focus the field so the next keys go there directly."""
|
||||
value = self.search.value or ""
|
||||
self.search.value = value[:-1] if backspace else value + (char or "")
|
||||
self.set_query(self.search.value)
|
||||
C.update(self.search)
|
||||
try:
|
||||
self.app.page.run_task(self._focus_at_end)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _focus_at_end(self) -> None:
|
||||
await self.search.focus()
|
||||
n = len(self.search.value or "")
|
||||
try: # Flutter selects nothing on a programmatic focus; put the cursor after the typed text
|
||||
self.search.selection = ft.TextSelection(base_offset=n, extent_offset=n)
|
||||
C.update(self.search)
|
||||
except Exception: # noqa: BLE001 (older Flet: no selection property; the cursor goes to the end anyway)
|
||||
pass
|
||||
|
||||
def _on_search(self, e):
|
||||
self.set_query(e.control.value)
|
||||
|
||||
def set_query(self, value: str) -> None:
|
||||
"""The search text changed: show/hide the clear X, filter after a short pause (debounced)."""
|
||||
self.f["q"] = value
|
||||
self.f["q"] = e.control.value
|
||||
if self.search.suffix.visible != bool(self.f["q"]):
|
||||
self.search.suffix.visible = bool(self.f["q"])
|
||||
C.update(self.search)
|
||||
@@ -538,75 +446,8 @@ class LibraryView:
|
||||
self.grid.controls = [self.cards[g["package"]][1] for g in shown if g["package"] in self.cards]
|
||||
n = len(self.games)
|
||||
self.count.value = tr("Showing {len} of {n}").format(len=len(shown), n=n) if len(shown) != n else ""
|
||||
self._update_shelf()
|
||||
if update:
|
||||
C.update(self.grid, self.count, self.shelf)
|
||||
|
||||
# ---------------------------------------------------------------- "On your Frame" shelf
|
||||
def _update_shelf(self) -> None:
|
||||
"""Show the shelf's games (properties + reused cards; a card is rebuilt only when what it shows changed)."""
|
||||
app = self.app
|
||||
games = shelf_games(self.games, self.f, app.frame_info, app.frame_state == "connected", self.select_mode)
|
||||
controls = []
|
||||
for g in games:
|
||||
pkg = g["package"]
|
||||
key = (display_title(g, self.tw), C.install_state(g, app.frame_info), bool(app.jobs.busy_with(pkg)),
|
||||
thumbs.url(pkg, SHELF_ART, T.px(640), wait=False))
|
||||
old = self.shelf_cards.get(pkg)
|
||||
if not old or old[0] != key:
|
||||
old = self.shelf_cards[pkg] = (key, self.shelf_card(g, *key))
|
||||
controls.append(old[1])
|
||||
for pkg in set(self.shelf_cards) - {g["package"] for g in games}:
|
||||
self.shelf_cards.pop(pkg, None)
|
||||
self.shelf_row.controls = controls
|
||||
self.shelf.visible = bool(controls)
|
||||
|
||||
def shelf_card(self, g: dict, title: str, state: str | None, busy: bool, art: str | None) -> ft.Control:
|
||||
"""A wide cover: title, "Update ready" when the Frame runs an older build, Play on hover; click opens the
|
||||
game, right-click shows its menu (the same as the grid's)."""
|
||||
app = self.app
|
||||
pkg = g["package"]
|
||||
w, h = T.px(264), T.px(148)
|
||||
play = ft.Container(
|
||||
ft.Icon(ft.Icons.PLAY_ARROW_ROUNDED, size=T.px(34), color=T.ON_ACCENT),
|
||||
width=T.px(60), height=T.px(60), border_radius=T.px(30), bgcolor=T.ACCENT, alignment=ft.Alignment.CENTER,
|
||||
shadow=ft.BoxShadow(blur_radius=20, spread_radius=1, color=T.soft("#000000", 0.6), offset=ft.Offset(0, 4)),
|
||||
tooltip=ft.Tooltip(message=tr("Play on Frame"), wait_duration=800), ink=True,
|
||||
on_click=lambda e: app.play(pkg, "frame"),
|
||||
scale=0.85, animate_scale=ft.Animation(160, ft.AnimationCurve.EASE_OUT))
|
||||
quick = ft.Container(play, left=0, right=0, top=0, bottom=T.px(36), alignment=ft.Alignment.CENTER, opacity=0,
|
||||
animate_opacity=ft.Animation(160, ft.AnimationCurve.EASE_OUT), visible=not busy)
|
||||
badges = [C.install_badge("outdated")] if state == "outdated" else []
|
||||
if busy:
|
||||
badges = [C.pill(tr("Working…"), T.ACCENT, ft.Icons.SYNC_ROUNDED, solid=True)]
|
||||
tile = ft.Container(
|
||||
ft.Stack([
|
||||
C.art_fill(art, left=0, right=0, top=0, bottom=0, placeholder_icon=C.platform_icon(g)),
|
||||
ft.Container(C.bottom_fade(None, 0.92), left=0, right=0, bottom=0, top=T.px(48)),
|
||||
*([ft.Container(ft.Row(badges, spacing=T.px(4)), left=T.px(10), top=T.px(10))] if badges else []),
|
||||
quick,
|
||||
ft.Container(ft.Text(title, size=T.px(14), weight=ft.FontWeight.W_700, color=T.TEXT, max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS),
|
||||
left=T.px(12), right=T.px(12), bottom=T.px(10)),
|
||||
]),
|
||||
width=w, height=h, border_radius=T.RADIUS, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER),
|
||||
shadow=card_shadow(), scale=1.0, animate_scale=ft.Animation(140, ft.AnimationCurve.EASE_OUT),
|
||||
tooltip=ft.Tooltip(message=tr("Click to open · right-click for quick actions"), wait_duration=1500),
|
||||
on_click=lambda e: app.open_game(pkg))
|
||||
|
||||
def hover(e):
|
||||
on = e.data in (True, "true")
|
||||
tile.scale = 1.03 if on else 1.0
|
||||
tile.shadow = card_shadow(on)
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
quick.opacity = 1 if on else 0
|
||||
play.scale = 1.0 if on else 0.85
|
||||
tile.update()
|
||||
tile.on_hover = hover
|
||||
# (padding: room for the hover lift and shadow inside the scrolling row, which clips)
|
||||
return ft.Container(ft.GestureDetector(content=tile,
|
||||
on_secondary_tap_down=lambda e: self.open_menu(pkg, e.global_position)),
|
||||
padding=ft.Padding(T.px(2), T.px(6), T.px(2), T.px(8)))
|
||||
C.update(self.grid, self.count)
|
||||
|
||||
# ---------------------------------------------------------------- header / filter bar
|
||||
def _update_hint(self):
|
||||
@@ -615,7 +456,7 @@ class LibraryView:
|
||||
self.hint.content = C.callout(ft.Row([
|
||||
C.body(tr("Connect your Steam Frame to install games and see what's on it."), T.TEXT, expand=True),
|
||||
C.ghost(tr("Connect"), ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.go("frame"), color=T.ACCENT)]),
|
||||
"info", G.FRAME)
|
||||
"info", ft.Icons.VIEW_IN_AR_ROUNDED)
|
||||
self.hint.visible = True
|
||||
else:
|
||||
self.hint.visible = False
|
||||
@@ -662,18 +503,28 @@ class LibraryView:
|
||||
C.update(self.subtitle, self.hint, self.filters)
|
||||
|
||||
def _seg(self, key: str, options: list[tuple[str, str]]) -> ft.Control:
|
||||
return C.segmented(options, self.f.get(key), lambda v: self._set(key, v))
|
||||
items = []
|
||||
for value, label in options:
|
||||
on = self.f.get(key) == value
|
||||
items.append(ft.Container(
|
||||
ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600, color=T.TEXT if on else T.TEXT_2),
|
||||
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20),
|
||||
bgcolor=T.SURFACE_3 if on else None,
|
||||
on_click=lambda e, v=value: self._set(key, v), ink=True))
|
||||
return ft.Container(ft.Row(items, spacing=T.px(2), tight=True), padding=T.px(3), border_radius=T.px(22),
|
||||
border=ft.Border.all(1, T.BORDER))
|
||||
|
||||
def _menu_chip(self, key: str, label: str, options: list[tuple[str, str]]) -> ft.Control:
|
||||
current = dict(options).get(self.f.get(key), options[0][1])
|
||||
return C.menu_button(
|
||||
return ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([C.meta(label + ":"), C.body(current, T.TEXT, size=T.T_META),
|
||||
ft.Icon(ft.Icons.EXPAND_MORE_ROUNDED, size=T.px(16), color=T.TEXT_2)],
|
||||
spacing=T.px(4), tight=True),
|
||||
padding=ft.Padding(T.px(12), T.px(7), T.px(8), T.px(7)), border_radius=T.px(20),
|
||||
border=ft.Border.all(1, T.BORDER)),
|
||||
items=[C.check_item(text, self.f.get(key) == value, lambda e, v=value: self._set(key, v))
|
||||
for value, text in options])
|
||||
items=[ft.PopupMenuItem(content=ft.Text(text), checked=self.f.get(key) == value,
|
||||
on_click=lambda e, v=value: self._set(key, v)) for value, text in options],
|
||||
bgcolor=T.SURFACE_2, tooltip="")
|
||||
|
||||
def _toggle_tag(self, tag: str):
|
||||
tags = list(self.f.get("tags") or [])
|
||||
@@ -683,13 +534,13 @@ class LibraryView:
|
||||
def _tag_menu(self, games: list[dict]) -> ft.Control:
|
||||
chosen = self.f.get("tags") or []
|
||||
label = ", ".join(chosen) if chosen else tr("Any")
|
||||
items = [C.check_item(t, t in chosen, lambda e, t=t: self._toggle_tag(t)) for t in all_tags(games)]
|
||||
items = [ft.PopupMenuItem(content=ft.Text(t), checked=t in chosen, on_click=lambda e, t=t: self._toggle_tag(t))
|
||||
for t in all_tags(games)]
|
||||
if chosen:
|
||||
items += C.menu_items([None, (tr("Clear tags"), ft.Icons.CLOSE_ROUNDED, lambda e: self._set("tags", []))])
|
||||
return C.menu_button(
|
||||
content=ft.Container(ft.Row([ft.Container(ft.Icon(ft.Icons.SELL_OUTLINED, size=T.px(14), color=T.TEXT_2),
|
||||
width=T.px(16), height=T.px(16),
|
||||
alignment=ft.Alignment.CENTER), # sized: overlapped the label
|
||||
items.append(ft.PopupMenuItem(content=ft.Text(tr("Clear tags")), icon=ft.Icons.CLEAR_ROUNDED,
|
||||
on_click=lambda e: self._set("tags", [])))
|
||||
return ft.PopupMenuButton(
|
||||
content=ft.Container(ft.Row([ft.Icon(ft.Icons.SELL_OUTLINED, size=T.px(14), color=T.TEXT_2),
|
||||
C.meta(tr("Tags:")),
|
||||
C.body(label if len(label) < 28
|
||||
else tr("{len} selected").format(len=len(chosen)), T.TEXT,
|
||||
@@ -698,7 +549,7 @@ class LibraryView:
|
||||
spacing=T.px(4), tight=True),
|
||||
padding=ft.Padding(T.px(12), T.px(7), T.px(8), T.px(7)), border_radius=T.px(20),
|
||||
border=ft.Border.all(1, T.ACCENT if chosen else T.BORDER)),
|
||||
items=items, tooltip=C.tip(HELP["tags"]))
|
||||
items=items, bgcolor=T.SURFACE_2, tooltip=C.tip(HELP["tags"]))
|
||||
|
||||
# ---------------------------------------------------------------- cards
|
||||
def _key(self, g: dict, pc: set[str], tw: set[str]) -> tuple:
|
||||
@@ -726,17 +577,16 @@ class LibraryView:
|
||||
if on_pc:
|
||||
badges.append(C.install_badge("on_pc"))
|
||||
if rift and g.get("exe_confirmed") is False:
|
||||
badges.append(C.pill(tr("Check program"), T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
|
||||
badges.append(C.pill(tr("Check exe"), T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
|
||||
tooltip=C.tip(HELP["check_exe"])))
|
||||
label, _, help_key = C.platform(g)
|
||||
platform = C.pill(label, T.PC if rift else T.TEXT, C.platform_icon(g), overlay=True,
|
||||
tooltip=C.tip(HELP[help_key]))
|
||||
check = ft.Container(ft.Checkbox(value=pkg in self.selected, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
on_change=lambda e: self.toggle_selected(pkg)),
|
||||
bgcolor=T.soft("#000000", 0.6), border_radius=T.RADIUS_SM, left=T.px(6), top=T.px(40),
|
||||
bgcolor=T.soft("#000000", 0.6), border_radius=T.px(8), left=T.px(6), top=T.px(40),
|
||||
visible=self.select_mode)
|
||||
self.checks[pkg] = check
|
||||
overlay = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS) # the selected fade
|
||||
quick_label, quick_kind = app.quick_action(g)
|
||||
circle = ft.Container(
|
||||
ft.Icon(quick_icon(quick_kind), size=T.px(56), color=T.ON_ACCENT),
|
||||
@@ -758,7 +608,6 @@ class LibraryView:
|
||||
# platform + state badges in one row that wraps: on a narrow card "On Frame" covered "Android"
|
||||
ft.Container(ft.Row([platform, *badges], spacing=T.px(4), run_spacing=T.px(4), wrap=True),
|
||||
left=T.px(10), right=T.px(10), top=T.px(10)),
|
||||
overlay,
|
||||
*([quick] if quick else []),
|
||||
check,
|
||||
ft.Container(ft.Column([
|
||||
@@ -779,16 +628,13 @@ class LibraryView:
|
||||
on = e.data in (True, "true")
|
||||
self.drag.hover(pkg, on)
|
||||
tile.scale = 1.03 if on else 1.0
|
||||
self.marks[pkg] = (tile, overlay, on)
|
||||
self._paint_card(pkg)
|
||||
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
|
||||
tile.shadow = card_shadow(on)
|
||||
if quick:
|
||||
quick.opacity = 1 if on and not self.select_mode else 0
|
||||
circle.scale = 1.0 if on else 0.85
|
||||
tile.update()
|
||||
tile.on_hover = hover
|
||||
self.marks[pkg] = (tile, overlay, False)
|
||||
self._paint_card(pkg)
|
||||
# no key=: Flet freezes keyed controls, and cards change in place (hover, state); the grid only ever holds
|
||||
# the matching cards in order (see _apply), which is what fixed search and sorting
|
||||
return ft.GestureDetector(content=tile, expand=True,
|
||||
|
||||
@@ -25,8 +25,8 @@ def kind_label(kind: str | None) -> str:
|
||||
|
||||
def show_paste_dialog(app: FramePortApp) -> None:
|
||||
"""Add games → "Install from link…": paste a FrameDrop/FramePort button link, a manifest or a file URL."""
|
||||
field = C.field(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
|
||||
expand=True)
|
||||
field = ft.TextField(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
|
||||
width=T.px(560), border_color=T.BORDER)
|
||||
|
||||
def go(e=None):
|
||||
text = (field.value or "").strip()
|
||||
@@ -35,14 +35,14 @@ def show_paste_dialog(app: FramePortApp) -> None:
|
||||
app.page.pop_dialog()
|
||||
open_link(app, text, pasted=True)
|
||||
field.on_submit = go
|
||||
app.page.show_dialog(C.dialog(
|
||||
tr("Install from a link"),
|
||||
ft.Column([
|
||||
app.page.show_dialog(ft.AlertDialog(
|
||||
title=ft.Text(tr("Install from a link")), bgcolor=T.SURFACE_2,
|
||||
content=ft.Column([
|
||||
C.body(tr("Paste the address of an \"Install with FrameDrop\" button (right-click it → Copy link), a "
|
||||
"FramePort or FrameDrop manifest (.json) or a direct link to an APK, a Linux build (.zip) or a "
|
||||
"Windows program (.exe).")),
|
||||
ft.Row([field, C.help_icon("install_links")]),
|
||||
], tight=True, spacing=T.S3),
|
||||
], tight=True, spacing=T.S3, width=T.px(600)),
|
||||
actions=[C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()),
|
||||
C.primary(tr("Continue"), ft.Icons.ARROW_FORWARD_ROUNDED, on_click=go)]))
|
||||
|
||||
@@ -57,7 +57,7 @@ def open_link(app: FramePortApp, text: str, pasted: bool = False) -> None:
|
||||
return
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage(tr("Reading the install link"))
|
||||
job.reporter.stage("Reading the install link")
|
||||
m = deeplink.fetch_manifest(req)
|
||||
sizes = {f.url: deeplink.head_size(f.url) for f in m.files}
|
||||
icon = deeplink.fetch_icon(m) # FramePort's manifest extension (None without one)
|
||||
@@ -122,7 +122,7 @@ def download_and_install(app: FramePortApp, m: deeplink.Manifest, icon=None) ->
|
||||
def run(job: Job):
|
||||
rep = job.reporter
|
||||
path = deeplink.download(m, rep)
|
||||
rep.stage(tr("Adding to the library"))
|
||||
rep.stage("Adding to the library")
|
||||
g = pipeline.add_from_link(m, path, rep, icon=icon)
|
||||
pkg = g["package"]
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,6 @@ import flet as ft
|
||||
from ...errors import explain
|
||||
from ...i18n import tr, tr_n
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
from .files_dialog import human
|
||||
|
||||
@@ -70,14 +69,13 @@ class LiveView:
|
||||
self._busy = False
|
||||
self._ticker: threading.Thread | None = None
|
||||
self.root = None
|
||||
self.idle = None # the "start the live view" hint below the bar while not streaming
|
||||
self.dot = C.dot(T.TEXT_3, 10)
|
||||
self.state = C.body(tr("Not streaming"), T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.detail = C.meta("")
|
||||
self.quality_dd = C.dropdown(label=tr("Quality"), value=self.quality, width=T.px(230),
|
||||
self.quality_dd = ft.Dropdown(label=tr("Quality"), value=self.quality, width=T.px(260), dense=True,
|
||||
options=[ft.DropdownOption(key=k, text=t) for k, t in QUALITIES],
|
||||
on_select=self._set_quality)
|
||||
self.start_btn = C.primary(tr("Start live view"), ft.Icons.PLAY_ARROW_ROUNDED, self._start)
|
||||
self.start_btn = C.primary(tr("Start live view"), ft.Icons.PLAY_ARROW_ROUNDED, self._start, big=True)
|
||||
self.open_btn = C.secondary(tr("Open viewer"), ft.Icons.OPEN_IN_NEW_ROUNDED, self._open)
|
||||
self.stop_btn = C.ghost(tr("Stop"), ft.Icons.STOP_ROUNDED, self._stop_click)
|
||||
self.url = C.meta("", selectable=True)
|
||||
@@ -90,34 +88,28 @@ class LiveView:
|
||||
if not (app.target and app.frame_state == "connected"):
|
||||
return ft.Column([
|
||||
app.top_bar(heading, sub),
|
||||
C.empty_state(G.LIVE, tr("Connect your Frame first"),
|
||||
C.empty_state(ft.Icons.CAST_ROUNDED, tr("Connect your Frame first"),
|
||||
tr("The live view can start once FramePort is connected to the Frame."),
|
||||
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")))], expand=True)
|
||||
if self.root is None:
|
||||
parts = C.transit_parts()
|
||||
h = T.px(56)
|
||||
self.idle = ft.Container(ft.Column([
|
||||
ft.Row([ft.Image(src=parts["portal"], height=h, width=h * 24 / 50, fit=ft.BoxFit.CONTAIN),
|
||||
ft.Image(src=parts["headset"], height=h * 0.86, fit=ft.BoxFit.CONTAIN)],
|
||||
spacing=T.S3, tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
C.body(tr("Start the live view to watch the headset in your browser"), T.TEXT_2,
|
||||
text_align=ft.TextAlign.CENTER),
|
||||
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S4, tight=True),
|
||||
alignment=ft.Alignment.CENTER, expand=True, padding=T.S6)
|
||||
self.root = ft.Column([
|
||||
app.top_bar(heading, sub),
|
||||
ft.Row([C.meta(tr("The headset's view and sound open in your web browser. Stop the stream when "
|
||||
"you're done."), T.TEXT_2), C.help_icon("live_view", 16)],
|
||||
spacing=T.px(2), tight=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
# status, Start/Open/Stop and Quality in one bar
|
||||
C.card(ft.Column([
|
||||
ft.Row([self.dot, ft.Column([self.state, self.detail], spacing=T.px(2), expand=True),
|
||||
self.quality_dd, self.start_btn, self.open_btn, self.stop_btn],
|
||||
spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
ft.Row([self.dot, self.state], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.detail,
|
||||
ft.Container(height=T.S2),
|
||||
ft.Row([self.start_btn, self.open_btn, self.stop_btn, self.quality_dd], # (no expand child: wraps)
|
||||
spacing=T.S3, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.url,
|
||||
], spacing=T.S2)),
|
||||
self.idle,
|
||||
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, expand=True)
|
||||
C.callout(tr("The picture opens in your default web browser, where it plays smoothly and can go "
|
||||
"full screen. It shows the headset's view with its sound "
|
||||
"(click Sound on in the player) whatever is running: Steam's menus, SteamVR or a game; "
|
||||
"while the headset sleeps the picture is black and updates about once a second. "
|
||||
"The Frame's hardware video encoder does the work (if it isn't available, the "
|
||||
"processor does, which costs a game more); stop the stream when you're done.")),
|
||||
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
self._refresh(update=False)
|
||||
self._ensure_ticker()
|
||||
return self.root
|
||||
@@ -142,12 +134,11 @@ class LiveView:
|
||||
self.quality_dd.disabled = running or self._busy
|
||||
self.url.value = (tr("Viewer address on this PC: {url}").format(url=live.url) if running else "")
|
||||
self.url.visible = running
|
||||
self.idle.visible = live is None
|
||||
if update: # called from the ticker / start / stream-end threads: send through Flet's event loop
|
||||
if self._push is None:
|
||||
self._push = C.LoopUpdater(self.app.page)
|
||||
self._push(self.dot, self.state, self.detail, self.start_btn, self.open_btn, self.stop_btn,
|
||||
self.quality_dd, self.url, self.idle)
|
||||
self.quality_dd, self.url)
|
||||
|
||||
def _ensure_ticker(self) -> None:
|
||||
if self._ticker and self._ticker.is_alive():
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Monitor: a live view of the Frame — the running game (fps, CPU/GPU/memory), CPU, GPU, memory, temperatures, power
|
||||
and battery with 2-minute sparklines, and its processes (end or kill one, end a game). Fed by the app's MonitorHub
|
||||
(frame/monitor_hub.py; one agent `_monitor` stream shared with the sidebar's live Frame card): the view subscribes to
|
||||
everything only while the tab is shown (app.go / app.disconnect / closing the window call stop()); the hub connects,
|
||||
reconnects and ends the stream when nobody needs it.
|
||||
and battery with 2-minute sparklines, and its processes (end or kill one, end a game). Streamed by the agent's
|
||||
`_monitor` session (frame/monitor.py) only while the tab is shown: app.go / app.disconnect / closing the window call
|
||||
stop(), and the agent ends the stream at EOF, so nothing keeps running on the Frame.
|
||||
|
||||
Built once and updated in place (CLAUDE.md performance rules): a tick changes values, colours and sparkline paths;
|
||||
the process table is a fixed pool of rows re-bound to the newest data, so clicks and menus keep working."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
@@ -23,21 +23,11 @@ if TYPE_CHECKING:
|
||||
|
||||
ROWS = 50 # process rows shown at most (the agent sends up to 150; the search narrows them)
|
||||
DETAIL_HEIGHT = 250 # the three detail cards share one height (px at 100 %)
|
||||
LEVEL_COLOR: dict[str, str] = {}
|
||||
GROUP_COLOR: dict[str, str] = {}
|
||||
CLUSTER_COLORS: list[str] = []
|
||||
POWER_COLORS: list[str] = [] # CPU, GPU, NPU, other
|
||||
|
||||
|
||||
def _fill_colors() -> None:
|
||||
"""(Re)fill the colour maps with the active theme's colours, in place (theme switches)."""
|
||||
LEVEL_COLOR.update({"ok": T.TEXT, "warn": T.WARN, "error": T.ERROR})
|
||||
GROUP_COLOR.update({"Game": T.ACCENT, "Steam": T.INFO, "SteamVR": T.PC, "Desktop": T.TEXT_2, "Other": T.TEXT_3})
|
||||
CLUSTER_COLORS[:] = [T.INFO, T.ACCENT, T.PC, T.OK]
|
||||
POWER_COLORS[:] = [T.INFO, T.ACCENT, T.PC, T.TEXT_3]
|
||||
|
||||
|
||||
T.on_change(_fill_colors)
|
||||
RETRY_SECONDS = 5 # reconnect after the stream was lost, while the tab is open
|
||||
LEVEL_COLOR = {"ok": T.TEXT, "warn": T.WARN, "error": T.ERROR}
|
||||
GROUP_COLOR = {"Game": T.ACCENT, "Steam": T.INFO, "SteamVR": T.PC, "Desktop": T.TEXT_2, "Other": T.TEXT_3}
|
||||
CLUSTER_COLORS = (T.INFO, T.ACCENT, T.PC, T.OK)
|
||||
POWER_COLORS = (T.INFO, T.ACCENT, T.PC, T.TEXT_3) # CPU, GPU, NPU, other
|
||||
|
||||
|
||||
def label(name: str) -> str:
|
||||
@@ -131,9 +121,9 @@ class ProcRow:
|
||||
self.group_box.bgcolor = T.soft(color, 0.12)
|
||||
self.lock.visible = bool(p.get("critical") or p.get("locked"))
|
||||
self.lock.color = T.TEXT_3 if p.get("locked") else T.WARN
|
||||
self.cpu.value = f"{p.get('cpu', 0):.1f}%"
|
||||
self.cpu.value = f"{p.get('cpu', 0):.1f} %"
|
||||
self.cpu.color = LEVEL_COLOR[M.level("cpu", p.get("cpu"))] if p.get("cpu", 0) >= 1 else T.TEXT_2
|
||||
self.gpu.value = f"{p['gpu']:.0f}%" if p.get("gpu") else "–"
|
||||
self.gpu.value = f"{p['gpu']:.0f} %" if p.get("gpu") else "–"
|
||||
self.mem.value = M.fmt_bytes(p.get("rss"))
|
||||
self.age.value = M.fmt_duration(p.get("age"))
|
||||
|
||||
@@ -141,7 +131,10 @@ class ProcRow:
|
||||
class MonitorView:
|
||||
def __init__(self, app: FramePortApp):
|
||||
self.app = app
|
||||
self.stopped = True # not subscribed to the app's monitor hub (the tab isn't shown)
|
||||
self.session = None # frame.monitor.MonitorSession while the tab is open
|
||||
self._connecting = False
|
||||
self._gen = 0 # bumped by stop(): a connect that finishes after the tab was left closes itself
|
||||
self.stopped = True
|
||||
self.root = None
|
||||
self.history = M.History()
|
||||
self.static: dict = {}
|
||||
@@ -158,13 +151,14 @@ class MonitorView:
|
||||
|
||||
# ---------------------------------------------------------------- building
|
||||
def _build(self) -> None:
|
||||
self.live_dot = C.dot(T.TEXT_3, 8)
|
||||
self.live_dot = C.dot(T.TEXT_3, T.px(8))
|
||||
self.live_text = C.meta(tr("Connecting…"))
|
||||
self.live = ft.Container(ft.Row([self.live_dot, self.live_text], spacing=T.S2, tight=True),
|
||||
bgcolor=T.SURFACE_3, border_radius=T.px(20),
|
||||
padding=ft.Padding(T.px(10), T.px(5), T.px(12), T.px(5)))
|
||||
self.overhead = C.meta("")
|
||||
self.interval = C.dropdown(value=f"{M.DEFAULT_INTERVAL:g}", width=T.px(150),
|
||||
self.interval = ft.Dropdown(value=f"{M.DEFAULT_INTERVAL:g}", dense=True, width=T.px(150),
|
||||
border_color=T.BORDER, text_size=T.T_BODY,
|
||||
options=[ft.DropdownOption(f"{s:g}", tr("Every {n}").format(n=M.fmt_interval(s)))
|
||||
for s in M.INTERVALS],
|
||||
on_select=self._set_interval, tooltip=tr("How often the Frame sends new numbers"))
|
||||
@@ -201,7 +195,7 @@ class MonitorView:
|
||||
# metric tiles
|
||||
self.t_cpu = Tile(tr("CPU"), ft.Icons.MEMORY_ROUNDED, T.INFO, hi=100)
|
||||
self.t_gpu = Tile(tr("GPU"), ft.Icons.VIEW_IN_AR_ROUNDED, T.ACCENT, hi=100, help_key="monitor_gpu")
|
||||
self.t_mem = Tile(tr("Memory"), ft.Icons.STORAGE_OUTLINED, T.PC, hi=100, help_key="monitor_pressure")
|
||||
self.t_mem = Tile(tr("Memory"), ft.Icons.STORAGE_ROUNDED, T.PC, hi=100, help_key="monitor_pressure")
|
||||
self.t_temp = Tile(tr("Temperature"), ft.Icons.THERMOSTAT_ROUNDED, T.WARN)
|
||||
self.t_power = Tile(tr("Power"), ft.Icons.BOLT_ROUNDED, T.OK, help_key="monitor_power")
|
||||
self.t_bat = Tile(tr("Battery"), ft.Icons.BATTERY_STD_ROUNDED, T.OK, hi=100)
|
||||
@@ -232,10 +226,13 @@ class MonitorView:
|
||||
self.details_btn = C.ghost(tr("Show details"), ft.Icons.EXPAND_MORE_ROUNDED, lambda e: self.toggle_details())
|
||||
|
||||
# processes
|
||||
self.filter = C.segmented([("game", tr("Game")), ("steam", tr("Steam & SteamVR")), ("all", tr("All"))],
|
||||
"game", self._set_filter) # .data = the selected filter
|
||||
self.search = C.search(hint_text=tr("Search name, PID or game"), width=T.px(240),
|
||||
on_change=lambda e: self._bind_rows(update=True))
|
||||
self.filter = ft.SegmentedButton(
|
||||
segments=[ft.Segment("game", label=tr("Game")), ft.Segment("steam", label=tr("Steam & SteamVR")),
|
||||
ft.Segment("all", label=tr("All"))],
|
||||
selected=["game"], show_selected_icon=False, on_change=self._set_filter)
|
||||
self.search = ft.TextField(hint_text=tr("Search name, PID or game"), dense=True, width=T.px(240),
|
||||
border_color=T.BORDER, prefix_icon=ft.Icons.SEARCH_ROUNDED,
|
||||
on_change=lambda e: self._bind_rows(update=True))
|
||||
self.sort_btns: dict[str, ft.TextButton] = {}
|
||||
left, right = ft.Alignment.CENTER_LEFT, ft.Alignment.CENTER_RIGHT
|
||||
header_cells = [ft.Container(self._sort_btn("name", tr("Name")), expand=True, alignment=left),
|
||||
@@ -251,8 +248,7 @@ class MonitorView:
|
||||
self.rows = [ProcRow(self) for _ in range(ROWS)]
|
||||
self.proc_note = C.meta("")
|
||||
self.no_game = C.callout(tr("No FramePort game is running. Start one in the headset or with Play, or switch "
|
||||
"the filter to see everything on the Frame."),
|
||||
icon=ft.Icons.SPORTS_ESPORTS_OUTLINED)
|
||||
"the filter to see everything on the Frame."), icon=ft.Icons.SPORTS_ESPORTS_ROUNDED)
|
||||
self.no_game.visible = False
|
||||
self.menu = ft.ContextMenu(content=C.card(ft.Column(
|
||||
[self.header, ft.Column([r.control for r in self.rows], spacing=0), self.proc_note],
|
||||
@@ -292,63 +288,74 @@ class MonitorView:
|
||||
return self.root
|
||||
|
||||
# ---------------------------------------------------------------- session
|
||||
@property
|
||||
def hub(self):
|
||||
return self.app.monitor_hub
|
||||
|
||||
def start(self) -> None:
|
||||
"""Subscribe to the app's monitor hub (everything, at the chosen interval); the hub connects, reconnects
|
||||
after a lost stream and shares it with the sidebar's live Frame card."""
|
||||
self.stopped = False
|
||||
hub = self.hub
|
||||
if hub.subscribed("monitor"):
|
||||
hub.reconnect() # the Reconnect button (after an error)
|
||||
if self.session is not None or self._connecting:
|
||||
return
|
||||
self._connecting = True
|
||||
self._set_live(tr("Connecting…"), T.TEXT_3, update=self.root is not None)
|
||||
hub.set_filter(self.filter.data)
|
||||
hub.subscribe("monitor", "all", float(self.interval.value), self._on_sample, self._on_state)
|
||||
if self.paused:
|
||||
hub.pause("monitor", True)
|
||||
gen, target = self._gen, self.app.target
|
||||
self.app.run_bg(self._connect, gen, target)
|
||||
|
||||
def _on_state(self, name: str, state: str, detail) -> None:
|
||||
"""The hub's stream state (its connect/reader/timer threads)."""
|
||||
if self.stopped:
|
||||
def _connect(self, gen: int, target) -> None:
|
||||
try:
|
||||
session = M.MonitorSession(target.frame, lambda s, g=gen: self._on_sample(g, s),
|
||||
lambda why, g=gen: self._on_end(g, why))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._connecting = False
|
||||
if gen == self._gen:
|
||||
self._set_live(tr("Not connected: {error}").format(error=explain(exc)), T.ERROR)
|
||||
return
|
||||
if state == "connecting":
|
||||
self._set_live(tr("Connecting…"), T.TEXT_3)
|
||||
elif state == "live":
|
||||
static = self.hub.static
|
||||
if static and static != self.static:
|
||||
self.static = static
|
||||
self._build_static()
|
||||
self._set_live(tr("Live"), T.OK)
|
||||
elif state == "lost":
|
||||
self._set_live(tr("Connection lost, reconnecting…"), T.WARN)
|
||||
elif state == "error":
|
||||
self._set_live(tr("Not connected: {error}").format(error=explain(detail)), T.ERROR)
|
||||
self._connecting = False
|
||||
if gen != self._gen: # the tab was left while connecting
|
||||
session.close()
|
||||
return
|
||||
self.session = session
|
||||
if session.static != self.static:
|
||||
self.static = session.static
|
||||
self._build_static()
|
||||
if self.interval.value != f"{M.DEFAULT_INTERVAL:g}":
|
||||
session.set_interval(float(self.interval.value))
|
||||
if self.filter.selected != ["game"]:
|
||||
session.set_filter(self.filter.selected[0])
|
||||
if self.paused:
|
||||
session.pause(True)
|
||||
self._set_live(tr("Live"), T.OK)
|
||||
|
||||
def _on_end(self, gen: int, why: str) -> None:
|
||||
if gen != self._gen:
|
||||
return
|
||||
self.session = None
|
||||
self._set_live(tr("Connection lost, reconnecting…"), T.WARN)
|
||||
timer = threading.Timer(RETRY_SECONDS, lambda: gen == self._gen and not self.stopped and self.start())
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop receiving (leaving the tab, disconnecting, closing the app); the hub ends the stream when nobody else
|
||||
(the live Frame card) needs it."""
|
||||
"""End the stream (leaving the tab, disconnecting, closing the app)."""
|
||||
self._gen += 1
|
||||
self.stopped = True
|
||||
self.hub.unsubscribe("monitor")
|
||||
session, self.session = self.session, None
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def _set_live(self, text: str, color: str, update: bool = True) -> None:
|
||||
self.live_text.value, self.live_dot.bgcolor = text, color
|
||||
self.reconnect.visible = color == T.ERROR
|
||||
if update:
|
||||
self._updater()(self.live, self.reconnect)
|
||||
C.update(self.live, self.reconnect)
|
||||
|
||||
def _set_interval(self, e) -> None:
|
||||
self.hub.update("monitor", interval=float(self.interval.value))
|
||||
if self.session:
|
||||
self.session.set_interval(float(self.interval.value))
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
self.paused = not self.paused
|
||||
self.hub.pause("monitor", self.paused) # paused: no samples here (the card keeps its own)
|
||||
if self.session:
|
||||
self.session.pause(self.paused)
|
||||
self.pause_btn.icon = ft.Icons.PLAY_ARROW_ROUNDED if self.paused else ft.Icons.PAUSE_ROUNDED
|
||||
self.pause_btn.tooltip = tr("Resume") if self.paused else tr("Pause")
|
||||
if self.paused:
|
||||
self._set_live(tr("Paused"), T.TEXT_3, update=False)
|
||||
self._set_live(tr("Paused") if self.paused else tr("Live"), T.TEXT_3 if self.paused else T.OK, update=False)
|
||||
C.update(self.pause_btn, self.live)
|
||||
|
||||
def toggle_details(self) -> None:
|
||||
@@ -359,8 +366,10 @@ class MonitorView:
|
||||
self.details_btn.icon = ft.Icons.EXPAND_LESS_ROUNDED if self.details.visible else ft.Icons.EXPAND_MORE_ROUNDED
|
||||
C.update(self.details, self.details_btn)
|
||||
|
||||
def _set_filter(self, which: str) -> None:
|
||||
self.hub.set_filter(which)
|
||||
def _set_filter(self, e) -> None:
|
||||
which = (self.filter.selected or ["game"])[0]
|
||||
if self.session:
|
||||
self.session.set_filter(which)
|
||||
|
||||
def set_sort(self, key: str) -> None:
|
||||
if self.sort_key == key:
|
||||
@@ -413,7 +422,7 @@ class MonitorView:
|
||||
for color, name in zip(POWER_COLORS, ("CPU", "GPU", "NPU", tr("Other")), strict=True):
|
||||
t = C.meta(f"{name} –")
|
||||
self.legend_texts.append(t)
|
||||
legend.append(ft.Row([C.dot(color, 8), t], spacing=T.px(6), tight=True))
|
||||
legend.append(ft.Row([C.dot(color, T.px(8)), t], spacing=T.px(6), tight=True))
|
||||
self.power_legend.controls = legend
|
||||
self.net.controls, self.net_texts = [], {}
|
||||
# through the event loop like the ticks: sent directly from this (connect) thread, the patch adding these
|
||||
@@ -428,8 +437,8 @@ class MonitorView:
|
||||
return self.push
|
||||
|
||||
# ---------------------------------------------------------------- samples
|
||||
def _on_sample(self, s: dict) -> None:
|
||||
if self.stopped or self.root is None:
|
||||
def _on_sample(self, gen: int, s: dict) -> None:
|
||||
if gen != self._gen or self.root is None:
|
||||
return
|
||||
self.last_sample = s
|
||||
for name, value in M.series_of(s).items():
|
||||
@@ -439,7 +448,7 @@ class MonitorView:
|
||||
self.procs = s.get("procs") or []
|
||||
changed.append(self._bind_rows(update=False))
|
||||
cpu_share = (s.get("self_ms") or 0) / 10 / max(1.0, s.get("dt") or 1.0) / max(1, self.static.get("cores", 1))
|
||||
self.overhead.value = tr("Monitor is using {pct}% of the Frame's CPU").format(pct=f"{cpu_share:.2f}")
|
||||
self.overhead.value = tr("Monitor is using {pct} % of the Frame's CPU").format(pct=f"{cpu_share:.2f}")
|
||||
self.overhead.tooltip = tr("The time the Frame spends collecting these numbers")
|
||||
self._updater()(self.overhead, *[c for group in changed for c in group])
|
||||
|
||||
@@ -461,7 +470,7 @@ class MonitorView:
|
||||
if swap > 64 * 1024 ** 2:
|
||||
sub += " · " + tr("swap {n}").format(n=M.fmt_bytes(swap))
|
||||
if psi:
|
||||
sub += " · " + tr("waiting {pct}%").format(pct=f"{psi:.0f}")
|
||||
sub += " · " + tr("waiting {pct} %").format(pct=f"{psi:.0f}")
|
||||
lvl = max((M.level("mem", 100.0 * used / total if total else None), M.level("psi_memory", psi)),
|
||||
key=("ok", "warn", "error").index)
|
||||
self.t_mem.set(M.fmt_pct(100.0 * used / total if total else None), sub, h.get("mem"), lvl)
|
||||
@@ -500,7 +509,7 @@ class MonitorView:
|
||||
game = games[0] if games else None
|
||||
was = self.game_card.visible
|
||||
self.game_card.visible = game is not None
|
||||
self.no_game.visible = game is None and self.filter.data == "game"
|
||||
self.no_game.visible = game is None and (self.filter.selected or ["game"])[0] == "game"
|
||||
if game is None:
|
||||
if self.game_pkg is not None:
|
||||
self.game_pkg = None
|
||||
@@ -546,7 +555,7 @@ class MonitorView:
|
||||
cores = (s.get("cpu") or {}).get("cores") or []
|
||||
for (bar, val), pct in zip(self.core_bars, cores, strict=False):
|
||||
bar.set([pct / 100.0])
|
||||
val.value = f"{pct:.0f}%"
|
||||
val.value = f"{pct:.0f} %"
|
||||
out += [bar.control, val]
|
||||
temps = s.get("temps") or {}
|
||||
zones = s.get("zones")
|
||||
@@ -611,13 +620,35 @@ class MonitorView:
|
||||
def open_menu(self, p: dict | None, position=None) -> None:
|
||||
if not p:
|
||||
return
|
||||
from ..menus import process_menu
|
||||
|
||||
name, pid = p.get("name", "?"), p.get("pid")
|
||||
handlers = {"toggle": lambda e: self.toggle_group(p), "copy_name": lambda e: self.app.copy(name),
|
||||
"copy_pid": lambda e: self.app.copy(str(pid)), "term": lambda e: self.ask_kill(p, "TERM"),
|
||||
"kill": lambda e: self.ask_kill(p, "KILL"), "end_game": lambda e: self.ask_end_game(p["game"])}
|
||||
self.menu.items = C.menu_items(process_menu(p, handlers.__getitem__))
|
||||
actions: list = []
|
||||
if p.get("kind") == "group":
|
||||
n = p["count"]
|
||||
actions.append((tr("Hide its processes") if p.get("expanded") else tr("Show its processes"),
|
||||
ft.Icons.UNFOLD_MORE_ROUNDED, lambda e: self.toggle_group(p)))
|
||||
if not p.get("locked"):
|
||||
actions += [None, (tr("End all {n}").format(n=n), ft.Icons.CLOSE_ROUNDED,
|
||||
lambda e: self.ask_kill(p, "TERM")),
|
||||
(tr("Force kill all {n}").format(n=n), ft.Icons.DANGEROUS_OUTLINED,
|
||||
lambda e: self.ask_kill(p, "KILL"))]
|
||||
if p.get("game"):
|
||||
actions += [None, (tr("End the whole game"), ft.Icons.STOP_CIRCLE_OUTLINED,
|
||||
lambda e: self.ask_end_game(p["game"]))]
|
||||
actions += [None, (tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(name))]
|
||||
self.menu.items = C.menu_items(actions)
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
return
|
||||
if not p.get("locked"):
|
||||
actions += [(tr("End process"), ft.Icons.CLOSE_ROUNDED, lambda e: self.ask_kill(p, "TERM")),
|
||||
(tr("Force kill"), ft.Icons.DANGEROUS_OUTLINED, lambda e: self.ask_kill(p, "KILL"))]
|
||||
if p.get("game"):
|
||||
actions += [None, (tr("End the whole game"), ft.Icons.STOP_CIRCLE_OUTLINED,
|
||||
lambda e: self.ask_end_game(p["game"]))]
|
||||
actions += [None, (tr("Copy PID {pid}").format(pid=pid), ft.Icons.CONTENT_COPY_ROUNDED,
|
||||
lambda e: self.app.copy(str(pid))),
|
||||
(tr("Copy name"), ft.Icons.CONTENT_COPY_ROUNDED, lambda e: self.app.copy(name))]
|
||||
self.menu.items = C.menu_items(actions)
|
||||
C.update(self.menu)
|
||||
self.app.page.run_task(self.menu.open, global_position=position)
|
||||
|
||||
@@ -625,13 +656,13 @@ class MonitorView:
|
||||
name, pid = p.get("name", "?"), p.get("pid")
|
||||
if p.get("kind") == "group":
|
||||
n = p["count"]
|
||||
heading = (tr("Force quit all {n} {name}?") if sig == "KILL" else tr("End all {n} {name}?")).format(
|
||||
heading = (tr("Force kill all {n} {name}?") if sig == "KILL" else tr("End all {n} {name}?")).format(
|
||||
n=n, name=name)
|
||||
text = tr("{n} processes named {name} with the same parent are ended.").format(n=n, name=name)
|
||||
if p.get("critical"):
|
||||
text += " " + tr("They are part of Steam, SteamVR or the desktop: the headset view or Steam may "
|
||||
"restart, and anything running in the headset may stop.")
|
||||
C.confirm(self.app.page, heading, text, tr("Force quit all") if sig == "KILL" else tr("End all"),
|
||||
C.confirm(self.app.page, heading, text, tr("Force kill all") if sig == "KILL" else tr("End all"),
|
||||
lambda: self.app.run_bg(self._kill, p, sig), danger=True)
|
||||
return
|
||||
if p.get("critical"):
|
||||
@@ -639,20 +670,20 @@ class MonitorView:
|
||||
text = tr("{name} is part of Steam, SteamVR or the desktop. Ending it can close the headset view or "
|
||||
"restart Steam, and anything running in the headset may stop.").format(name=name)
|
||||
elif sig == "KILL":
|
||||
heading = tr("Force quit {name}?").format(name=name)
|
||||
heading = tr("Force kill {name}?").format(name=name)
|
||||
text = tr("{name} (PID {pid}) stops at once without saving anything.").format(name=name, pid=pid)
|
||||
else:
|
||||
heading = tr("End {name}?").format(name=name)
|
||||
text = tr("{name} (PID {pid}) is asked to close.").format(name=name, pid=pid)
|
||||
if p.get("game"):
|
||||
text += " " + tr("It belongs to a running game, which may stop with it.")
|
||||
label = tr("Force quit") if sig == "KILL" else tr("End process")
|
||||
label = tr("Force kill") if sig == "KILL" else tr("End process")
|
||||
C.confirm(self.app.page, heading, text, label, lambda: self.app.run_bg(self._kill, p, sig),
|
||||
danger=sig == "KILL" or bool(p.get("critical")))
|
||||
|
||||
def _kill(self, p: dict, sig: str) -> None:
|
||||
session = self.hub
|
||||
if session.session is None:
|
||||
session = self.session
|
||||
if session is None:
|
||||
self.app.toast(tr("The monitor isn't connected."), error=True)
|
||||
return
|
||||
name = p.get("name", "?")
|
||||
@@ -666,7 +697,7 @@ class MonitorView:
|
||||
left.append(m["pid"])
|
||||
if left:
|
||||
self.app.toast(tr("{n} of the {name} processes are still running.").format(n=len(left), name=name),
|
||||
action=None if sig == "KILL" else tr("Force quit"),
|
||||
action=None if sig == "KILL" else tr("Force kill"),
|
||||
on_action=None if sig == "KILL" else lambda e: self.app.run_bg(self._kill, p, "KILL"))
|
||||
else:
|
||||
self.app.toast(tr("All {n} {name} processes ended.").format(n=p["count"], name=name))
|
||||
@@ -681,7 +712,7 @@ class MonitorView:
|
||||
if res.get("ended"):
|
||||
self.app.toast(tr("{name} ended.").format(name=name))
|
||||
else:
|
||||
self.app.toast(tr("{name} is still running.").format(name=name), action=tr("Force quit"),
|
||||
self.app.toast(tr("{name} is still running.").format(name=name), action=tr("Force kill"),
|
||||
on_action=lambda e: self.app.run_bg(self._kill, p, "KILL"))
|
||||
|
||||
def ask_end_game(self, pkg: str | None = None) -> None:
|
||||
@@ -695,8 +726,8 @@ class MonitorView:
|
||||
tr("End game"), lambda: self.app.run_bg(self._end_game, pkg, title), danger=True)
|
||||
|
||||
def _end_game(self, pkg: str, title: str) -> None:
|
||||
session = self.hub
|
||||
if session.session is None:
|
||||
session = self.session
|
||||
if session is None:
|
||||
self.app.toast(tr("The monitor isn't connected."), error=True)
|
||||
return
|
||||
self.end_btn.disabled = True
|
||||
|
||||
@@ -17,7 +17,6 @@ from ...artwork import thumbs
|
||||
from ...errors import explain
|
||||
from ...i18n import tr, tr_n
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
from .files_dialog import human
|
||||
|
||||
@@ -70,27 +69,24 @@ class ScreenshotsView:
|
||||
self.games: list[dict] = []
|
||||
self.selected: set[str] = set() # paths
|
||||
self.checks: dict[str, ft.Checkbox] = {}
|
||||
self.tiles: dict[str, tuple] = {} # path → (tile, quiet checkbox, selection overlay)
|
||||
self.hot: set[str] = set() # tiles under the mouse or with keyboard focus
|
||||
self._gen = 0
|
||||
self._loaded = False
|
||||
self._lock = threading.Lock()
|
||||
self.dropdown = C.dropdown(label=tr("Game"), value=ALL, width=T.px(300),
|
||||
self.dropdown = ft.Dropdown(label=tr("Game"), value=ALL, width=T.px(300), dense=True,
|
||||
options=[ft.DropdownOption(key=ALL, text=tr("All games"))],
|
||||
on_select=lambda e: self.set_filter(e.control.value))
|
||||
self.select_all = ft.Checkbox(value=False, active_color=T.ACCENT, check_color=T.ON_ACCENT,
|
||||
label=tr("Select all"), label_style=ft.TextStyle(color=T.TEXT_2, size=T.T_BODY),
|
||||
on_change=self._toggle_all)
|
||||
tooltip=tr("Select all"), on_change=self._toggle_all)
|
||||
self.toolbar = ft.Row([
|
||||
self.dropdown, self.select_all, ft.Container(expand=True),
|
||||
C.secondary(tr("Download all…"), ft.Icons.DOWNLOAD_ROUNDED, self._download_all),
|
||||
C.secondary(tr("Download all"), ft.Icons.DOWNLOAD_ROUNDED, self._download_all),
|
||||
C.icon_btn(ft.Icons.REFRESH_ROUNDED, tr("Refresh"), lambda e: self.load()),
|
||||
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
self.sel_label = C.body("", T.TEXT, weight=ft.FontWeight.W_500)
|
||||
self.sel_bar = ft.Container(ft.Row([
|
||||
self.sel_label, ft.Container(expand=True),
|
||||
C.secondary(tr("Download selected…"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete…"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self.delete(self._chosen())),
|
||||
C.secondary(tr("Download selected"), ft.Icons.DOWNLOAD_ROUNDED, self._download_selected),
|
||||
C.ghost(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, lambda e: self.delete(self._chosen())),
|
||||
C.ghost(tr("Clear"), ft.Icons.CLOSE_ROUNDED, lambda e: self._clear_selection()),
|
||||
], spacing=T.S2), padding=ft.Padding(T.S3, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.ACCENT_SOFT, visible=False)
|
||||
@@ -114,7 +110,7 @@ class ScreenshotsView:
|
||||
self._loaded = False # load again once connected
|
||||
return ft.Column([
|
||||
app.top_bar(heading, sub),
|
||||
C.empty_state(G.SHOT, tr("Connect your Frame first"),
|
||||
C.empty_state(ft.Icons.PHOTO_LIBRARY_OUTLINED, tr("Connect your Frame first"),
|
||||
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:
|
||||
@@ -161,7 +157,7 @@ class ScreenshotsView:
|
||||
self.shots, self.games = r.get("shots") or [], r.get("games") or []
|
||||
self.selected &= {s["path"] for s in self.shots}
|
||||
self._fill_dropdown()
|
||||
self.checks, self.tiles, self.hot = {}, {}, set()
|
||||
self.checks = {}
|
||||
self.grid.controls = []
|
||||
if not self.shots:
|
||||
empty = C.empty_state(
|
||||
@@ -228,22 +224,14 @@ 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))
|
||||
check.on_focus = lambda e, p=s["path"]: self._set_hot(p, True)
|
||||
check.on_blur = lambda e, p=s["path"]: self._set_hot(p, False)
|
||||
quiet_check = C.quiet(check)
|
||||
quiet_check.left, quiet_check.top = 0, 0
|
||||
overlay = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS_SM)
|
||||
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),
|
||||
overlay, caption, quiet_check]),
|
||||
caption, ft.Container(check, left=0, top=0)]),
|
||||
width=T.px(256), height=T.px(144), border_radius=T.RADIUS_SM, ink=True,
|
||||
border=ft.Border.all(2, ft.Colors.TRANSPARENT),
|
||||
tooltip=f"{s.get('title') or ''} · {day_label(s.get('time'))} {when}",
|
||||
on_click=lambda e, i=i: self.viewer(i),
|
||||
on_hover=lambda e, p=s["path"]: self._hover(p, e.data in (True, "true")))
|
||||
self.tiles[s["path"]] = (tile, quiet_check, overlay)
|
||||
self._paint(s["path"])
|
||||
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))
|
||||
|
||||
@@ -304,54 +292,46 @@ class ScreenshotsView:
|
||||
# ---------------------------------------------------------------- selection
|
||||
def _toggle(self, path: str, on: bool) -> None:
|
||||
(self.selected.add if on else self.selected.discard)(path)
|
||||
check = self.checks.get(path)
|
||||
if check is not None and check.value != on:
|
||||
check.value = on
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
def _toggle_all(self, e) -> None:
|
||||
self.selected = {s["path"] for s in self.shots} if e.control.value else set()
|
||||
on = bool(e.control.value)
|
||||
self.selected = {s["path"] for s in self.shots} if on else set()
|
||||
for check in self.checks.values():
|
||||
if check.value != on:
|
||||
check.value = on
|
||||
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():
|
||||
if check.value:
|
||||
check.value = False
|
||||
C.update(check)
|
||||
self._update_selection()
|
||||
|
||||
# quiet tiles: the checkbox shows on hover, keyboard focus or while anything is selected
|
||||
def _hover(self, path: str, inside: bool) -> None:
|
||||
self.drag.hover(path, inside)
|
||||
self._set_hot(path, inside)
|
||||
|
||||
def _set_hot(self, path: str, on: bool) -> None:
|
||||
(self.hot.add if on else self.hot.discard)(path)
|
||||
if path in self.tiles and self._paint(path):
|
||||
C.update(self.tiles[path][0])
|
||||
|
||||
def _paint(self, path: str) -> bool:
|
||||
"""Properties only: checkbox value + visibility, the accent border and the selection fade. True = changed."""
|
||||
tile, quiet_check, overlay = self.tiles[path]
|
||||
on = path in self.selected
|
||||
check = self.checks[path]
|
||||
before = (check.value, quiet_check.opacity, overlay.bgcolor, overlay.gradient is not None)
|
||||
check.value = on
|
||||
C.reveal(quiet_check, on or path in self.hot or bool(self.selected))
|
||||
C.selected_style(overlay, on, subtle=True)
|
||||
tile.border = ft.Border.all(2, T.ACCENT if on else ft.Colors.TRANSPARENT)
|
||||
return before != (check.value, quiet_check.opacity, overlay.bgcolor, overlay.gradient is not None)
|
||||
|
||||
def _update_selection(self, render: bool = True) -> None:
|
||||
n = len(self.selected)
|
||||
size = sum(s.get("size") or 0 for s in self._chosen())
|
||||
self.sel_label.value = tr("{n} selected").format(n=n) + (f" · {human(size)}" if size else "")
|
||||
self.sel_bar.visible = bool(n)
|
||||
self.select_all.value = bool(self.shots) and n == len(self.shots)
|
||||
changed = [path for path in self.tiles if self._paint(path)]
|
||||
if render:
|
||||
for c in (self.sel_bar, self.select_all):
|
||||
C.update(c)
|
||||
for path in changed:
|
||||
C.update(self.tiles[path][0])
|
||||
|
||||
def _chosen(self) -> list[dict]:
|
||||
return [s for s in self.shots if s["path"] in self.selected]
|
||||
@@ -397,15 +377,16 @@ class ScreenshotsView:
|
||||
def delete(e):
|
||||
page.pop_dialog()
|
||||
self.delete([shots[state["i"]]])
|
||||
page.show_dialog(C.viewer(
|
||||
ft.Container(ft.Column([img, ft.Row([
|
||||
page.show_dialog(ft.AlertDialog(
|
||||
content=ft.Container(ft.Column([img, ft.Row([
|
||||
C.icon_btn(ft.Icons.CHEVRON_LEFT_ROUNDED, tr("Previous"), lambda e: show(-1)),
|
||||
ft.Column([caption, details], spacing=0, expand=True),
|
||||
C.icon_btn(ft.Icons.CHEVRON_RIGHT_ROUNDED, tr("Next"), lambda e: show(1)),
|
||||
C.secondary(tr("Download…"), ft.Icons.DOWNLOAD_ROUNDED, download),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete…"), delete),
|
||||
C.secondary(tr("Download"), ft.Icons.DOWNLOAD_ROUNDED, download),
|
||||
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Delete"), delete),
|
||||
C.ghost(tr("Close"), on_click=lambda e: page.pop_dialog())], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)], spacing=T.S2, tight=True), width=T.px(1100))))
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)], spacing=T.S2, tight=True), width=T.px(1100)),
|
||||
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
|
||||
show()
|
||||
|
||||
# ---------------------------------------------------------------- actions
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Settings: the portable toolchain (incl. Revive), this PC for PC VR games, data folder, about."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
@@ -12,7 +11,6 @@ from ...errors import explain
|
||||
from ...i18n import fmt_size, tr, tr_n
|
||||
from ...recommend import catalog
|
||||
from .. import components as C
|
||||
from .. import glyphs as G
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,20 +19,6 @@ if TYPE_CHECKING:
|
||||
TOOL_TITLES = {"java": tr("Java runtime"), "overport": "OVRPort", "apksigner": "apksigner", "revive": tr("Revive")}
|
||||
TOOL_WHY = {"java": tr("Runs OVRPort and apksigner"), "overport": tr("Converts Quest games to OpenXR"),
|
||||
"apksigner": tr("Signs rebuilt games"), "revive": tr("Runs Oculus PC games on OpenXR")}
|
||||
# the page's sections in order (the index on the left lists them): Appearance near the top (it's the most used),
|
||||
# "Remove FramePort" last
|
||||
SECTIONS = ("appearance", "updates", "links", "installing", "tools", "pc", "data", "feedback", "about", "remove")
|
||||
|
||||
|
||||
def section_titles() -> dict[str, str]:
|
||||
"""Index labels of SECTIONS (the sections' own headings may be longer)."""
|
||||
return {"appearance": tr("Appearance"), "updates": tr("Updates"), "links": tr("Install links"),
|
||||
"installing": tr("Installing"), "tools": tr("Tools"), "pc": tr("This PC"), "data": tr("Data"),
|
||||
"feedback": tr("Problems and feedback"), "about": tr("About"), "remove": tr("Remove FramePort")}
|
||||
|
||||
|
||||
def scroll_key(section: str) -> str:
|
||||
return f"settings-{section}"
|
||||
|
||||
|
||||
class SettingsView:
|
||||
@@ -42,8 +26,6 @@ class SettingsView:
|
||||
self.app = app
|
||||
self.tools = ft.Column(spacing=0)
|
||||
self.pc = ft.Column(spacing=0)
|
||||
self.page_col: ft.Column | None = None # the scrolling column of sections (build())
|
||||
self.index: dict[str, ft.Container] = {} # section -> its entry in the index
|
||||
|
||||
def fill_tools(self, check_latest=False):
|
||||
from ...tools import toolchain
|
||||
@@ -59,37 +41,37 @@ class SettingsView:
|
||||
detail += " · " + s.detail.split("·")[-1].strip()
|
||||
else:
|
||||
detail += tr(" · downloaded when first needed") if s.optional else tr(" · not installed yet")
|
||||
rows.append(C.Check(True if s.installed else ("warn" if s.optional else False),
|
||||
TOOL_TITLES.get(s.name, s.name), detail, "revive" if s.name == "revive" else None,
|
||||
None if s.optional else (tr("Install"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
self.app.update_tools)))
|
||||
self.tools.controls = [C.checklist(rows)]
|
||||
rows.append(C.status_row(True if s.installed else (None if s.optional else False),
|
||||
TOOL_TITLES.get(s.name, s.name), detail,
|
||||
help="revive" if s.name == "revive" else None))
|
||||
self.tools.controls = rows
|
||||
C.update(self.tools)
|
||||
|
||||
def fill_pc(self):
|
||||
from ...core import winhost
|
||||
|
||||
if not winhost.available():
|
||||
items = [C.Check("warn", tr("Windows not detected"),
|
||||
tr("PC VR games can run on this PC only with Windows (or WSL on Windows)"))]
|
||||
self.pc.controls = [C.status_row(
|
||||
None, tr("Windows not detected"),
|
||||
tr("PC VR games can run on this PC only with Windows (or WSL on Windows)"))]
|
||||
else:
|
||||
try:
|
||||
from ...targets.pc_revive import PcReviveTarget
|
||||
|
||||
d = PcReviveTarget().describe()
|
||||
items = [
|
||||
C.Check(bool(d["steam"]), tr("Steam"),
|
||||
tr("Found") if d["steam"] else tr("Steam for Windows not found")),
|
||||
C.Check(True if d["steamvr"] else "warn", tr("SteamVR"),
|
||||
tr("Installed") if d["steamvr"] else tr("Install SteamVR from Steam to play PC VR games"),
|
||||
"steamvr_pc"),
|
||||
C.Check(bool(d["revive"]), tr("Revive"), (f"{d['revive_version']} · {d['revive']}"
|
||||
if d["revive"] else tr("Downloaded when first needed")),
|
||||
"revive"),
|
||||
self.pc.controls = [
|
||||
C.status_row(bool(d["steam"]), tr("Steam"),
|
||||
tr("Found") if d["steam"] else tr("Steam for Windows not found")),
|
||||
C.status_row(d["steamvr"] or None, tr("SteamVR"),
|
||||
tr("Installed") if d["steamvr"]
|
||||
else tr("Install SteamVR from Steam to play PC VR games"),
|
||||
help="steamvr_pc"),
|
||||
C.status_row(bool(d["revive"]), tr("Revive"), (f"{d['revive_version']} · {d['revive']}"
|
||||
if d["revive"] else tr("Downloaded when first needed")),
|
||||
help="revive"),
|
||||
]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
items = [C.Check(False, tr("Couldn't check this PC"), explain(exc))]
|
||||
self.pc.controls = [C.checklist(items)]
|
||||
self.pc.controls = [C.status_row(False, tr("Couldn't check this PC"), explain(exc))]
|
||||
C.update(self.pc)
|
||||
|
||||
def installing(self) -> ft.Control:
|
||||
@@ -111,7 +93,7 @@ class SettingsView:
|
||||
launch = C.switch(tr("Launch test after installing on the Frame (starts the game once without the headset "
|
||||
"and checks its log)"), value=bool(library.setting("install.launch_test", True)),
|
||||
on_change=changed)
|
||||
return ft.Column([launch, keep, C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED,
|
||||
return ft.Column([launch, keep, C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_ROUNDED,
|
||||
clean, tooltip=tr("Your own game files aren't touched."))],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
@@ -124,7 +106,7 @@ class SettingsView:
|
||||
|
||||
app = self.app
|
||||
last = library.setting("update.last_check")
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else tr("never")
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else "never"
|
||||
found = app.updater.found
|
||||
status = (C.callout(ft.Row([C.body(tr("FramePort {version} is available.").format(version=found.version),
|
||||
T.TEXT, expand=True),
|
||||
@@ -138,8 +120,8 @@ class SettingsView:
|
||||
|
||||
def auto_install(e):
|
||||
library.set_setting("update.auto_install", bool(e.control.value))
|
||||
kind = {"bundle": tr("the downloaded app"), "source": tr("a source checkout (git pull + uv sync)"),
|
||||
"wheel": tr("an installed Python package (reinstalled from the release)")}[updates.install_kind()]
|
||||
kind = {"bundle": "the downloaded app", "source": "a source checkout (git pull + uv sync)",
|
||||
"wheel": "an installed Python package (reinstalled from the release)"}[updates.install_kind()]
|
||||
return ft.Column([
|
||||
ft.Row([C.kv(tr("Installed"), tr("FramePort {version} · {kind}").format(version=__version__, kind=kind)),
|
||||
ft.Container(expand=True),
|
||||
@@ -152,7 +134,7 @@ class SettingsView:
|
||||
"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…"), G.TEST,
|
||||
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)
|
||||
@@ -167,7 +149,7 @@ class SettingsView:
|
||||
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 recipes from GitHub: last checked {when}").format(when=when)
|
||||
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"]))
|
||||
|
||||
@@ -178,7 +160,7 @@ class SettingsView:
|
||||
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 recipes from GitHub automatically (confirmed recipes arrive without a "
|
||||
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)
|
||||
@@ -243,95 +225,6 @@ class SettingsView:
|
||||
app.run_bg(lambda: show(urlhandler.status()))
|
||||
return ft.Column(controls, spacing=T.S4)
|
||||
|
||||
def theme_picker(self) -> ft.Control:
|
||||
"""One card per colour theme (built-in and installed theme files: a swatch of its window and its two portal
|
||||
colours); a click switches at once. Theme files are installed from here (docs/THEMES.md)."""
|
||||
from ...core import library
|
||||
from ..app import themes_dir
|
||||
|
||||
app = self.app
|
||||
about = {"portal": tr("Orange for actions, blue for the PC side: the logo's two portals"),
|
||||
"portal_oled": tr("Portal on true black, for OLED screens"),
|
||||
"original": tr("FramePort's original violet")}
|
||||
|
||||
def pick(tid: str):
|
||||
if tid == T.THEME:
|
||||
return
|
||||
library.set_setting("ui.theme", tid)
|
||||
app.restyle(tid)
|
||||
|
||||
def remove(tid: str):
|
||||
name = T.THEMES[tid]["name"]
|
||||
try:
|
||||
T.remove_theme(tid)
|
||||
except (T.ThemeError, OSError) as exc:
|
||||
app.toast(tr("Couldn't remove the theme: {error}").format(error=exc), error=True)
|
||||
return
|
||||
if tid == T.THEME:
|
||||
library.set_setting("ui.theme", T.DEFAULT_THEME)
|
||||
app.restyle(T.DEFAULT_THEME)
|
||||
else:
|
||||
app.render()
|
||||
app.toast(tr("Removed the theme \"{name}\"").format(name=name))
|
||||
|
||||
async def install(e):
|
||||
files = await ft.FilePicker().pick_files(dialog_title=tr("Theme file to install"),
|
||||
allowed_extensions=["json"])
|
||||
path = next((f.path for f in files or [] if f.path), None)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
tid = T.install_theme(Path(path), themes_dir())
|
||||
except (T.ThemeError, OSError) as exc:
|
||||
app.toast(tr("That theme file can't be used: {error}").format(error=exc), error=True)
|
||||
return
|
||||
library.set_setting("ui.theme", tid)
|
||||
app.restyle(tid)
|
||||
app.toast(tr("Installed the theme \"{name}\"").format(name=T.THEMES[tid]["name"]))
|
||||
|
||||
def card(tid: str) -> ft.Control:
|
||||
theme, on = T.THEMES[tid], tid == T.THEME
|
||||
c = theme["colors"]
|
||||
dot = lambda color: ft.Container(width=T.px(18), height=T.px(18), border_radius=T.px(9), # noqa: E731
|
||||
bgcolor=color)
|
||||
bar = ft.Container(height=T.px(6), border_radius=T.px(3), expand=True,
|
||||
gradient=ft.LinearGradient(colors=[c["SECONDARY"], c["ACCENT"]]) if theme["dual"]
|
||||
else None, bgcolor=None if theme["dual"] else c["SURFACE_3"])
|
||||
swatch = ft.Container(ft.Row([dot(c["ACCENT"]), dot(c["SECONDARY"]), bar], spacing=T.px(6),
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
bgcolor=c["BG"], border=ft.Border.all(1, c["BORDER"]), border_radius=T.RADIUS_SM,
|
||||
padding=ft.Padding(T.S3, T.S3, T.S3, T.S3))
|
||||
# small marks (an IconButton would make this card's name row taller than the others')
|
||||
marks = [C.as_icon(ft.Icons.CHECK_CIRCLE_ROUNDED, T.px(18), T.TEXT if T.DUAL else T.ACCENT)] if on else []
|
||||
if tid.startswith(T.USER_PREFIX):
|
||||
marks.append(ft.Container(C.as_icon(ft.Icons.DELETE_OUTLINE_ROUNDED, T.px(18), T.TEXT_2),
|
||||
tooltip=tr("Remove this theme"), border_radius=T.RADIUS_XS, ink=True,
|
||||
on_click=lambda e, t=tid: remove(t)))
|
||||
corner = ft.Row(marks, spacing=T.S2, tight=True)
|
||||
box = ft.Container(ft.Column([
|
||||
swatch,
|
||||
ft.Row([C.body(theme["name"], T.TEXT, weight=ft.FontWeight.W_600, expand=True), corner],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
C.meta(about.get(tid) or tr("Installed theme file · {file}").format(file=Path(theme["path"]).name)),
|
||||
], spacing=T.S2), width=T.px(240), padding=T.S3, border_radius=T.RADIUS,
|
||||
ink=True, on_click=lambda e, t=tid: pick(t),
|
||||
border=ft.Border.all(2, T.ACCENT if on else T.BORDER)) # same width: the content doesn't shift
|
||||
C.selected_style(box, on)
|
||||
return box
|
||||
|
||||
return ft.Column([
|
||||
# no expand= in here: an expanding child in a wrap=True Row is an invalid layout (Flet's grey box)
|
||||
ft.Row([C.body(tr("Theme"), T.TEXT, weight=ft.FontWeight.W_500), ft.Container(width=T.S4),
|
||||
C.ghost(tr("Copy this theme as a file"), ft.Icons.CONTENT_COPY_ROUNDED,
|
||||
lambda e: (app.copy(T.theme_json(T.THEME)),
|
||||
app.toast(tr("Copied: save it as a .json file, change the colors and "
|
||||
"install it")))),
|
||||
C.secondary(tr("Install theme file…"), ft.Icons.FILE_OPEN_OUTLINED, install)],
|
||||
spacing=T.S2, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
ft.Row([card(t) for t in T.THEMES], spacing=T.S3, run_spacing=T.S3, wrap=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
], spacing=T.S2)
|
||||
|
||||
def appearance(self) -> ft.Control:
|
||||
from ...core import library
|
||||
|
||||
@@ -349,22 +242,15 @@ class SettingsView:
|
||||
else tr("Now {scale:.0%}.").format(scale=T.SCALE))
|
||||
C.update(note)
|
||||
|
||||
dd = C.dropdown(label=tr("Text and layout size"), value=str(current) if current != "auto" else "auto",
|
||||
dd = ft.Dropdown(label=tr("Text and layout size"), value=str(current) if current != "auto" else "auto",
|
||||
options=options, width=T.px(260), on_select=changed)
|
||||
live_card = C.switch(tr("Show the running game on the Frame card"), value=self.app.live_card,
|
||||
on_change=lambda e: self.app.set_live_card(e.control.value))
|
||||
live_note = C.meta(tr("Streams a small sample from the Frame every 5 seconds (game, frame rate, battery). "
|
||||
"Turn off to save the Frame's processor."))
|
||||
motion = C.switch(tr("Reduce motion (no fades between pages, no portal pulse when the Frame connects)"),
|
||||
value=self.app.reduce_motion, on_change=lambda e: self.app.set_reduce_motion(e.control.value))
|
||||
controls = [self.theme_picker(), ft.Container(height=T.S2), dd, note, ft.Container(height=T.S2), live_card,
|
||||
live_note, motion]
|
||||
controls = [dd, note]
|
||||
languages = i18n.available()
|
||||
if len(languages) > 1: # only once a translation exists
|
||||
def language_changed(e):
|
||||
library.set_setting("ui.language", e.control.value)
|
||||
C.update(lang_note)
|
||||
lang = C.dropdown(label=tr("Language"), value=i18n.language(), width=T.px(260), on_select=language_changed,
|
||||
lang = ft.Dropdown(label=tr("Language"), value=i18n.language(), width=T.px(260), on_select=language_changed,
|
||||
options=[ft.dropdown.Option(code, i18n.language_name(code)) for code in languages])
|
||||
lang_note = C.meta(tr("Changes apply the next time FramePort starts."))
|
||||
controls += [lang, lang_note]
|
||||
@@ -376,7 +262,7 @@ class SettingsView:
|
||||
from ...frame.connection import bundled_agent_version
|
||||
|
||||
mine = bundled_agent_version()
|
||||
text = f"v{mine}" if mine else tr("unknown")
|
||||
text = f"v{mine}" if mine else "unknown"
|
||||
info = self.app.frame_info or {}
|
||||
remote = info.get("agent_version")
|
||||
if self.app.frame_state == "connected" and remote:
|
||||
@@ -386,55 +272,29 @@ class SettingsView:
|
||||
text += tr(" · Frame not connected")
|
||||
return text
|
||||
|
||||
def index_column(self) -> ft.Control:
|
||||
"""The page's index (left): one entry per section; a click scrolls there and highlights the entry."""
|
||||
def entry(key: str, label: str) -> ft.Container:
|
||||
text = C.body(label, T.TEXT_2, weight=ft.FontWeight.W_500)
|
||||
|
||||
async def click(e, k=key):
|
||||
await self.show_section(k)
|
||||
box = ft.Container(text, padding=ft.Padding(T.S3, T.px(7), T.S3, T.px(7)), border_radius=T.RADIUS_SM,
|
||||
ink=True, on_click=click, data=text)
|
||||
self.index[key] = box
|
||||
return box
|
||||
titles = section_titles()
|
||||
col = ft.Column([entry(k, titles[k]) for k in SECTIONS], spacing=T.px(2))
|
||||
self._mark(SECTIONS[0])
|
||||
return ft.Container(col, width=T.px(180), padding=ft.Padding(0, T.px(2), T.S4, 0))
|
||||
|
||||
def _mark(self, current: str) -> None:
|
||||
for key, box in self.index.items():
|
||||
C.selected_style(box, key == current, text=box.data)
|
||||
|
||||
async def show_section(self, key: str) -> None:
|
||||
"""Scroll the page to a section (index click; ui_smoke calls it too)."""
|
||||
self._mark(key)
|
||||
C.update(*self.index.values())
|
||||
if self.page_col is not None:
|
||||
await self.page_col.scroll_to(scroll_key=scroll_key(key), duration=0 if self.app.reduce_motion else 300)
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
app = self.app
|
||||
self.tools.controls = [C.checklist([C.Check(None, tr("Checking tools…"))])]
|
||||
self.tools.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ACCENT),
|
||||
C.meta(tr("Checking tools…"))], spacing=T.S2)]
|
||||
app.run_bg(self.fill_tools)
|
||||
self.pc.controls = [C.checklist([C.Check(None, tr("Checking this PC…"))])]
|
||||
self.pc.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2),
|
||||
color=T.ACCENT),
|
||||
C.meta(tr("Checking this PC…"))], spacing=T.S2)]
|
||||
app.run_bg(self.fill_pc)
|
||||
from ... import __version__ as ver
|
||||
data = str(user_data_dir())
|
||||
sections = {
|
||||
"appearance": C.section(tr("Appearance"), C.card(self.appearance(), padding=T.S4), help="ui_scale"),
|
||||
"updates": C.section(tr("Updates"), C.card(self.updates_card(), padding=T.S4), help="app_updates"),
|
||||
"links": C.section(tr("Install links"), C.card(self.links_card(), padding=T.S4), help="install_links"),
|
||||
"installing": C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
"tools": C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort manages its own copies; nothing is installed system-wide"),
|
||||
action=ft.Row([C.ghost(tr("Update tools"), ft.Icons.UPDATE_ROUNDED,
|
||||
lambda e: app.update_tools(update=True)),
|
||||
C.secondary(tr("Install missing"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.update_tools())], spacing=T.S2)),
|
||||
"pc": C.section(tr("This PC (for PC VR games)"),
|
||||
C.card(self.pc, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2))),
|
||||
"data": C.section(tr("Data"), C.card(ft.Column([
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Settings"), tr("Updates, tools, this PC and where FramePort keeps its data")),
|
||||
C.section(tr("Updates"), C.card(self.updates_card(), padding=T.S4), help="app_updates"),
|
||||
C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort manages its own copies; nothing is installed system-wide"),
|
||||
action=ft.Row([C.ghost(tr("Update tools"), ft.Icons.UPDATE_ROUNDED,
|
||||
lambda e: app.update_tools(update=True)),
|
||||
C.secondary(tr("Install missing"), ft.Icons.DOWNLOAD_ROUNDED,
|
||||
lambda e: app.update_tools())], spacing=T.S2)),
|
||||
C.section(tr("This PC (for PC VR games)"), C.card(self.pc, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2))),
|
||||
C.section(tr("Data"), C.card(ft.Column([
|
||||
C.kv(tr("Data folder"), ft.Row([C.body(data, T.TEXT, selectable=True, expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy path"),
|
||||
lambda e: app.copy(data))]), "data_folder"),
|
||||
@@ -443,7 +303,7 @@ class SettingsView:
|
||||
self.catalog_updates(),
|
||||
], spacing=T.S2))),
|
||||
# text above, buttons below (side by side, the buttons squeezed the text in a narrow window)
|
||||
"feedback": C.section(tr("Problems and feedback"), C.card(ft.Column([
|
||||
C.section(tr("Problems and feedback"), C.card(ft.Column([
|
||||
C.body(tr("Something not working? Collect a diagnostics zip (logs, settings, device info; personal "
|
||||
"data removed) and attach it to a GitHub issue. For one game, use its menu instead.")),
|
||||
ft.Row([C.secondary(tr("Report a problem…"), ft.Icons.BUG_REPORT_OUTLINED,
|
||||
@@ -451,27 +311,23 @@ class SettingsView:
|
||||
C.ghost(tr("Collect app logs"), ft.Icons.FOLDER_ZIP_OUTLINED, lambda e: app.collect_logs())],
|
||||
spacing=T.S3, run_spacing=T.S2, wrap=True),
|
||||
], spacing=T.S3)), help="diag_bundle"),
|
||||
"about": C.section(tr("About"), C.card(ft.Column([
|
||||
C.section(tr("Remove FramePort"), C.card(ft.Column([
|
||||
C.body(tr("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
|
||||
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.")),
|
||||
ft.OutlinedButton(tr("Uninstall FramePort…"), icon=ft.Icons.DELETE_FOREVER_ROUNDED,
|
||||
on_click=lambda e: app.uninstall_app(),
|
||||
style=ft.ButtonStyle(color=T.ERROR, side=ft.BorderSide(1, T.soft(T.ERROR, 0.6)),
|
||||
shape=ft.RoundedRectangleBorder(radius=T.RADIUS_SM))),
|
||||
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START))),
|
||||
C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
C.section(tr("Install links"), C.card(self.links_card(), padding=T.S4), help="install_links"),
|
||||
C.section(tr("Appearance"), C.card(self.appearance(), padding=T.S4), help="ui_scale"),
|
||||
C.section(tr("About"), C.card(ft.Column([
|
||||
C.kv(tr("Version"), ver),
|
||||
C.kv(tr("Frame agent"), self.agent_text(), "frame_agent"),
|
||||
C.kv(tr("Source"), C.ghost(REPO_URL.removeprefix("https://"), ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
color=T.ACCENT, url=REPO_URL)),
|
||||
C.kv(tr("Source"), ft.TextButton(REPO_URL.removeprefix("https://"), icon=ft.Icons.OPEN_IN_NEW_ROUNDED,
|
||||
url=REPO_URL)),
|
||||
C.meta(tr("Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. "
|
||||
"Not affiliated with Valve or Meta.")),
|
||||
], spacing=T.S2))),
|
||||
"remove": C.section(tr("Remove FramePort"), C.card(ft.Column([
|
||||
C.body(tr("Removes everything FramePort created: its data and tools on this PC, the Steam entries it "
|
||||
"added, and (optionally) its games and files on the Frame. Your game dumps aren't touched.")),
|
||||
C.danger(tr("Uninstall FramePort…"), ft.Icons.DELETE_FOREVER_OUTLINED, lambda e: app.uninstall_app(),
|
||||
outline=True),
|
||||
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START))),
|
||||
}
|
||||
# the bottom padding lets the last sections scroll up to the top too
|
||||
self.page_col = ft.Column([ft.Container(sections[k], key=ft.ScrollKey(scroll_key(k))) for k in SECTIONS]
|
||||
+ [ft.Container(height=T.px(240))],
|
||||
spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Settings"), tr("Updates, tools, this PC and where FramePort keeps its data")),
|
||||
ft.Row([self.index_column(), self.page_col], expand=True, spacing=0,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
], spacing=T.S5, expand=True)
|
||||
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
@@ -32,7 +32,8 @@ class WelcomeView:
|
||||
def step(self, n: int, head: str, text: str, state: str, *content: ft.Control) -> ft.Control:
|
||||
icon = {"done": (ft.Icons.CHECK_ROUNDED, T.OK), "busy": (None, T.ACCENT),
|
||||
"todo": (None, T.TEXT_3), "error": (ft.Icons.PRIORITY_HIGH_ROUNDED, T.ERROR)}[state]
|
||||
marker = C.spinner("m") if state == "busy" else \
|
||||
marker = ft.ProgressRing(width=T.px(20), height=T.px(20), stroke_width=T.px(2),
|
||||
color=T.ACCENT) if state == "busy" else \
|
||||
ft.Icon(icon[0], size=T.px(18), color=icon[1]) if icon[0] else ft.Text(str(n), weight=ft.FontWeight.W_700,
|
||||
color=T.TEXT_2)
|
||||
return C.card(ft.Row([
|
||||
@@ -62,7 +63,9 @@ class WelcomeView:
|
||||
g_state = "done" if library.games() else "todo"
|
||||
return ft.Column([
|
||||
ft.Container(height=T.S5),
|
||||
ft.Row([C.logo(T.px(60)),
|
||||
ft.Row([ft.Container(ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED, size=T.px(30), color=T.ON_ACCENT),
|
||||
width=T.px(56), height=T.px(56), border_radius=T.px(16), bgcolor=T.ACCENT,
|
||||
alignment=ft.Alignment.CENTER),
|
||||
ft.Column([C.title(tr("Welcome to FramePort")),
|
||||
C.body(tr("Play your Quest, Android and PC VR games on the Steam Frame. Three steps "
|
||||
"and you're set."))], spacing=T.px(2))], spacing=T.S4),
|
||||
@@ -76,9 +79,9 @@ class WelcomeView:
|
||||
else [C.secondary(tr("Set up the Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
|
||||
lambda e: app.go("frame"))])),
|
||||
self.step(3, tr("Add your games"),
|
||||
tr("A folder with Android games (APK + OBB, for example Quest games) or PC VR games."),
|
||||
g_state, ft.Row([C.primary(tr("Scan a folder…"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
C.ghost(tr("Add an APK file…"), ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
|
||||
tr("A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games."),
|
||||
g_state, ft.Row([C.primary(tr("Scan a folder"), ft.Icons.FOLDER_OPEN_ROUNDED, app.pick_folder),
|
||||
C.ghost(tr("Add an APK file"), ft.Icons.ANDROID_ROUNDED, app.pick_apk)],
|
||||
spacing=T.S2)),
|
||||
ft.Row([ft.Container(expand=True),
|
||||
C.primary(tr("Go to my library"), ft.Icons.ARROW_FORWARD_ROUNDED, lambda e: app.finish_welcome())
|
||||
|
||||
@@ -124,7 +124,7 @@ def check(force: bool = False) -> Update | None:
|
||||
up = update_from_release(data) if data else None
|
||||
if not up or not is_newer(up.version):
|
||||
return None
|
||||
if not force and hidden(up.version):
|
||||
if not force and library.setting("update.skipped") == up.version:
|
||||
return None
|
||||
return up
|
||||
|
||||
@@ -154,7 +154,7 @@ def cached_update() -> Update | None:
|
||||
up = update_from_release(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not up or not is_newer(up.version) or hidden(up.version):
|
||||
if not up or not is_newer(up.version) or library.setting("update.skipped") == up.version:
|
||||
return None
|
||||
return up
|
||||
|
||||
@@ -165,28 +165,9 @@ def cache_age() -> float | None:
|
||||
|
||||
|
||||
def skip(version: str) -> None:
|
||||
""""Skip this version": never offered again automatically ("Check now" still finds it)."""
|
||||
library.set_setting("update.skipped", version)
|
||||
|
||||
|
||||
def snooze(version: str, hours: float = 24) -> None:
|
||||
"""The banner's "Later": hide this version for a while (a day by default), then offer it again."""
|
||||
library.set_setting("update.snoozed", {"version": version, "until": time.time() + hours * 3600})
|
||||
|
||||
|
||||
def hidden(version: str) -> bool:
|
||||
"""True when the user skipped this version, or snoozed it and the snooze hasn't run out yet."""
|
||||
if library.setting("update.skipped") == version:
|
||||
return True
|
||||
snoozed = library.setting("update.snoozed")
|
||||
if isinstance(snoozed, dict) and snoozed.get("version") == version:
|
||||
try:
|
||||
return time.time() < float(snoozed.get("until") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- how installed
|
||||
def running_executable() -> Path | None:
|
||||
try:
|
||||
|
||||
@@ -119,6 +119,21 @@ def triage(log: str, state: str = "UNKNOWN", package: str | None = None, crash:
|
||||
return res
|
||||
|
||||
|
||||
MISSING_OBB = ("The game keeps its content in a data file (.obb) and none was installed: FramePort found no .obb "
|
||||
"files next to its APK. Unreal games then stop right after start without an error (GitHub #85). Put "
|
||||
"the .obb files in a folder named like the package (or obb/) next to the APK, add the folder again "
|
||||
"and reinstall.")
|
||||
|
||||
|
||||
def add_missing_obb(res: TriageResult) -> TriageResult:
|
||||
"""A launch test of a game whose APK expects an OBB that wasn't installed (from the library, not the log: the
|
||||
game logs nothing about it). Only when it sent no frames: an OBB from an earlier install may still be there."""
|
||||
if res.fps is None and not any(f.id == "missing-obb" for f in res.findings):
|
||||
res.findings.insert(0, Finding("missing-obb", "fatal", MISSING_OBB, [],
|
||||
"analysis: the APK expects an OBB (Unreal bHasOBBFiles); no data folder"))
|
||||
return res
|
||||
|
||||
|
||||
LOGCAT_TIME = re.compile(r"^\d\d-\d\d (\d\d):(\d\d):(\d\d)\.(\d{3})")
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def isolated_home(tmp_path, monkeypatch):
|
||||
|
||||
# ---------------------------------------------------------------------------------------------- AXML builder
|
||||
def build_axml(elements, strings_extra=()):
|
||||
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool".
|
||||
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool" | "int" | "ref".
|
||||
Produces a UTF-16 string-pool binary XML like aapt does (enough for our editor)."""
|
||||
ns = "http://schemas.android.com/apk/res/android"
|
||||
pool = []
|
||||
@@ -61,6 +61,9 @@ def build_axml(elements, strings_extra=()):
|
||||
for a, kind, v in e[2]:
|
||||
if kind == "str":
|
||||
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), idx(v), 8, 0, 0x03, idx(v))
|
||||
elif kind in ("int", "ref"): # decimal integer / resource reference (@string/…)
|
||||
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), 0xFFFFFFFF, 8, 0, 0x10 if kind == "int" else 0x01,
|
||||
v)
|
||||
else:
|
||||
attrs += struct.pack("<IIIHBBI", idx(ns), idx(a), 0xFFFFFFFF, 8, 0, 0x12, 0xFFFFFFFF if v else 0)
|
||||
ext = struct.pack("<IIHHHHHH", 0xFFFFFFFF, idx(e[1]), 20, 20, len(e[2]), 0, 0, 0)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Test fixture for patches/upstream.py probes: the two OVRPort runtime functions ovrport/app#73 fixed, as runtime
|
||||
// 3.4.3-23204ea has them (default) and as 3.4.3-aa54c3f does (-DFIXED); same instruction sequences as those builds.
|
||||
// Build: aarch64-linux-android29-clang -shared -fPIC -nostdlib fakeruntime.S -o ../libfakeruntime_old_arm64.so
|
||||
// aarch64-linux-android29-clang -shared -fPIC -nostdlib -DFIXED fakeruntime.S -o ../libfakeruntime_fixed_arm64.so
|
||||
.text
|
||||
.globl AAudioStream_getFramesPerBurst
|
||||
.type AAudioStream_getFramesPerBurst, %function
|
||||
AAudioStream_getFramesPerBurst:
|
||||
ldr w0, [x0]
|
||||
ret
|
||||
.size AAudioStream_getFramesPerBurst, .-AAudioStream_getFramesPerBurst
|
||||
|
||||
// libopenxr_loader.so: the dispatcher's XrHapticAmplitudeEnvelopeVibrationFB -> 100 Hz samples (x20 = the struct:
|
||||
// +16 duration in ns, +24 amplitudeCount)
|
||||
.globl fake_envelope_samples
|
||||
.type fake_envelope_samples, %function
|
||||
fake_envelope_samples:
|
||||
ldr w8, [x20]
|
||||
mov w9, #28105
|
||||
movk w9, #15261, lsl #16 // XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB
|
||||
cmp w8, w9
|
||||
b.ne 1f
|
||||
ldr w9, [x20, #24]
|
||||
#ifdef FIXED
|
||||
mov x8, #225833675390976
|
||||
ldr d1, [x20, #16]
|
||||
movk x8, #16845, lsl #48 // 1e9
|
||||
ucvtf d0, w9
|
||||
fmov d2, x8
|
||||
scvtf d1, d1
|
||||
mov w8, #1120403456 // 100.0f
|
||||
fmul d0, d0, d2
|
||||
fmov s2, w8
|
||||
fdiv d0, d0, d1
|
||||
ucvtf s1, w9
|
||||
fmul s1, s1, s2
|
||||
fcvt s0, d0
|
||||
fdiv s0, s1, s0
|
||||
#else
|
||||
ldr x8, [x20, #16]
|
||||
ucvtf s0, w9
|
||||
scvtf s1, x8
|
||||
mov w8, #1120403456
|
||||
fmov s2, w8
|
||||
fdiv s1, s0, s1
|
||||
fmul s0, s0, s2
|
||||
fdiv s0, s0, s1
|
||||
#endif
|
||||
fcvtzu w21, s0
|
||||
1:
|
||||
ret
|
||||
.size fake_envelope_samples, .-fake_envelope_samples
|
||||
|
||||
// libovrplatformloader.so
|
||||
.globl ovr_Microphone_GetOutputBufferMaxSize
|
||||
.type ovr_Microphone_GetOutputBufferMaxSize, %function
|
||||
ovr_Microphone_GetOutputBufferMaxSize:
|
||||
#ifdef FIXED
|
||||
stp x29, x30, [sp, #-32]!
|
||||
stp x20, x19, [sp, #16]
|
||||
mov x29, sp
|
||||
cbz x0, 3f
|
||||
ldr x8, [x0, #24]
|
||||
mov w19, #48000
|
||||
cbz x8, 2f
|
||||
mov x20, x0
|
||||
mov x0, x8
|
||||
bl AAudioStream_getFramesPerBurst
|
||||
cmp w0, w19
|
||||
b.hi 2f
|
||||
ldr x0, [x20, #24]
|
||||
bl AAudioStream_getFramesPerBurst
|
||||
sxtw x19, w0
|
||||
2:
|
||||
mov x0, x19
|
||||
ldp x20, x19, [sp, #16]
|
||||
ldp x29, x30, [sp], #32
|
||||
ret
|
||||
3:
|
||||
mov w19, #48000
|
||||
mov x0, x19
|
||||
ldp x20, x19, [sp, #16]
|
||||
ldp x29, x30, [sp], #32
|
||||
ret
|
||||
#else
|
||||
stp x29, x30, [sp, #-16]!
|
||||
mov x29, sp
|
||||
ldr x0, [x0, #0x18]
|
||||
bl AAudioStream_getFramesPerBurst
|
||||
sxtw x0, w0
|
||||
ldp x29, x30, [sp], #16
|
||||
ret
|
||||
#endif
|
||||
.size ovr_Microphone_GetOutputBufferMaxSize, .-ovr_Microphone_GetOutputBufferMaxSize
|
||||
@@ -76,3 +76,63 @@ def test_start_activity_moves_launcher_to_the_vr_activity():
|
||||
# only those two attribute values changed: the rest parses as before
|
||||
assert axml.Axml(fixed).attr_str(next(e for e in axml.Axml(fixed).elements() if e.name == "manifest"),
|
||||
"package") == "org.x.game"
|
||||
|
||||
|
||||
def _sdk_manifest(min_sdk):
|
||||
from conftest import build_axml
|
||||
|
||||
return build_axml([
|
||||
("start", "manifest", [("package", "str", "org.x.app")]),
|
||||
("start", "uses-sdk", [("minSdkVersion", "int", min_sdk), ("targetSdkVersion", "int", 34)]),
|
||||
("end", "uses-sdk"),
|
||||
("end", "manifest"),
|
||||
])
|
||||
|
||||
|
||||
def test_min_sdk(quest_manifest):
|
||||
assert axml.min_sdk(_sdk_manifest(34)) == 34
|
||||
assert axml.min_sdk(_sdk_manifest(29)) == 29
|
||||
assert axml.min_sdk(quest_manifest) is None # no uses-sdk element
|
||||
|
||||
|
||||
def _twa_manifest(value_kind, value):
|
||||
from conftest import build_axml
|
||||
|
||||
return build_axml([
|
||||
("start", "manifest", [("package", "str", "dev.pages.mahjong_vr.twa")]),
|
||||
("start", "uses-sdk", [("minSdkVersion", "int", 23)]),
|
||||
("end", "uses-sdk"),
|
||||
("start", "application", []),
|
||||
("start", "meta-data", [("name", "str", "asset_statements"), ("resource", "ref", 0x7F0F0001)]),
|
||||
("end", "meta-data"),
|
||||
("start", "activity", [("name", "str", "com.google.androidbrowserhelper.trusted.LauncherActivity")]),
|
||||
("start", "meta-data", [("name", "str", "android.support.customtabs.trusted.DEFAULT_URL"),
|
||||
("value", value_kind, value)]),
|
||||
("end", "meta-data"),
|
||||
("start", "meta-data", [("name", "str", "com.example.flag"), ("value", "bool", True)]),
|
||||
("end", "meta-data"),
|
||||
("end", "activity"),
|
||||
("end", "application"),
|
||||
("end", "manifest"),
|
||||
])
|
||||
|
||||
|
||||
def test_meta_data_and_web_wrapper(tmp_path):
|
||||
import zipfile
|
||||
|
||||
from frameport.analysis.detect import analyze, web_wrapper
|
||||
|
||||
m = _twa_manifest("str", "https://mahjong-vr.pages.dev/")
|
||||
meta = axml.meta_data(m)
|
||||
assert meta == {"asset_statements": 0x7F0F0001, "android.support.customtabs.trusted.DEFAULT_URL":
|
||||
"https://mahjong-vr.pages.dev/", "com.example.flag": True}
|
||||
assert web_wrapper(meta, axml.Axml(m).strings()) == {"url": "https://mahjong-vr.pages.dev/"}
|
||||
# @string/launchUrl (Bubblewrap) without a resource table: still a wrapper, URL unknown
|
||||
ref = _twa_manifest("ref", 0x7F0F0002)
|
||||
assert web_wrapper(axml.meta_data(ref), axml.Axml(ref).strings()) == {"url": None}
|
||||
assert web_wrapper({}, ["com.example.Main"]) is None
|
||||
apk = tmp_path / "twa.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", m)
|
||||
a = analyze(apk)
|
||||
assert a.extra["web_wrapper"] == {"url": "https://mahjong-vr.pages.dev/"} and a.extra["min_sdk"] == 23
|
||||
@@ -26,11 +26,11 @@ def test_no_action_without_frame_work_or_while_charging_or_paused_for_another_re
|
||||
|
||||
def test_plugged_in_but_draining_counts_as_not_charging():
|
||||
weak = {"percent": 12, "status": "Discharging", "plugged": True, "draining": True}
|
||||
assert B.advice(weak, True, None, True) == "pause" and B.low(weak) and B.label(weak) == "12%"
|
||||
assert B.advice(weak, True, None, True) == "pause" and B.low(weak) and B.label(weak) == "12 %"
|
||||
|
||||
|
||||
def test_label():
|
||||
assert B.label(bat(76)) == "76%" and B.label(bat(76, True)) == "76% ⚡" and B.label(None) == ""
|
||||
assert B.label(bat(76)) == "76 %" and B.label(bat(76, True)) == "76 % ⚡" and B.label(None) == ""
|
||||
assert B.low(bat(20)) and not B.low(bat(20, True)) and not B.low(bat(60))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Agent: games on another drive (microSD, GitHub #90) and Desktop Mode entries for Linux apps (GitHub #84)."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
|
||||
|
||||
|
||||
def load(monkeypatch, tmp_path, extra_mounts=()):
|
||||
"""The agent with HOME = <tmp>/home, removable drives under <tmp>/media and a fake /proc/mounts."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
|
||||
a = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(a)
|
||||
media = tmp_path / "media"
|
||||
(media / "steamos" / "SD Card").mkdir(parents=True, exist_ok=True)
|
||||
(media / "steamos" / "STICK").mkdir(parents=True, exist_ok=True)
|
||||
(media / "steamos" / "RO").mkdir(parents=True, exist_ok=True)
|
||||
sd = str(media / "steamos" / "SD Card").replace(" ", "\\040")
|
||||
table = tmp_path / "mounts"
|
||||
table.write_text("/dev/nvme0n1p8 / btrfs rw,relatime 0 0\n"
|
||||
f"/dev/mmcblk0p1 {sd} ext4 rw,nosuid,nodev,relatime 0 0\n"
|
||||
f"/dev/sda1 {media}/steamos/STICK exfat rw,nosuid,nodev 0 0\n"
|
||||
f"/dev/sdb1 {media}/steamos/RO ext4 ro,nosuid 0 0\n"
|
||||
"proc /proc proc rw 0 0\n" + "".join(extra_mounts))
|
||||
monkeypatch.setattr(a, "PROC_MOUNTS", str(table))
|
||||
monkeypatch.setattr(a, "MEDIA_ROOT", str(media))
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
monkeypatch.setattr(a, "as_owner", lambda: [])
|
||||
return a
|
||||
|
||||
|
||||
def sd_dir(tmp_path):
|
||||
return tmp_path / "media" / "steamos" / "SD Card"
|
||||
|
||||
|
||||
def quest_install(a, pkg="com.x.y", dest=None):
|
||||
"""A Quest game as finalize leaves it (no Lepton needed: written by hand like the agent would)."""
|
||||
prep = a.cmd_prepare({"package": pkg, "title": "Game", "dest": dest})
|
||||
base = Path(prep["base"])
|
||||
(base / "lepton-app" / "obb").mkdir(parents=True)
|
||||
(base / "lepton-app" / "game.apk").write_bytes(b"apk" * 100)
|
||||
(base / "lepton-app" / "obb" / "main.obb").write_bytes(b"o" * 5000)
|
||||
saves = base / "lepton-data" / "external" / "Android" / "data" / pkg / "files"
|
||||
saves.mkdir(parents=True)
|
||||
(saves / "save.dat").write_text("progress")
|
||||
shutil.rmtree(base / "incoming")
|
||||
anchor = Path(prep["anchor"])
|
||||
anchor.mkdir(parents=True, exist_ok=True)
|
||||
(anchor / "artwork").mkdir(exist_ok=True)
|
||||
(anchor / "artwork" / "icon.png").write_bytes(b"png")
|
||||
a.write_launcher(str(anchor), str(base), pkg, "Game", prep["appid"], "/lepton/lepton", {"X": "1"})
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": pkg, "appid": int(prep["appid"]),
|
||||
"base": str(base), "title": "Game"}))
|
||||
return prep
|
||||
|
||||
|
||||
def test_drives_lists_internal_and_removable_drives(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
drives = {d["label"]: d for d in a.cmd_drives({})["drives"]}
|
||||
assert set(drives) == {"Internal storage", "SD Card", "STICK", "RO"}
|
||||
internal = drives["Internal storage"]
|
||||
assert internal["internal"] and internal["install_dir"] == a.ANCHORS and internal["usable"]
|
||||
assert internal["fstype"] == "btrfs" and internal["free_bytes"] > 0
|
||||
sd = drives["SD Card"]
|
||||
assert sd["usable"] and sd["removable"] and sd["fstype"] == "ext4"
|
||||
assert sd["path"] == str(sd_dir(tmp_path)) and sd["install_dir"] == str(sd_dir(tmp_path) / "FramePort")
|
||||
assert not drives["STICK"]["usable"] and "exfat" in drives["STICK"]["reason"]
|
||||
assert not drives["RO"]["usable"] and "read-only" in drives["RO"]["reason"]
|
||||
|
||||
|
||||
def test_steam_library_on_another_drive_is_listed(monkeypatch, tmp_path):
|
||||
lib = tmp_path / "games"
|
||||
(lib / "steamapps").mkdir(parents=True)
|
||||
a = load(monkeypatch, tmp_path, [f"/dev/sdc1 {lib} ext4 rw 0 0\n"])
|
||||
steamapps = Path(a.STEAM) / "steamapps"
|
||||
steamapps.mkdir(parents=True)
|
||||
(steamapps / "libraryfolders.vdf").write_text(f'"libraryfolders" {{ "1" {{ "path" "{lib}" }} }}')
|
||||
drives = {d["path"]: d for d in a.list_drives()}
|
||||
assert drives[str(lib)]["steam_library"] and drives[str(lib)]["install_dir"] == str(lib / "FramePort")
|
||||
|
||||
|
||||
def test_resolve_dest_never_falls_back_to_internal_storage(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
for internal in (None, "", "internal", a.ANCHORS):
|
||||
assert a.resolve_dest(internal) == a.ANCHORS
|
||||
assert a.resolve_dest(str(sd_dir(tmp_path))) == str(sd_dir(tmp_path) / "FramePort") # the mount point
|
||||
assert (sd_dir(tmp_path) / "FramePort").is_dir()
|
||||
with pytest.raises(a.AgentError, match="exfat"):
|
||||
a.resolve_dest(str(tmp_path / "media/steamos/STICK/FramePort"))
|
||||
with pytest.raises(a.AgentError, match="read-only"):
|
||||
a.resolve_dest(str(tmp_path / "media/steamos/RO"))
|
||||
with pytest.raises(a.AgentError, match="isn't inserted"): # an SD card that was taken out
|
||||
a.resolve_dest(str(tmp_path / "media/steamos/OLDCARD/FramePort"))
|
||||
assert not (tmp_path / "media/steamos/OLDCARD").exists()
|
||||
|
||||
|
||||
def test_new_games_go_to_the_chosen_drive_and_keep_their_base(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
dest = str(sd_dir(tmp_path) / "FramePort")
|
||||
prep = quest_install(a, dest=dest)
|
||||
assert prep["base"] == os.path.join(dest, "com.x.y") and prep["anchor"].startswith(a.ANCHORS)
|
||||
assert prep["free_bytes"] > 0
|
||||
# a reinstall keeps the game where it is, whatever the default is now
|
||||
again = a.cmd_prepare({"package": "com.x.y", "title": "Game", "dest": None})
|
||||
assert again["base"] == prep["base"] and again["installed"]
|
||||
games = a.cmd_list_installed({})["games"]
|
||||
assert games[0]["drive"] == {"internal": False, "path": str(sd_dir(tmp_path)), "label": "SD Card"}
|
||||
assert games[0]["drive_missing"] is False and games[0]["apk_present"]
|
||||
assert {d["label"]: d["games"] for d in a.cmd_drives({})["drives"]}["SD Card"] == 1
|
||||
# Linux apps and PC VR games take dest too
|
||||
p = a.cmd_prepare_linux({"package": "linux.tool", "title": "Tool", "dest": dest})
|
||||
assert p["base"] == os.path.join(dest, "linux.tool")
|
||||
|
||||
|
||||
def test_missing_sd_card(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
prep = quest_install(a, dest=str(sd_dir(tmp_path) / "FramePort"))
|
||||
(tmp_path / "mounts").write_text("/dev/nvme0n1p8 / btrfs rw 0 0\n") # card taken out
|
||||
game = a.cmd_list_installed({})["games"][0]
|
||||
assert game["drive_missing"] is True and game["drive"]["label"] == "SD Card"
|
||||
with pytest.raises(a.AgentError, match="isn't inserted"):
|
||||
a.cmd_prepare({"package": "com.x.y", "title": "Game"})
|
||||
with pytest.raises(a.AgentError, match="isn't inserted"):
|
||||
a.cmd_launch({"package": "com.x.y"})
|
||||
with pytest.raises(a.AgentError, match="isn't inserted"):
|
||||
a.cmd_move({"package": "com.x.y", "dest": "internal", "detach": False})
|
||||
assert Path(prep["base"]).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("same_drive", [True, False])
|
||||
def test_move_quest_game_to_sd_and_back(monkeypatch, tmp_path, same_drive):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "same_device", lambda x, y: same_drive)
|
||||
prep = quest_install(a)
|
||||
anchor = Path(prep["anchor"])
|
||||
assert prep["base"] == str(anchor) # internal storage: the files live next to the launcher
|
||||
os.symlink("/nonexistent/elsewhere", anchor / "lepton-app" / "outside-link")
|
||||
dest = str(sd_dir(tmp_path))
|
||||
r = a.cmd_move({"package": "com.x.y", "dest": dest, "detach": False})
|
||||
assert r["state"] == "done", r
|
||||
new = sd_dir(tmp_path) / "FramePort" / "com.x.y"
|
||||
assert (new / "lepton-app" / "obb" / "main.obb").stat().st_size == 5000
|
||||
assert (new / "lepton-data/external/Android/data/com.x.y/files/save.dat").read_text() == "progress"
|
||||
assert os.readlink(new / "lepton-app" / "outside-link") == "/nonexistent/elsewhere"
|
||||
# the anchor keeps its own files only
|
||||
assert sorted(os.listdir(anchor)) == ["artwork", "deployment.json", "launch.sh"]
|
||||
dep = a.deployment("com.x.y")
|
||||
assert dep["base"] == str(new) and dep["moved"]["from"] == str(anchor)
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert f"app_dir='{new}'" in text and "export X=1" in text
|
||||
assert subprocess.run(["bash", "-n", str(anchor / "launch.sh")]).returncode == 0
|
||||
assert a.cmd_move_status({})["state"] == "done"
|
||||
with pytest.raises(a.AgentError, match="already there"):
|
||||
a.cmd_move({"package": "com.x.y", "dest": dest, "detach": False})
|
||||
# and back to internal storage: the drive's empty FramePort folder goes
|
||||
r = a.cmd_move({"package": "com.x.y", "dest": "internal", "detach": False})
|
||||
assert r["state"] == "done", r
|
||||
assert (anchor / "lepton-app" / "game.apk").exists() and not (sd_dir(tmp_path) / "FramePort").exists()
|
||||
assert a.deployment("com.x.y")["base"] == str(anchor)
|
||||
assert f"app_dir={anchor}\n" in (anchor / "launch.sh").read_text()
|
||||
|
||||
|
||||
def test_move_refusals(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
quest_install(a)
|
||||
with pytest.raises(a.AgentError, match="exfat"):
|
||||
a.cmd_move({"package": "com.x.y", "dest": str(tmp_path / "media/steamos/STICK"), "detach": False})
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: True)
|
||||
with pytest.raises(a.AgentError, match="running"):
|
||||
a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path)), "detach": False})
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
monkeypatch.setattr(a, "same_device", lambda x, y: False)
|
||||
monkeypatch.setattr(a, "_space", lambda path: (100 << 20, 1 << 40)) # 100 MiB free < headroom
|
||||
with pytest.raises(a.AgentError, match="not enough space"):
|
||||
a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path)), "detach": False})
|
||||
with pytest.raises(a.AgentError, match="not installed"):
|
||||
a.cmd_move({"package": "com.not.there", "dest": "internal", "detach": False})
|
||||
assert a.deployment("com.x.y")["base"] == os.path.join(a.ANCHORS, "com.x.y")
|
||||
|
||||
|
||||
def test_failed_copy_leaves_the_game_where_it_was(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "same_device", lambda x, y: False)
|
||||
prep = quest_install(a)
|
||||
real = a.tree_stats
|
||||
monkeypatch.setattr(a, "tree_stats", lambda root, names: (real(root, names)[0], 1) if "SD" in root
|
||||
else real(root, names)) # the copy comes up short
|
||||
r = a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path)), "detach": False})
|
||||
assert r["state"] == "failed" and "doesn't match" in r["error"]
|
||||
assert a.deployment("com.x.y")["base"] == prep["base"]
|
||||
assert (Path(prep["base"]) / "lepton-app" / "game.apk").exists()
|
||||
assert not (sd_dir(tmp_path) / "FramePort" / "com.x.y").exists()
|
||||
assert f"app_dir={prep['base']}\n" in (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
|
||||
|
||||
def test_move_detaches_through_systemd_run(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
quest_install(a)
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd) or
|
||||
subprocess.CompletedProcess(cmd, 0, "", "") if cmd[0] == "systemd-run" else
|
||||
subprocess.run(cmd, capture_output=True, text=True))
|
||||
r = a.cmd_move({"package": "com.x.y", "dest": str(sd_dir(tmp_path))})
|
||||
assert r["started"] and r["to"].endswith("FramePort/com.x.y")
|
||||
cmd = next(c for c in calls if c[0] == "systemd-run")
|
||||
assert "_move_worker" in cmd and json.loads(cmd[-1])["package"] == "com.x.y"
|
||||
assert a.cmd_move_status({})["state"] == "running"
|
||||
|
||||
|
||||
def test_replace_path_keeps_shell_quoting_valid(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
old, new = "/home/steamos/Applications/quest-frame/rift.x", "/run/media/steamos/SD/FramePort/rift.x"
|
||||
text = (f"base={old}\ncd {old}/game\nexec proton run {old}/revive/ReviveInjector.exe /openxr "
|
||||
f"'Z:\\home\\steamos\\Applications\\quest-frame\\rift.x\\game\\G.exe' >>\"$base/launch.log\"\n"
|
||||
f"other={old}2/x\n")
|
||||
out = a.replace_path(text, old, new)
|
||||
assert f"base={new}\n" in out and f"cd {new}/game" in out and f"{new}/revive/ReviveInjector.exe" in out
|
||||
assert "'Z:\\run\\media\\steamos\\SD\\FramePort\\rift.x\\game\\G.exe'" in out
|
||||
assert f"other={old}2/x" in out # another folder that only starts the same
|
||||
with pytest.raises(a.AgentError): # an unquoted path can't become one that needs quotes
|
||||
a.replace_path(text, old, "/run/media/steamos/SD Card/FramePort/rift.x")
|
||||
spaced = "/run/media/steamos/SD Card/FramePort/rift.x"
|
||||
assert a.replace_path(f"base='{spaced}'\n", spaced, old) == f"base='{old}'\n"
|
||||
|
||||
|
||||
def test_symlinks_into_the_old_folder_follow_the_move(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
old, new = tmp_path / "old", tmp_path / "new"
|
||||
(new / "game").mkdir(parents=True)
|
||||
os.symlink(str(old / "revive" / "LibReviveXR64.dll"), new / "game" / "LibOVRRT64_1.dll")
|
||||
os.symlink("../drive_c", new / "game" / "relative")
|
||||
assert a.retarget_symlinks(str(new), ["game"], str(old), str(new)) == 1
|
||||
assert os.readlink(new / "game" / "LibOVRRT64_1.dll") == str(new / "revive" / "LibReviveXR64.dll")
|
||||
assert os.readlink(new / "game" / "relative") == "../drive_c"
|
||||
|
||||
|
||||
def linux_install(a, tmp_path, pkg="linux.true", title="True Tool", tags=None, dest=None, **extra):
|
||||
prep = a.cmd_prepare_linux({"package": pkg, "title": title, "dest": dest})
|
||||
app_in = os.path.join(prep["incoming"], "app", "bin")
|
||||
os.makedirs(app_in)
|
||||
shutil.copy("/bin/true", os.path.join(app_in, "true"))
|
||||
size = os.path.getsize(os.path.join(app_in, "true"))
|
||||
os.makedirs(os.path.join(prep["base"], "incoming-artwork"))
|
||||
Path(prep["base"], "incoming-artwork", "icon.png").write_bytes(b"png")
|
||||
res = a.cmd_finalize_linux({"package": pkg, "title": title, "exe": "bin/true", "tags": tags or [],
|
||||
"manifests": {"app": {"bin/true": size}}, "env": {"APP_MODE": "1"}, "dest": dest,
|
||||
**extra})
|
||||
return prep, res
|
||||
|
||||
|
||||
def test_linux_app_moves_with_its_launcher(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "same_device", lambda x, y: False)
|
||||
prep, _ = linux_install(a, tmp_path)
|
||||
r = a.cmd_move({"package": "linux.true", "dest": str(sd_dir(tmp_path)), "detach": False})
|
||||
assert r["state"] == "done", r
|
||||
new = sd_dir(tmp_path) / "FramePort" / "linux.true"
|
||||
text = (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
assert f"'{new}/app/bin/true'" in text and "export APP_MODE=1" in text and prep["base"] not in text
|
||||
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
|
||||
assert (new / "app" / "bin" / "true").exists() and not (Path(prep["anchor"]) / "app").exists()
|
||||
|
||||
|
||||
def test_pc_vr_game_moves_with_its_prefix_and_launcher(monkeypatch, tmp_path):
|
||||
"""PC VR: the launcher is written again from the install record (env + game args kept since agent v63), the
|
||||
Proton prefix moves with the game and the LibOVRRT redirect follows."""
|
||||
from test_agent import fake_steam_tools
|
||||
|
||||
a = load(monkeypatch, tmp_path)
|
||||
fake_steam_tools(a, Path(a.HOME))
|
||||
monkeypatch.setattr(a, "same_device", lambda x, y: False)
|
||||
prep = a.cmd_prepare_pcvr({"package": "rift.game", "title": "Rift Game"})
|
||||
inc = Path(prep["incoming"])
|
||||
(inc / "game/UEGame.exe").write_bytes(b"MZexe")
|
||||
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
|
||||
(inc / "revive/LibReviveXR64.dll").write_bytes(b"MZdll")
|
||||
a.cmd_finalize_pcvr({"package": "rift.game", "title": "Rift Game", "exe": "UEGame.exe", "libovr_redirect": True,
|
||||
"manifests": {"game": {"UEGame.exe": 5},
|
||||
"revive": {"ReviveInjector.exe": 2, "LibReviveXR64.dll": 5}},
|
||||
"env": {"FOO": "bar"}, "game_args": ["-vrmode"]})
|
||||
base = Path(prep["base"])
|
||||
(base / "compatdata/pfx/drive_c").mkdir(parents=True)
|
||||
(base / "compatdata/pfx/drive_c/save.sav").write_text("saved")
|
||||
assert os.path.islink(base / "game/LibOVRRT64_1.dll")
|
||||
r = a.cmd_move({"package": "rift.game", "dest": str(sd_dir(tmp_path)), "detach": False})
|
||||
assert r["state"] == "done", r
|
||||
new = sd_dir(tmp_path) / "FramePort" / "rift.game"
|
||||
assert (new / "compatdata/pfx/drive_c/save.sav").read_text() == "saved"
|
||||
assert os.readlink(new / "game/LibOVRRT64_1.dll") == str(new / "revive/LibReviveXR64.dll")
|
||||
text = (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
assert f"{base}/game" not in text and f"{base}/revive" not in text # (the play log stays in the anchor)
|
||||
assert "export FOO=bar" in text and "-vrmode" in text
|
||||
assert f"base='{new}'" in text
|
||||
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
|
||||
|
||||
|
||||
def test_cleanup_and_purge_on_a_drive(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
||||
fp = sd_dir(tmp_path) / "FramePort"
|
||||
prep = quest_install(a, dest=str(fp))
|
||||
(fp / "com.old.game").mkdir() # left by an earlier install
|
||||
(fp / "com.old.game" / "x").write_bytes(b"1234")
|
||||
assert a.cmd_cleanup({"paths": [str(fp / "com.old.game")]})["freed_bytes"] == 4
|
||||
for bad in (prep["base"], str(fp), str(fp / "com.x.y" / "lepton-data"), str(sd_dir(tmp_path))):
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_cleanup({"paths": [bad]})
|
||||
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
|
||||
assert json.loads((tmp_path / "st.json").read_text())["state"] == "done"
|
||||
assert not fp.exists() and sd_dir(tmp_path).exists()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Desktop Mode (#84)
|
||||
def test_linux_app_gets_a_desktop_mode_entry(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
(Path(a.HOME) / "Desktop").mkdir()
|
||||
prep, res = linux_install(a, tmp_path, title='Odd "Name" 100%', tags=["Linux", "Puzzle"])
|
||||
menu = Path(a.HOME) / ".local/share/applications/frameport-true.desktop"
|
||||
desk = Path(a.HOME) / "Desktop/frameport-true.desktop"
|
||||
assert res["desktop_entry"]["menu"] == str(menu) and res["desktop_entry"]["desktop"] == str(desk)
|
||||
text = menu.read_text()
|
||||
assert text == desk.read_text() and os.access(desk, os.X_OK)
|
||||
lines = dict(line.split("=", 1) for line in text.splitlines()[1:])
|
||||
assert lines["Name"] == 'Odd "Name" 100%' and lines["Categories"] == "Game;"
|
||||
assert lines["Exec"] == f'env FRAMEPORT_DESKTOP=1 "{prep["anchor"]}/launch.sh"'
|
||||
assert lines["Icon"] == f"{prep['anchor']}/artwork/icon.png" and lines["X-FramePort-Package"] == "linux.true"
|
||||
assert lines["Terminal"] == "false"
|
||||
# nothing changes when it is refreshed again; an app without game tags is a utility
|
||||
assert a.refresh_desktop_entries() == []
|
||||
linux_install(a, tmp_path, pkg="linux.editor", title="Editor")
|
||||
assert "Categories=Utility;" in (Path(a.HOME) / ".local/share/applications/frameport-editor.desktop").read_text()
|
||||
# uninstall removes both files
|
||||
a.cmd_uninstall({"package": "linux.true", "keep_data": False})
|
||||
assert not menu.exists() and not desk.exists()
|
||||
|
||||
|
||||
def test_exec_quoting():
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
|
||||
a = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(a)
|
||||
assert a.desktop_exec_arg('/a b/$x`"\\%') == '"/a b/\\\\$x\\\\`\\\\"\\\\\\\\%%"'
|
||||
assert a.desktop_value("two\nlines\\") == "two lines\\\\"
|
||||
assert a.desktop_file_name("linux.My App!") == "frameport-My-App.desktop"
|
||||
|
||||
|
||||
def test_desktop_entry_switch_and_refresh(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
linux_install(a, tmp_path, desktop_entry=False)
|
||||
menu = Path(a.HOME) / ".local/share/applications/frameport-true.desktop"
|
||||
assert not menu.exists() and a.deployment("linux.true")["desktop_entry"] is False
|
||||
assert a.refresh_desktop_entries() == [] # off stays off
|
||||
r = a.cmd_desktop_entry({"package": "linux.true", "enabled": True})
|
||||
assert r["enabled"] and menu.exists()
|
||||
a.cmd_desktop_entry({"package": "linux.true", "enabled": False})
|
||||
assert not menu.exists()
|
||||
# an install from before agent v63 (no desktop_entry field) gets one with the host fixes; stale ones go
|
||||
dep_path = Path(a.ANCHORS) / "linux.true" / "deployment.json"
|
||||
raw = json.loads(dep_path.read_text())
|
||||
raw.pop("desktop_entry")
|
||||
dep_path.write_text(json.dumps(raw))
|
||||
stale = menu.parent / "frameport-gone.desktop"
|
||||
stale.write_text("[Desktop Entry]\nX-FramePort-Package=linux.gone\n")
|
||||
(menu.parent / "frameport-links.desktop").write_text("[Desktop Entry]\nName=not an app entry\n")
|
||||
assert "Desktop Mode entries (2)" in a.ensure_host_fixes()
|
||||
assert menu.exists() and not stale.exists() and (menu.parent / "frameport-links.desktop").exists()
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_desktop_entry({"package": "com.not.linux", "enabled": True})
|
||||
|
||||
|
||||
def test_linux_launcher_from_desktop_mode(monkeypatch, tmp_path):
|
||||
"""Started from Desktop Mode (FRAMEPORT_DESKTOP=1) the launcher's parent exits at once: the app must keep running,
|
||||
and the desktop's display is used instead of Steam's."""
|
||||
a = load(monkeypatch, tmp_path)
|
||||
prep, _ = linux_install(a, tmp_path)
|
||||
text = (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
assert '[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1' in text
|
||||
assert 'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then' in text
|
||||
# older launchers are upgraded in place
|
||||
old = text.replace('[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1\n', "").replace(
|
||||
'if [[ -z "${FRAMEPORT_DESKTOP:-}" && -z "${DISPLAY:-}" ]]; then', 'if [[ -z "${DISPLAY:-}" ]]; then')
|
||||
old = old.replace("# Started from Desktop Mode's menu (FRAMEPORT_DESKTOP=1, GitHub #84) the desktop's own session "
|
||||
"is used.\n", "")
|
||||
assert "FRAMEPORT_DESKTOP" not in old
|
||||
(Path(prep["anchor"]) / "launch.sh").write_text(old)
|
||||
assert "linux.true" in a.upgrade_launchers()
|
||||
new = (Path(prep["anchor"]) / "launch.sh").read_text()
|
||||
assert '[[ -n "${FRAMEPORT_DESKTOP:-}" ]] && parent=1' in new and "FRAMEPORT_DESKTOP:-}\" && -z" in new
|
||||
assert subprocess.run(["bash", "-n", str(Path(prep["anchor"]) / "launch.sh")]).returncode == 0
|
||||
# the watchdog: a parent that exits ends the app, unless started from the desktop
|
||||
script = tmp_path / "app.sh"
|
||||
script.write_text("#!/bin/sh\nsleep 6\necho finished >" + str(tmp_path / "out") + "\n")
|
||||
script.chmod(0o755)
|
||||
program = os.path.join(prep["base"], "app", "bin", "true")
|
||||
assert program in new
|
||||
body = new.replace(program, str(script))
|
||||
launcher = tmp_path / "l.sh"
|
||||
launcher.write_text(body)
|
||||
launcher.chmod(0o755)
|
||||
env = dict(os.environ, FRAMEPORT_DESKTOP="1", DISPLAY=":9")
|
||||
subprocess.run(["bash", "-c", f"{launcher} & exit 0"], env=env, timeout=20)
|
||||
import time
|
||||
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline and not (tmp_path / "out").exists():
|
||||
time.sleep(0.5)
|
||||
assert (tmp_path / "out").read_text().strip() == "finished"
|
||||
@@ -1,59 +0,0 @@
|
||||
"""The sidebar's live Frame card: display values from a monitor sample (ui/frame_card.py, no Flet)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.ui import frame_card as FC
|
||||
|
||||
|
||||
def sample(**game):
|
||||
g = {"package": "com.example.game", "title": "Example Game", "kind": "quest", "elapsed": 27 * 60 + 10,
|
||||
"fps": 71.6}
|
||||
g.update(game)
|
||||
return {"t": 1000.0, "games": [g], "battery": {"percent": 76, "plugged": False, "draining": True}}
|
||||
|
||||
|
||||
def test_no_game_no_row():
|
||||
assert FC.now_playing(None) is None
|
||||
assert FC.now_playing({"games": []}) is None
|
||||
assert FC.now_playing({"battery": {"percent": 50}}) is None
|
||||
|
||||
|
||||
def test_now_playing_values():
|
||||
np = FC.now_playing(sample(), [72.0] * 10)
|
||||
assert np["package"] == "com.example.game" and np["title"] == "Example Game"
|
||||
assert np["minutes"] == 27 and "27 min" in np["line"]
|
||||
assert np["fps_text"] == "72" and np["level"] == "ok" and np["target"] == 72
|
||||
|
||||
|
||||
def test_fps_levels_against_the_refresh_rate():
|
||||
history = [90.0] * 20 # aiming for 90 Hz
|
||||
assert FC.now_playing(sample(fps=78.0), history)["level"] == "warn" # < 90 %
|
||||
assert FC.now_playing(sample(fps=60.0), history)["level"] == "error" # < 75 %
|
||||
assert FC.now_playing(sample(fps=89.5), history)["level"] == "ok"
|
||||
|
||||
|
||||
def test_missing_fps_and_title():
|
||||
np = FC.now_playing(sample(fps=None, title=None, elapsed=None))
|
||||
assert np["fps_text"] == "–" and np["level"] == "none" and np["target"] is None
|
||||
assert np["title"] == "com.example.game" and np["minutes"] is None and np["line"]
|
||||
|
||||
|
||||
def test_battery_ring():
|
||||
assert FC.battery_ring(None) is None and FC.battery_ring({}) is None
|
||||
r = FC.battery_ring({"percent": 76, "plugged": False, "draining": True})
|
||||
assert r == {"value": 0.76, "text": "76", "level": "ok"}
|
||||
assert FC.battery_ring({"percent": 12, "plugged": False, "draining": True})["level"] == "warn"
|
||||
assert FC.battery_ring({"percent": 40, "plugged": True, "draining": False})["level"] == "charging"
|
||||
assert FC.battery_ring({"percent": 140})["value"] == 1.0
|
||||
|
||||
|
||||
def test_fps_history_covers_two_minutes():
|
||||
h = FC.fps_history()
|
||||
for i in range(300): # one sample a second for 5 minutes
|
||||
h.add("fps", 72.0, 1000.0 + i)
|
||||
assert len(h.get("fps")) == FC.FPS_POINTS and FC.FPS_POINTS * FC.FPS_BUCKET == 120
|
||||
|
||||
|
||||
def test_uploading():
|
||||
assert FC.uploading(SimpleNamespace(stage="Upload game (3 files, 1.2 GiB)"))
|
||||
assert not FC.uploading(SimpleNamespace(stage="Finalizing"))
|
||||
assert not FC.uploading(None)
|
||||
@@ -73,3 +73,22 @@ def test_enabled_patch_is_never_hidden():
|
||||
r.patches["frame.vrapi_bridge"] = {}
|
||||
shown, _ = engine.visible_patches(a, r)
|
||||
assert "frame.vrapi_bridge" in {p.id for p in shown}
|
||||
|
||||
|
||||
def test_newer_android_than_the_frames_only_warns():
|
||||
"""GitHub #71/#72: minSdk 34 apps crash at start on Android 13+ classes; Lepton runs Android 11."""
|
||||
_, r = suggest(extra={"size": 1, "min_sdk": 34})
|
||||
assert r.status != "unsupported" # the manifest alone never decides (a launch test does)
|
||||
assert "asks for Android 14 (API 34)" in r.notes and "Android is 11 (API 30)" in r.notes
|
||||
# Quest games declare up to 32 (Quest's Android 12L) and run (16 catalog games do)
|
||||
for ok in (None, 23, 29, 32):
|
||||
_, r = suggest(extra={"size": 1, "min_sdk": ok})
|
||||
assert r.status != "unsupported" and "asks for Android" not in r.notes
|
||||
|
||||
|
||||
def test_web_wrapper_only_warns():
|
||||
"""GitHub #86: a Trusted Web Activity opens a website in Meta's browser; there's nothing to port."""
|
||||
_, r = suggest(extra={"size": 1, "web_wrapper": {"url": "https://mahjong-vr.pages.dev/"}})
|
||||
assert r.status != "unsupported" and "open https://mahjong-vr.pages.dev/ in a browser" in r.notes
|
||||
_, r = suggest(extra={"size": 1, "web_wrapper": {"url": None}})
|
||||
assert r.status != "unsupported" and "website" in r.notes
|
||||
@@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
UI = ROOT / "src" / "frameport" / "ui"
|
||||
# calls whose first argument is text shown to the user
|
||||
TEXT_CALLS = {"body", "meta", "title", "h2", "primary", "secondary", "ghost", "pill", "callout", "toast", "switch",
|
||||
"section", "Text", "TextButton", "empty_state", "confirm", "kv"}
|
||||
"section", "Text", "TextButton", "empty_state", "confirm", "kv", "status_row"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""PC side of games on another drive (GitHub #90) and Linux apps' Desktop Mode entries (GitHub #84)."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
from frameport.frame.connection import FrameTarget
|
||||
from frameport.install import drives, installer
|
||||
from frameport.targets.frame_lepton import FrameLeptonTarget
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
"""Answers agent commands from a script: {command: reply or [replies] or callable(args)}."""
|
||||
|
||||
def __init__(self, replies):
|
||||
self.replies, self.calls = replies, []
|
||||
|
||||
def agent(self, command, timeout=None, **args):
|
||||
self.calls.append((command, args))
|
||||
r = self.replies[command]
|
||||
if callable(r):
|
||||
return r(args)
|
||||
if isinstance(r, list):
|
||||
return r.pop(0) if len(r) > 1 else r[0]
|
||||
return r
|
||||
|
||||
|
||||
def test_install_dest_setting():
|
||||
assert drives.install_dest() is None
|
||||
drives.set_install_dest("/run/media/steamos/SD/FramePort")
|
||||
assert drives.install_dest() == "/run/media/steamos/SD/FramePort"
|
||||
assert library.setting(drives.SETTING) == "/run/media/steamos/SD/FramePort"
|
||||
drives.set_install_dest(None)
|
||||
assert drives.install_dest() is None and library.setting(drives.SETTING) == "internal"
|
||||
t = FrameLeptonTarget(FrameTarget("frame.local", "steamos", 22))
|
||||
drives.set_install_dest("/run/media/steamos/SD/FramePort")
|
||||
assert t.install_dest() == "/run/media/steamos/SD/FramePort"
|
||||
t.dest = "" # frameport install --dest internal
|
||||
assert t.install_dest() == ""
|
||||
|
||||
|
||||
def test_linux_install_passes_dest_and_desktop_entry(monkeypatch, tmp_path):
|
||||
(tmp_path / "app").mkdir()
|
||||
(tmp_path / "app" / "tool").write_bytes(b"#!/bin/sh\n")
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10}}, "free_bytes": 1 << 40,
|
||||
"incoming": "/x/incoming", "base": "/sd/FramePort/linux.tool"},
|
||||
"finalize_linux": {"base": "/sd/FramePort/linux.tool", "appid": 1,
|
||||
"desktop_entry": {"menu": "/m", "desktop": "/d"}}})
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", dest="/sd/FramePort",
|
||||
desktop_entry=False)
|
||||
rep = Reporter()
|
||||
checks = []
|
||||
rep.subscribe(lambda ev: ev.kind == "check" and checks.append(ev.data["name"]))
|
||||
installer.install_linux(frame, plan, rep)
|
||||
assert frame.calls[0] == ("prepare_linux", {"package": "linux.tool", "title": "Tool", "dest": "/sd/FramePort"})
|
||||
fin = dict(frame.calls)["finalize_linux"]
|
||||
assert fin["dest"] == "/sd/FramePort" and fin["desktop_entry"] is False
|
||||
assert "Desktop Mode entry" in checks
|
||||
|
||||
|
||||
def test_move_follows_the_agents_progress():
|
||||
status = [{"state": "none"},
|
||||
{"state": "running", "package": "com.x.y", "phase": "copying", "done_bytes": 1 << 30,
|
||||
"total_bytes": 2 << 30},
|
||||
{"state": "running", "package": "com.x.y", "phase": "verifying", "done_bytes": 2 << 30,
|
||||
"total_bytes": 2 << 30},
|
||||
{"state": "done", "package": "com.x.y", "base": "/sd/FramePort/com.x.y"}]
|
||||
frame = FakeFrame({"move_status": status,
|
||||
"move": {"started": True, "from": "/home/a", "to": "/sd/FramePort/com.x.y",
|
||||
"total_bytes": 2 << 30}})
|
||||
rep = Reporter()
|
||||
seen = []
|
||||
rep.subscribe(lambda ev: ev.kind == "progress" and seen.append((round(ev.fraction, 2), ev.message)))
|
||||
st = drives.move(frame, "com.x.y", "/sd/FramePort", rep, sleep=lambda s: None)
|
||||
assert st["state"] == "done"
|
||||
assert ("move", {"package": "com.x.y", "dest": "/sd/FramePort"}) in frame.calls
|
||||
assert seen[0] == (0.5, "Copying 1.0/2.0 GiB") and seen[1][1].startswith("Checking the copy")
|
||||
|
||||
|
||||
def test_move_picks_up_a_running_move_and_reports_failures():
|
||||
frame = FakeFrame({"move_status": [{"state": "running", "package": "com.x.y", "total_bytes": 0},
|
||||
{"state": "failed", "error": "the copy doesn't match"}],
|
||||
"move": lambda a: pytest.fail("a running move of the same game isn't started again")})
|
||||
with pytest.raises(RuntimeError, match="doesn't match"):
|
||||
drives.move(frame, "com.x.y", "internal", Reporter(), sleep=lambda s: None)
|
||||
# a retried job (the Frame was lost) whose move finished meanwhile
|
||||
from frameport.frame.connection import AgentFailed
|
||||
|
||||
def already(a):
|
||||
raise AgentFailed("Game is already there")
|
||||
frame = FakeFrame({"move_status": {"state": "done", "package": "com.x.y"}, "move": already})
|
||||
assert drives.move(frame, "com.x.y", "internal", Reporter(), sleep=lambda s: None)["state"] == "done"
|
||||
# cancelled before the copy started: nothing is asked of the Frame
|
||||
frame = FakeFrame({"move_status": {"state": "none"}, "move": lambda a: pytest.fail("started")})
|
||||
rep = Reporter()
|
||||
rep.cancelled.set()
|
||||
with pytest.raises(Cancelled):
|
||||
drives.move(frame, "com.x.y", "internal", rep, sleep=lambda s: None)
|
||||
|
||||
|
||||
def test_drive_helpers():
|
||||
d_int = {"internal": True, "path": "/home/steamos", "label": "Internal storage", "free_bytes": 12.5 * 2**30}
|
||||
d_sd = {"internal": False, "path": "/run/media/steamos/SD", "label": "SD", "free_bytes": None}
|
||||
assert drives.drive_text(d_int) == "Internal storage · 12.5 GiB free" and drives.drive_text(d_sd) == "SD"
|
||||
on_sd = {"drive": {"internal": False, "path": "/run/media/steamos/SD", "label": "SD"}, "drive_missing": True}
|
||||
assert drives.game_drive(on_sd)["missing"] is True and drives.game_drive({"package": "x"}) is None
|
||||
assert drives.is_on(on_sd, d_sd) and not drives.is_on(on_sd, d_int)
|
||||
assert drives.is_on({"package": "old agent"}, d_int)
|
||||
|
||||
|
||||
def test_target_switches_a_desktop_entry(monkeypatch):
|
||||
t = FrameLeptonTarget(FrameTarget("frame.local", "steamos", 22))
|
||||
t.frame = FakeFrame({"desktop_entry": lambda a: {"enabled": a["enabled"]}})
|
||||
monkeypatch.setattr(t, "connect", lambda: t)
|
||||
assert t.set_desktop_entry("linux.tool", False) == {"enabled": False}
|
||||
assert t.frame.calls == [("desktop_entry", {"package": "linux.tool", "enabled": False})]
|
||||
|
||||
|
||||
def test_pipeline_passes_the_desktop_entry_choice(monkeypatch):
|
||||
from frameport import pipeline
|
||||
|
||||
library.upsert_game("linux.tool", kind="linux", title="Tool", game_dir=str(Path("/apps/tool")), exe="tool",
|
||||
desktop_entry=False, analysis={"extra": {}})
|
||||
got = {}
|
||||
|
||||
class T:
|
||||
label = "Frame"
|
||||
|
||||
def install_linux(self, *a, **k):
|
||||
got.update(k)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
|
||||
pipeline.install_linux("linux.tool", T(), Reporter(), add_to_library=False)
|
||||
assert got["desktop_entry"] is False
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Library "On your Frame" shelf (which games, order, limit, when it hides) and the cover tint helpers."""
|
||||
import colorsys
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.artwork.thumbs import dominant_rgb, tint_from_rgb
|
||||
from frameport.ui.views.library import DEFAULT_FILTERS, filters_active, shelf_games
|
||||
|
||||
|
||||
def g(pkg, played=0, sha=None, title=None):
|
||||
return {"package": pkg, "title": title or pkg, "last_played": played,
|
||||
"build": {"sha256": sha} if sha else {}}
|
||||
|
||||
|
||||
FRAME = {"installed": [{"package": "a", "sha256": "1"}, {"package": "b", "sha256": "old"},
|
||||
{"package": "c", "sha256": "1"}]}
|
||||
GAMES = [g("a", 10, "1"), g("b", 30, "1"), g("c", 0, "1"), g("d", 99, "1")] # d isn't on the Frame
|
||||
|
||||
|
||||
def names(games):
|
||||
return [x["package"] for x in games]
|
||||
|
||||
|
||||
def test_shelf_installed_most_recent_first():
|
||||
# outdated (b) counts as on the Frame; d isn't installed; never played (c) last
|
||||
assert names(shelf_games(GAMES, dict(DEFAULT_FILTERS), FRAME, True)) == ["b", "a", "c"]
|
||||
|
||||
|
||||
def test_shelf_limit():
|
||||
games = [g(f"p{i}", i) for i in range(12)]
|
||||
frame = {"installed": [{"package": x["package"]} for x in games]}
|
||||
out = shelf_games(games, dict(DEFAULT_FILTERS), frame, True)
|
||||
assert len(out) == 8 and out[0]["package"] == "p11"
|
||||
assert len(shelf_games(games, dict(DEFAULT_FILTERS), frame, True, limit=3)) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [{"q": "x"}, {"where": "frame"}, {"platform": "pcvr"}, {"status": "works"},
|
||||
{"tags": ["Favorite"]}])
|
||||
def test_shelf_hides_while_filtering(change):
|
||||
f = {**DEFAULT_FILTERS, **change}
|
||||
assert filters_active(f)
|
||||
assert shelf_games(GAMES, f, FRAME, True) == []
|
||||
|
||||
|
||||
def test_shelf_ignores_sort_and_blank_search():
|
||||
f = {**DEFAULT_FILTERS, "sort": "size", "q": " "}
|
||||
assert not filters_active(f)
|
||||
assert shelf_games(GAMES, f, FRAME, True)
|
||||
|
||||
|
||||
def test_shelf_hides_without_frame_but_stays_in_select_mode():
|
||||
f = dict(DEFAULT_FILTERS)
|
||||
assert shelf_games(GAMES, f, FRAME, False) == []
|
||||
assert shelf_games(GAMES, f, None, False) == []
|
||||
# select mode keeps it: hiding it shifted the grid under a drag-select
|
||||
assert shelf_games(GAMES, f, FRAME, True, select_mode=True) == shelf_games(GAMES, f, FRAME, True)
|
||||
assert shelf_games(GAMES, f, {"installed": []}, True) == []
|
||||
|
||||
|
||||
def hls(hex_):
|
||||
return colorsys.rgb_to_hls(*(int(hex_[i:i + 2], 16) / 255 for i in (1, 3, 5)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rgb", [(255, 0, 0), (0, 0, 0), (255, 255, 255), (20, 200, 240), (128, 128, 128),
|
||||
(250, 240, 30), (10, 5, 40)])
|
||||
def test_tint_is_clamped(rgb):
|
||||
out = tint_from_rgb(rgb)
|
||||
assert out.startswith("#") and len(out) == 7
|
||||
_, lum, s = hls(out)
|
||||
assert 0.35 - 0.01 <= lum <= 0.55 + 0.01
|
||||
assert s <= 0.55 + 0.01
|
||||
|
||||
|
||||
def test_tint_keeps_hue_and_mild_colours():
|
||||
h0 = colorsys.rgb_to_hls(20 / 255, 200 / 255, 240 / 255)[0]
|
||||
assert abs(hls(tint_from_rgb((20, 200, 240)))[0] - h0) < 0.02
|
||||
assert tint_from_rgb((115, 89, 64)) == "#735940" # already inside the range: unchanged
|
||||
|
||||
|
||||
def test_dominant_rgb_prefers_colour():
|
||||
black, white, red, blue = (5, 5, 5), (250, 250, 250), (200, 30, 30), (30, 30, 200)
|
||||
assert dominant_rgb([(100, black), (80, white), (10, red), (20, blue)]) == blue
|
||||
assert dominant_rgb([(100, black), (5, (120, 120, 120))]) == black # nothing colourful: the most common
|
||||
assert dominant_rgb([]) is None
|
||||
|
||||
|
||||
def test_compute_tint_cached_next_to_the_art(tmp_path, monkeypatch):
|
||||
from PIL import Image
|
||||
|
||||
from frameport.artwork import thumbs
|
||||
|
||||
src = tmp_path / "portrait.png"
|
||||
im = Image.new("RGB", (60, 90), (8, 8, 8))
|
||||
im.paste((40, 90, 220), (0, 0, 60, 30)) # a third blue on black
|
||||
im.save(src)
|
||||
monkeypatch.setattr(thumbs, "pick", lambda pkg, kinds: src)
|
||||
monkeypatch.setattr(thumbs, "_tints", {})
|
||||
assert thumbs.cached_tint("x.tint") == (False, None)
|
||||
tint = thumbs.compute_tint("x.tint")
|
||||
h, lum, s = hls(tint)
|
||||
assert 0.55 < h < 0.7 and 0.35 <= lum <= 0.56 and s <= 0.56 # blue, clamped
|
||||
assert thumbs.cached_tint("x.tint") == (True, tint)
|
||||
cached = list(tmp_path.glob("t_tint_*.json"))
|
||||
assert len(cached) == 1
|
||||
cached[0].write_text('{"tint": "#123456"}', encoding="utf-8") # the cached answer is used: no image work
|
||||
assert thumbs.compute_tint("x.tint") == "#123456"
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Library type-to-search: which key presses type a character and when they go to the search field."""
|
||||
import pytest
|
||||
|
||||
from frameport.ui.views.library import should_capture, typed_char
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,shift,want", [
|
||||
("A", False, "a"), ("A", True, "A"), ("z", False, "z"),
|
||||
("1", False, "1"), ("1", True, "!"), ("0", False, "0"),
|
||||
(" ", False, " "), ("Space", False, " "),
|
||||
("-", False, "-"), ("-", True, "_"), (".", False, "."), ("'", False, "'"), (",", False, ","),
|
||||
("&", False, "&"), ("Numpad 7", False, "7"), ("Numpad Decimal", False, "."),
|
||||
])
|
||||
def test_typed_char_printable(key, shift, want):
|
||||
assert typed_char(key, shift) == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["Shift Left", "Control Left", "Alt", "Meta Left", "Caps Lock", "Arrow Left",
|
||||
"Arrow Down", "Home", "Page Up", "F1", "F12", "Escape", "Enter", "Tab",
|
||||
"Backspace", "Delete", "", "\t"])
|
||||
def test_typed_char_named_keys(key):
|
||||
assert typed_char(key) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mods", [dict(ctrl=True), dict(alt=True), dict(meta=True), dict(ctrl=True, shift=True)])
|
||||
def test_typed_char_shortcuts(mods):
|
||||
assert typed_char("F", **mods) is None
|
||||
assert typed_char("1", **mods) is None
|
||||
|
||||
|
||||
def test_should_capture():
|
||||
assert should_capture("library", False, False, "a")
|
||||
assert not should_capture("game", False, False, "a") # the game page has no search
|
||||
assert not should_capture("files", False, False, "a")
|
||||
assert not should_capture("library", True, False, "a") # a dialog has the keyboard
|
||||
assert not should_capture("library", False, True, "a") # the field types it itself
|
||||
assert not should_capture("library", False, False, None)
|
||||
assert not should_capture("library", False, False, " ") # Space on a focused button, not a search
|
||||
assert should_capture("library", False, False, " ", "arc")
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Menu structure: game menus (Library right-click, game page "…"), the Monitor process menu and menu_items."""
|
||||
import flet as ft
|
||||
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import theme as T
|
||||
from frameport.ui.menus import Header, MenuState, menu_sections, process_menu
|
||||
|
||||
DESTRUCTIVE = {"Uninstall from Frame…", "Uninstall from this PC…", "Remove from library…"}
|
||||
QUEST = {"package": "com.q", "title": "Q"}
|
||||
RIFT = {"package": "rift.r", "kind": "rift", "title": "R"}
|
||||
LINUX = {"package": "linux.l", "kind": "linux", "title": "L"}
|
||||
|
||||
|
||||
def _key(k):
|
||||
return k
|
||||
|
||||
|
||||
def _play(where="Frame"):
|
||||
return (f"Play on {where}", ft.Icons.PLAY_ARROW_ROUNDED, f"play_{where}")
|
||||
|
||||
|
||||
def _install(label="Reinstall on Frame"):
|
||||
return (label, "fp:frame", "install")
|
||||
|
||||
|
||||
def _states():
|
||||
"""(name, game, state) for the cases the menus have to handle."""
|
||||
return [
|
||||
("quest on Frame", QUEST, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
settings=True, selectable=True)),
|
||||
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
|
||||
settings=True, selectable=True)),
|
||||
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
|
||||
installs=[_install("Reinstall on this PC")])),
|
||||
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
selectable=True)),
|
||||
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True)),
|
||||
]
|
||||
|
||||
|
||||
def _items(entries):
|
||||
return [e for e in entries if e is not None and not isinstance(e, Header)]
|
||||
|
||||
|
||||
def _section_of(entries, label):
|
||||
"""The header the item sits under (None = top section)."""
|
||||
head = None
|
||||
for e in entries:
|
||||
if isinstance(e, Header):
|
||||
head = e.label
|
||||
elif e is not None and e[0] == label:
|
||||
return head
|
||||
raise AssertionError(f"{label} not in menu")
|
||||
|
||||
|
||||
def test_library_menu_structure():
|
||||
for name, g, st in _states():
|
||||
entries = C.menu_entries(menu_sections(g, st, _key))
|
||||
labels = [e[0] for e in _items(entries)]
|
||||
assert "Select" in labels[:4], (name, labels)
|
||||
assert "Type on Frame" not in labels
|
||||
# destructive items close the menu, after a divider
|
||||
n = sum(1 for lbl in labels if lbl in DESTRUCTIVE)
|
||||
assert labels[-n:] == [lbl for lbl in labels if lbl in DESTRUCTIVE] and labels[-1] == "Remove from library…"
|
||||
first = next(i for i, e in enumerate(entries) if e is not None and not isinstance(e, Header)
|
||||
and e[0] in DESTRUCTIVE)
|
||||
assert entries[first - 1] is None, name
|
||||
# every header has items under it; every item has an icon
|
||||
for i, e in enumerate(entries):
|
||||
if isinstance(e, Header):
|
||||
assert i + 1 < len(entries) and entries[i + 1] is not None and not isinstance(entries[i + 1], Header)
|
||||
assert all(e[1] for e in _items(entries)), name
|
||||
if st.on_frame and not st.job:
|
||||
assert _section_of(entries, "Run launch test on Frame") == "Troubleshoot"
|
||||
if st.plays and not st.job:
|
||||
assert labels[0].startswith("Play on")
|
||||
|
||||
|
||||
def test_job_items_first_and_page_menu_leaves_out_its_buttons():
|
||||
_, g, st = _states()[-1]
|
||||
labels = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
assert labels[:2] == ["Show progress", "Cancel"]
|
||||
for name, g, st in _states():
|
||||
st.quick = False
|
||||
labels = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
for gone in ("Select", "Open game page", "Show progress", "Cancel", "Play on Frame", "Reinstall on Frame",
|
||||
"Run launch test on Frame", "Uninstall from Frame…"):
|
||||
assert gone not in labels, (name, gone)
|
||||
assert labels[-1] == "Remove from library…"
|
||||
|
||||
|
||||
def test_sections_and_conditions():
|
||||
_, g, st = _states()[0]
|
||||
entries = menu_sections(g, st, _key)
|
||||
heads = [e.label for e in entries if isinstance(e, Header)]
|
||||
assert heads == ["Artwork", "Recipe", "Troubleshoot"]
|
||||
assert _section_of(entries, "Analyze again") == "Troubleshoot"
|
||||
assert _section_of(entries, "Update Steam art on Frame") == "Artwork"
|
||||
assert _section_of(entries, "Share working recipe…") == "Recipe"
|
||||
linux = menu_sections(LINUX, _states()[3][2], _key)
|
||||
assert "Recipe" not in [e.label for e in linux if isinstance(e, Header)]
|
||||
rift = [e[0] for e in _items(menu_sections(RIFT, _states()[2][2], _key))]
|
||||
assert {"Run launch test on this PC", "Check game files", "Change program…", "Uninstall from this PC…"} <= set(rift)
|
||||
assert "Analyze again" not in rift
|
||||
# the reset icon isn't the Frame's Restart icon
|
||||
reset = next(e for e in _items(entries) if e[0] == "Reset to suggested recipe…")
|
||||
assert reset[1] != ft.Icons.RESTART_ALT_ROUNDED
|
||||
|
||||
|
||||
def test_menu_entries_trim_headers_and_dividers():
|
||||
act = ("A", ft.Icons.ADD_ROUNDED, None)
|
||||
raw = [None, Header("Empty"), None, Header("Kept"), act, None, None, Header("Last")]
|
||||
assert C.menu_entries(raw) == [Header("Kept"), act]
|
||||
items = C.menu_items([act, None, Header("H"), act])
|
||||
assert items[0].height == items[3].height == T.px(32) and items[2].height == T.px(24)
|
||||
assert items[1].content is None # divider
|
||||
assert items[2].disabled and items[0].disabled # no handler: shown disabled
|
||||
|
||||
|
||||
def test_process_menu():
|
||||
proc = {"name": "x", "pid": 5, "game": "com.q"}
|
||||
labels = [e[0] if e else "—" for e in process_menu(proc, _key)]
|
||||
assert labels == ["Copy name", "Copy PID 5", "—", "End…", "Force quit…", "—", "End game…"]
|
||||
locked = process_menu({**proc, "locked": True}, _key)
|
||||
prot = next(e for e in locked if e and e[0] == "Protected by SteamOS")
|
||||
assert prot[2] is None and "End…" not in [e[0] for e in locked if e]
|
||||
group = [e[0] if e else "—" for e in process_menu({"kind": "group", "count": 3, "name": "g"}, _key)]
|
||||
assert group == ["Show its processes", "Copy name", "—", "End all 3…", "Force quit all 3…"]
|
||||
end = next(e for e in process_menu(proc, _key) if e and e[0] == "End…")
|
||||
assert end[1] != ft.Icons.CLOSE_ROUNDED
|
||||
@@ -199,7 +199,7 @@ def test_formatting():
|
||||
assert M.fmt_bytes(None) == "–" and M.fmt_rate(2048) == "2 KB/s"
|
||||
assert M.fmt_watts(3.558) == "3.56 W" and M.fmt_watts(12.34) == "12.3 W"
|
||||
assert M.fmt_duration(45) == "45 s" and M.fmt_duration(720) == "12 min" and M.fmt_duration(3900) == "1 h 05 min"
|
||||
assert M.fmt_pct(33.4) == "33%" and M.fmt_pct(None) == "–"
|
||||
assert M.fmt_pct(33.4) == "33 %" and M.fmt_pct(None) == "–"
|
||||
|
||||
|
||||
def test_levels():
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
"""MonitorHub: one shared `_monitor` stream for every subscriber (union of modules, fastest interval, fan-out,
|
||||
reconnect), and the agent's per-module collection (monitor_plan, {"modules": …})."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_monitor_agent import fa, monitor # noqa: F401 (fixture + helper)
|
||||
|
||||
from frameport.frame import monitor as M
|
||||
from frameport.frame import monitor_hub as H
|
||||
|
||||
|
||||
class FakeSession:
|
||||
instances: list = []
|
||||
|
||||
def __init__(self, frame, on_sample, on_end, agent=63):
|
||||
self.frame, self.on_sample, self.on_end = frame, on_sample, on_end
|
||||
self.static = {"agent": agent}
|
||||
self.calls: list = []
|
||||
self.closed = False
|
||||
FakeSession.instances.append(self)
|
||||
|
||||
def set_interval(self, s):
|
||||
self.calls.append(("interval", s))
|
||||
|
||||
def set_modules(self, m):
|
||||
self.calls.append(("modules", m))
|
||||
return True
|
||||
|
||||
def set_filter(self, w):
|
||||
self.calls.append(("filter", w))
|
||||
|
||||
def pause(self, p):
|
||||
self.calls.append(("pause", p))
|
||||
|
||||
def kill(self, pid, sig="TERM", force=False):
|
||||
return {"ended": True, "pid": pid, "sig": sig, "force": force}
|
||||
|
||||
def end_game(self, pkg):
|
||||
return {"ended": True, "package": pkg}
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
# test helpers
|
||||
def push(self, sample):
|
||||
self.on_sample(sample)
|
||||
|
||||
def end(self, why="gone"):
|
||||
self.closed = True
|
||||
self.on_end(why)
|
||||
|
||||
|
||||
class Timers:
|
||||
def __init__(self):
|
||||
self.pending: list = []
|
||||
|
||||
def __call__(self, delay, fn):
|
||||
t = SimpleNamespace(delay=delay, fn=fn, cancelled=False)
|
||||
t.cancel = lambda: setattr(t, "cancelled", True)
|
||||
self.pending.append(t)
|
||||
return t
|
||||
|
||||
def fire(self):
|
||||
due, self.pending = self.pending, []
|
||||
for t in due:
|
||||
if not t.cancelled:
|
||||
t.fn()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub():
|
||||
FakeSession.instances = []
|
||||
timers = Timers()
|
||||
target = SimpleNamespace(frame="frame")
|
||||
box = {"target": target}
|
||||
h = H.MonitorHub(lambda: box["target"], session_factory=FakeSession, spawn=lambda fn: fn(), timer=timers)
|
||||
h.timers, h.box = timers, box
|
||||
return h
|
||||
|
||||
|
||||
def sub(modules, interval, paused=False):
|
||||
s = H.Subscriber("x", H.norm_modules(modules), interval, lambda _s: None)
|
||||
s.paused = paused
|
||||
return s
|
||||
|
||||
|
||||
def test_merge():
|
||||
assert H.merge([]) == (None, None)
|
||||
assert H.merge([sub({"cpu"}, 2), sub({"games", "battery"}, 1)]) == (frozenset({"cpu", "games", "battery"}), 1.0)
|
||||
assert H.merge([sub({"cpu"}, 2), sub("all", 5)]) == ("all", 2.0)
|
||||
assert H.merge([sub({"cpu"}, 2), sub("all", 0.1, paused=True)]) == (frozenset({"cpu"}), 2.0)
|
||||
assert H.merge([sub("all", 0.1, paused=True)]) == (None, None)
|
||||
assert H.merge([sub({"cpu"}, 0.3)]) == (frozenset({"cpu"}), 0.25) # snapped to what the agent accepts
|
||||
assert H.snap_interval(0.01) == 0.1 and H.snap_interval(10) == 5.0
|
||||
|
||||
|
||||
def test_subscribe_starts_once_and_reconfigures(hub):
|
||||
states = []
|
||||
hub.subscribe("card", {"battery", "games"}, 2, lambda s: None, lambda *a: states.append(a))
|
||||
assert len(FakeSession.instances) == 1
|
||||
s = FakeSession.instances[0]
|
||||
assert s.calls == [("interval", 2.0), ("modules", frozenset({"battery", "games"}))]
|
||||
assert states == [("card", "connecting", None), ("card", "live", None)]
|
||||
assert hub.static == {"agent": 63}
|
||||
later = []
|
||||
hub.subscribe("tab", "all", 0.5, lambda s: None, lambda *a: later.append(a))
|
||||
assert len(FakeSession.instances) == 1 # same stream, reconfigured
|
||||
assert s.calls[-2:] == [("interval", 0.5), ("modules", "all")]
|
||||
assert later == [("tab", "live", None)]
|
||||
hub.update("tab", interval=0.1)
|
||||
assert s.calls[-1] == ("interval", 0.1)
|
||||
hub.unsubscribe("tab")
|
||||
assert s.calls[-2:] == [("interval", 2.0), ("modules", frozenset({"battery", "games"}))] and not s.closed
|
||||
hub.unsubscribe("card")
|
||||
assert s.closed and hub.session is None and hub.state == "idle"
|
||||
hub.subscribe("card", {"cpu"}, 1, lambda s: None)
|
||||
assert len(FakeSession.instances) == 2 # lazily started again
|
||||
|
||||
|
||||
def test_pause_excludes_and_stops(hub):
|
||||
got = {"a": [], "b": []}
|
||||
hub.subscribe("a", {"cpu"}, 1, got["a"].append)
|
||||
hub.subscribe("b", "all", 0.25, got["b"].append)
|
||||
s = FakeSession.instances[0]
|
||||
hub.pause("b", True)
|
||||
assert s.calls[-2:] == [("interval", 1.0), ("modules", frozenset({"cpu"}))]
|
||||
s.push({"n": 1})
|
||||
assert got == {"a": [{"n": 1}], "b": []}
|
||||
hub.pause("a", True) # nobody active: the stream ends
|
||||
assert s.closed and hub.session is None
|
||||
hub.pause("b", False)
|
||||
assert len(FakeSession.instances) == 2 and FakeSession.instances[1].calls[-1] == ("interval", 0.25)
|
||||
|
||||
|
||||
def test_fan_out_survives_a_failing_subscriber(hub):
|
||||
got = []
|
||||
|
||||
def bad(_s):
|
||||
raise RuntimeError("view bug")
|
||||
hub.subscribe("bad", "all", 1, bad)
|
||||
hub.subscribe("good", "all", 1, got.append)
|
||||
FakeSession.instances[0].push({"n": 1})
|
||||
FakeSession.instances[0].push({"n": 2})
|
||||
assert got == [{"n": 1}, {"n": 2}]
|
||||
|
||||
|
||||
def test_reconnect_after_loss(hub):
|
||||
states = []
|
||||
hub.subscribe("tab", {"procs"}, 1, lambda s: None, lambda *a: states.append(a[1:]))
|
||||
hub.set_filter("all")
|
||||
hub.pause_stream(True)
|
||||
first = FakeSession.instances[0]
|
||||
first.end("connection reset")
|
||||
assert hub.session is None and states[-1] == ("lost", "connection reset")
|
||||
assert len(hub.timers.pending) == 1 and hub.timers.pending[0].delay == H.RETRY_SECONDS
|
||||
hub.timers.fire()
|
||||
second = FakeSession.instances[1]
|
||||
assert hub.session is second and states[-1] == ("live", None)
|
||||
# the new stream gets the subscriber's config plus the kept filter and pause
|
||||
assert second.calls == [("interval", 1.0), ("modules", frozenset({"procs"})), ("filter", "all"), ("pause", True)]
|
||||
assert hub.kill(5)["pid"] == 5 and hub.end_game("com.x.y")["package"] == "com.x.y"
|
||||
|
||||
|
||||
def test_retry_cancelled_when_nobody_is_left(hub):
|
||||
hub.subscribe("tab", "all", 1, lambda s: None)
|
||||
FakeSession.instances[0].end()
|
||||
hub.unsubscribe("tab")
|
||||
hub.timers.fire()
|
||||
assert len(FakeSession.instances) == 1 and hub.session is None
|
||||
|
||||
|
||||
def test_error_without_target_then_reconnect(hub):
|
||||
states = []
|
||||
hub.box["target"] = None
|
||||
hub.subscribe("tab", "all", 1, lambda s: None, lambda *a: states.append(a[1]))
|
||||
assert states == ["connecting", "error"] and hub.session is None and not hub.timers.pending
|
||||
with pytest.raises(M.MonitorUnavailable):
|
||||
hub.kill(1)
|
||||
hub.box["target"] = SimpleNamespace(frame="frame")
|
||||
hub.reconnect()
|
||||
assert hub.session is FakeSession.instances[0] and states[-1] == "live"
|
||||
|
||||
|
||||
def test_close_ends_everything(hub):
|
||||
hub.subscribe("tab", "all", 1, lambda s: None)
|
||||
s = FakeSession.instances[0]
|
||||
hub.close()
|
||||
assert s.closed and hub.session is None
|
||||
hub.subscribe("tab", "all", 1, lambda s: None)
|
||||
assert len(FakeSession.instances) == 1
|
||||
|
||||
|
||||
def test_session_set_modules_needs_a_new_agent():
|
||||
sent = []
|
||||
s = M.MonitorSession.__new__(M.MonitorSession)
|
||||
s.send = sent.append
|
||||
s.static = {"agent": 62}
|
||||
assert s.set_modules({"cpu"}) is False and sent == []
|
||||
s.static = {"agent": M.MIN_AGENT_MODULES}
|
||||
assert s.set_modules({"cpu", "battery"}) is True and sent == [{"modules": ["battery", "cpu"]}]
|
||||
assert s.set_modules("all") and sent[-1] == {"modules": "all"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ agent side
|
||||
def test_monitor_plan(fa): # noqa: F811
|
||||
every = set(fa.MON_MODULES) | {"scan", "gpu_ns"}
|
||||
assert fa.monitor_plan() == every and fa.monitor_plan("all") == every and fa.monitor_plan("cpu") == every
|
||||
assert fa.monitor_plan([]) == frozenset()
|
||||
assert fa.monitor_plan(["battery", "cpu", "bogus"]) == {"battery", "cpu"} # no process scan
|
||||
assert fa.monitor_plan(["games"]) == {"games", "scan", "gpu_ns"}
|
||||
assert fa.monitor_plan(["gpu"]) == {"gpu", "scan", "gpu_ns"} # GPU busy comes from the scanned render fds
|
||||
assert set(fa.MON_MODULES) == set(M.MODULES)
|
||||
|
||||
|
||||
def test_sample_collects_only_requested_modules(fa): # noqa: F811
|
||||
m, clock = monitor(fa)
|
||||
m.set_modules(["battery", "cpu"])
|
||||
scanned = []
|
||||
m.scan = lambda *a: scanned.append(a) # instance attribute, removed below (monkeypatch.undo would undo `fa`)
|
||||
s = m.sample(wall=2000.0)
|
||||
assert set(s) == {"t", "dt", "battery", "cpu", "self_ms"} and not scanned
|
||||
m.set_modules(["games"])
|
||||
del m.scan
|
||||
clock["t"] += 0.25
|
||||
s = m.sample(wall=2000.25)
|
||||
assert "games" in s and s["games"][0]["package"] == "com.example.game" and "procs" not in s and "cpu" not in s
|
||||
m.set_modules(["procs"]) # newly wanted: scanned at once, not 2 s later
|
||||
clock["t"] += 0.25
|
||||
assert "procs" in m.sample(wall=2000.5)
|
||||
m.set_modules("all")
|
||||
clock["t"] += 0.25
|
||||
full = m.sample(wall=2000.75)
|
||||
assert {"cpu", "mem", "psi", "temps", "power", "battery", "gpu", "games"} <= set(full)
|
||||
assert fa.Monitor(clock=lambda: 0.0).static()["modules"] == list(fa.MON_MODULES)
|
||||
|
||||
|
||||
def test_session_modules_message(fa): # noqa: F811
|
||||
class Mon:
|
||||
def __init__(self):
|
||||
self.modules = None
|
||||
|
||||
def static(self):
|
||||
return {"agent": fa.AGENT_VERSION}
|
||||
|
||||
def sample(self):
|
||||
return {"modules": self.modules}
|
||||
|
||||
def set_modules(self, modules):
|
||||
self.modules = modules
|
||||
|
||||
r, wfd = os.pipe()
|
||||
stdin, writer = os.fdopen(r), os.fdopen(wfd, "w")
|
||||
out = io.StringIO()
|
||||
mon = Mon()
|
||||
t = threading.Thread(target=fa.monitor_session, args=(stdin, out, mon), daemon=True)
|
||||
t.start()
|
||||
writer.write(json.dumps({"modules": ["battery"]}) + "\n")
|
||||
writer.flush()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and mon.modules != ["battery"]:
|
||||
time.sleep(0.02)
|
||||
writer.close()
|
||||
t.join(5)
|
||||
assert mon.modules == ["battery"] and not t.is_alive()
|
||||