Compare commits

..
18 Commits
Author SHA1 Message Date
spoopyghosty0 f3dee5425e Library: the On your Frame shelf stays in select mode (hiding it moved the grid under a drag-select) 2026-10-08 00:13:25 -04:00
spoopyghosty0 676b025c5b ui_smoke: steps wait for their screenshot and mouse actions (names always match what they show)
- driver/shooter hand-off: the driver runs a step, signals ready, then waits (240 s max) for the shooter's screenshot + mouse actions before the next step; fixed STEP_SECONDS and the compensating sleeps (library-typed/-cleared, --hover, --gestures, --links) removed
- new step library-shelf-hover (the shelf's first card hovered: its Play button); hover positions per step
- mouse actions that save their own picture (menus) skip the duplicate -mouse shot; the -mouse shot waits the paint delay (typed search showed half-faded results)
- --links keeps the 1440x900 Add games click; --gestures positions updated for the shelf, fake screenshots keep the same day layout at any hour
2026-10-08 00:10:09 -04:00
spoopyghosty0 fac72d17c8 Setup checklist with inline fixes, gentle motion (Reduce motion setting), Settings index
- C.checklist/C.Check replace status_row: Frame 'Ready to play' (Install Lepton, Install Proton, Test, Restart the Frame when kernel keys run high), Settings Tools (Install per missing tool) and This PC
- Views fade in (150 ms AnimatedSwitcher, same-route redraws swap in place); the wordmark portal pulses once on connect; Settings > Appearance 'Reduce motion' (ui.reduce_motion) turns both off
- Settings: fixed index column (Appearance first, Remove FramePort last) that scrolls to sections; ui_smoke step settings-index
- Tests: checklist items, section order, reduce-motion default
2026-10-07 23:49:39 -04:00
spoopyghosty0 a2fa6021f5 Library 'On your Frame' shelf, cover-tinted game pages, portal empty states
- Library: 'On your Frame' row above the grid (installed/outdated games on the connected Frame, most recently used first, max 8, wide covers, Update ready pill, Play on hover, click opens, right-click = the game menu); hidden while searching/filtering, in select mode or without a Frame; cards reused, updated with the grid
- Game page: cover tint (thumbs.compute_tint: Pillow quantize, most common colourful entry, clamped to lightness 0.35-0.55 / saturation <= 0.55, cached as t_tint_<sha8>.json next to the thumbnails, computed in prewarm or in the background) as a glow around/inside the hero and a faint wash at the top of the page
- C.empty_state: the transit portal + the context icon on a soft glow (C.portal_scene) for every empty state
- Tests: shelf selection, tint helpers, compute_tint cache; radius-token test ignores RadialGradient fractions
2026-10-07 23:35:49 -04:00
spoopyghosty0 c492e662cb Quiet rows (actions on hover, portal-fade selection), Live view and Type on Frame trimmed, transit upload progress monotonic
- C.quiet/C.reveal: row/tile actions + checkboxes at opacity 0 until hover, keyboard focus or selection (space kept)
- Files rows: checkbox shows on hover/focus/select mode, actions on hover/focus or when the row is selected; selected rows get C.selected_style
- Screenshots: checkbox on hover/focus or while anything is selected; selected tiles get a 2 px accent border + subtle fade; labelled "Select all"
- Library select mode: selected cards get the 2 px accent border + subtle fade (hover scale/shadow unchanged)
- selected_style(subtle=True) for overlays on pictures
- Live view: one help line + help icon, status/Quality/Start in one bar, portal + headset empty-state hint while not streaming
- Type on Frame: the repeated callout replaced by one line + help icon under the input
- Transit: Monotonic keeps the bar from moving back within a run (a new stage cleared the job's fraction); cover 1.4x larger and placed just before the portal; tests
2026-10-07 23:20:59 -04:00
spoopyghosty0 ce803340b0 Install progress as a transit: the game crosses the portal from the PC to the Frame (sidebar, game page, Activity)
- glyphs.transit_parts: the logo's monitor, portal and headset drawn whole (same paths/strokes), per theme colours, cached by TRANSIT_REV
- ui/transit.py: pure stage-name mapper (Analyze/Patch/Sign/Upload/Install/Test, overall fraction, waiting/failed, frame/pc) + tests
- components.Transit: created once, set() updates properties; cover rides into the portal at the progress head
- sidebar activity card (compact), game hero (updated from _on_job, no re-render), Activity running tile
- ui_smoke: a pretend running install (transit-game, transit-activity)
2026-10-07 23:08:58 -04:00
spoopyghosty0 4be34d20ca Live Frame card: the running game, its frame rate and the battery in the sidebar, from the shared monitor hub (Settings switch)
- app owns one MonitorHub; the Monitor tab subscribes (all modules) instead of owning a MonitorSession; the hub reconnects, _poll retries an error
- sidebar Frame card: battery ring (C.gauge), name + connection line; now-playing row (art, title, playing time, fps by level, 2-min sparkline) opens the Monitor
- card subscribes to games + battery every 5 s while connected, paused during uploads; setting ui.live_frame_card in Settings > Appearance
- ui/frame_card.py pure helpers + tests; ui_smoke injects FakeMonitorSession into the hub
2026-10-07 22:53:11 -04:00
spoopyghosty0 a4e8e20fcd Library: start typing anywhere to search
- Keys typed on the Library (no dialog open, search not focused) go into the search and focus it, cursor at the end; Backspace removes the last character; Esc in a non-empty search clears it
- Pure helpers library.typed_char / should_capture (tested); C.dialog_open reads Flet's page._dialogs (snack bars don't count)
- Search hint "Type to search games and tags"
- "Add a PC VR game folder…" is now "Add a PC game folder…" (it adds Windows games too)
- ui_smoke: library-typed step types "arc" after clicking the header
2026-10-07 22:41:34 -04:00
spoopyghosty0 fc1bee2673 Menus: grouped sections with headers, Select near the top, launch test under Troubleshoot, destructive last; compact rows
- ui/menus.py (no controls): menu_sections() builds a game's menu for the Library right-click and the game page's "…" (Header entries for Artwork/Recipe/Troubleshoot, destructive items last after a divider; the page leaves out what its hero and Where-it's-installed cards offer; Type on Frame left the game menu; "Run launch test on …"; Analyze again also on the page); process_menu() for Monitor (Copy first, End…/Force quit… with distinct icons, End game… last, disabled "Protected by SteamOS")
- C.menu_items: 32 px rows, small-caps headers (dropped when empty), ICON_S icons, disabled items without a handler; C.menu_button/C.check_item; menus get a BORDER_STRONG border + shadow (PopupMenuButton and theme popup_menu_theme for ContextMenus)
- Add games menu: new labels/icons, Rescan folders at the end (toolbar button kept); docs/help updated
- Icons whose ROUNDED variant is filled are OUTLINED again (Monitor nav, Sleep, file types, …); the consistency test checks an explicit line-style allow-list
- Library Tags chip: the tag icon no longer overlaps its label
- tests/test_menus.py; ui_smoke shoots the Add games and game page menus
2026-10-07 22:30:48 -04:00
spoopyghosty0 ad8847509b Shared components: one dialog, button, field, segmented, selection and spinner style everywhere (+ guard test)
- theme: RADIUS_XS, ICON_S/M/L, DIALOG_S/M/L, T_DISPLAY (scaled); tooltip radius token
- components: C.dialog/C.viewer (12 px corners, semibold title, size tokens), C.danger (+outline), C.primary_menu (Add games looks exactly like C.primary), C.field/C.search/C.dropdown, C.segmented, C.selected_style, C.spinner, C.hoverable; C.dot scales its size
- every AlertDialog, ProgressRing, TextField, Dropdown and the Monitor SegmentedButton migrated; Files locations, exe rows, theme cards and the sidebar use selected_style (dual: portal fade + white icon)
- ROUNDED icons everywhere (all OUTLINED had a twin), CLEAR -> CLOSE; raw radii/title sizes -> tokens; ink on clickable tiles/chips/switch labels
- tests/test_ui_consistency.py guards it (AST over ui/)
2026-10-07 22:08:36 -04:00
spoopyghosty0 627c05bf98 UI text: snooze for Later, Frame picker, clear labels, tr() everywhere, one vocabulary
- updates.snooze()/hidden(): the update banner's "Not now" hides a version for 24 h (update.snoozed); "Skip this version" unchanged
- Frame page: "Switch Frame…" opens a picker of remembered Frames (+ "Find another Frame…"); separate Disconnect button
- Labels: Update Steam art on Frame, Reset to suggested recipe… (asks first; pipeline.reset_recipe without the duplicated Rift branch), Uninstall from this PC…, Change program…, Share working recipe…, Force quit/End game…, ellipsis on everything that opens a dialog/picker or asks
- One Developer Mode path (Settings → System → Enable Developer Mode / Settings → Developer → Pair new host); linux_app help points to FEX
- tr() for the remaining visible strings (install status, job stages, update dialog, settings, files); this PC, recipe, program, for example, 50%, US spelling, Steam IDs, arm64
- Long callouts (live view, Type on Frame, pairing, library empty state) shortened, details in help texts
- tests/test_ui_text.py: untranslated literals, "..."/"e.g.", vocabulary, Switch Frame not wired to disconnect; snooze expiry test
2026-10-07 21:50:21 -04:00
spoopyghosty0 b82b714165 Monitor hub: one modular monitor stream for every view (agent v63: _monitor collects only requested modules)
- agent: {"modules": [...]} control message, monitor_plan(); default = all (older clients unchanged); GPU busy needs the process scan
- frame/monitor_hub.py (no Flet): subscribers with modules + interval, one session for the union and the fastest interval, fan-out, pause, reconnect, filter/pause/kill/end_game pass-throughs
- MonitorSession.set_modules (agent >= 63); tests/test_monitor_hub.py
2026-10-07 21:42:37 -04:00
spoopyghosty0 0bfdfbd0e8 Themes for the features merged from main: Monitor tab, Sparkline, Portal's own info colour
- Monitor: its colour maps (levels, process groups, CPU clusters, power split) refill on a theme change; restyle()
  stops and drops the Monitor view like the other kept views
- Sparkline's default colour is read when it's built (it was captured at import)
- Portal themes: INFO is a cyan of its own (#5CD0E6); it equalled PC's blue, so the Monitor showed Steam and SteamVR
  processes, and CPU and memory, in one colour
- Translation template regenerated after the rebase
2026-10-07 20:13:29 -04:00
spoopyghosty0 19addc1570 ui_smoke: a warm-up screen while the web fonts load (the first shot measured text with a fallback font: wrapped and overlapping labels) 2026-10-07 20:01:10 -04:00
spoopyghosty0 1f35c77a69 Selected sidebar tab: white icon on the portal fade in dual themes (an orange icon clashed with it) 2026-10-07 20:00:45 -04:00
spoopyghosty0 5973b6c85c Wordmark with a portal between Frame and Port; glyphs match Material icons in menus
- Dual themes: "Frame" (accent), a small tilted portal (rim from the accent into SECONDARY around a dark hole,
  generated in the active theme's colours, also for installed themes: glyphs.portal_mark), "Port" (SECONDARY)
- Glyphs: stroke 2 (was 1.75, lighter than Material's icons); menu items tint them TEXT like Material's own icons
- ui_smoke: waits until the web server listens (a fixed 6 s failed with a second smoke run on the machine)
2026-10-07 20:00:45 -04:00
spoopyghosty0 d74dc1aa61 Portal (OLED) and Original themes, installable theme files, both portals in the Portal theme
- Built-ins: Portal (default), Portal (OLED) (true black) and Original (the former violet; "classic" still maps to it)
- Portal now uses both of the logo's portals: orange stays the main action and selection; the new SECONDARY token
  (blue) colours outlined buttons, progress bars and switch tracks; "dual" themes add the blue-to-orange sidebar
  edge, the two-colour wordmark and the selected tab's fade. Original looks exactly as before
- Theme files: <data>/themes/*.json (name, base, dual, any subset of the colour tokens), checked on install (dark
  background, readable text, known colours; T.ThemeError says why). Settings -> Appearance: "Install theme file...",
  "Copy this theme as a file" (a template), a bin icon removes an installed theme
- docs/THEMES.md + docs/themes/example-theme.json (kept valid by a test); ui_smoke --themes includes installed themes
2026-10-07 20:00:44 -04:00
spoopyghosty0 7b14207f81 Portal theme, FramePort logo and glyphs, live theme switching in Settings
- Logo (a monitor that goes through a tilted portal and comes out as the Steam Frame) in the sidebar and welcome
  screen; src/assets/icon.png is the bundles' app icon (flet build; PyInstaller fallback via --icon)
- 24 px line glyphs (ui/icons/glyphs, ui/glyphs.py) replace Material icons for FramePort's own concepts: Frame,
  launch test, PC, live view, screenshots, Type on Frame, verified recipe, rebuild; shown as tinted images through
  C.as_icon and the button/menu helpers (same size as the Material icons next to them)
- Colour themes, dark only: "portal" (default; orange accent, blue PC side, status colours chosen so warnings never
  read as the accent) and "classic" (the previous violet). Settings -> Appearance theme cards switch live:
  app.restyle() rebuilds the shell and drops cached views (setting ui.theme)
- Theme colours are read when building: default arguments no longer capture tokens, module-level colour maps refill
  through T.on_change; tests/test_theme.py checks both
- ui_smoke.py --themes renders the picker and every theme after a live switch
2026-10-07 20:00:30 -04:00
111 changed files with 4990 additions and 4154 deletions

No files matched your search

+33 -20
View File
@@ -10,8 +10,20 @@ 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` (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
`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
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
@@ -53,6 +65,14 @@ 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
@@ -61,8 +81,18 @@ 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`/`status_row`/`kv`, tooltips via `C.tip()` (wraps). Game actions for the Library
`help=` argument of `section`/`kv` (`C.Check` help key), 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).
@@ -71,14 +101,6 @@ 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).
@@ -139,11 +161,6 @@ 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
@@ -159,10 +176,6 @@ 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).
+1 -1
View File
@@ -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 config…** (it fills in the
**Tested something? Share it.** In FramePort open the game → **…** → **Share working recipe…** (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.
+119 -756
View File
File diff suppressed because it is too large. Load diff
@@ -1,19 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,21 +0,0 @@
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
-18
View File
@@ -1,18 +0,0 @@
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
-10
View File
@@ -18,16 +18,6 @@ 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
-5
View File
@@ -35,11 +35,6 @@
## 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`.
+1 -1
View File
@@ -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 config…** opens a prefilled GitHub issue so your
Tried an untested game? Its page asks how it runs; **Share working recipe…** opens a prefilled GitHub issue so your
recipe can join the built-in catalog for everyone.
![Patches](images/patches.png)
+1 -1
View File
@@ -2,7 +2,7 @@
Two ways users feed results back, both without a GitHub token:
- **Share working config** (game menu, `frameport share-recipe <pkg>`): saves the recipe as a user catalog entry and
- **Share working recipe** (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.
+1 -1
View File
@@ -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 file… works with a lone APK too. Its data is found when the data folder sits next
- **A single game:** Add games → Add an APK… 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
-23
View File
@@ -28,23 +28,6 @@
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
@@ -137,12 +120,6 @@
- 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
View File
@@ -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 config can be shared from the app (**Share working config…**).
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…**).
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,7 +27,6 @@ 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 | |
@@ -51,11 +50,9 @@ 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 | |
@@ -65,7 +62,6 @@ 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 | |
@@ -75,7 +71,6 @@ 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. |
+6 -15
View File
@@ -85,11 +85,6 @@ 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
@@ -99,7 +94,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 (.exe)…** adds a single Windows program; the Frame runs it through Proton (as a
- **Add games → Add a Windows program…** 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`),
@@ -114,7 +109,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 → Install from a link…** takes the button's address (right-click → Copy link), a `framedrop://` or
- **Add games → Add 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
@@ -139,7 +134,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 and in a game's menu): while the tab is
- **Type on Frame** (its own tab in the sidebar; also on the Steam Frame page): 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.
@@ -190,7 +185,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 one game folder…**. FramePort finds the game's program and asks when there is more than one
**Add games → Add a PC 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.
@@ -201,7 +196,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 one game folder…** with the game's folder (pick the program that
**Windows games without VR:** **Add games → Add a PC 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).
@@ -222,9 +217,6 @@ 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)
@@ -249,7 +241,7 @@ Command line: `frameport frame send <files> --dest videos` (`frameport frame sto
## Sharing a working game, reporting a problem
- **Share working config…** (game menu): opens a prefilled GitHub issue with the game's patches and settings. Accepted
- **Share working recipe…** (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,
@@ -269,7 +261,6 @@ 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 |
+2 -9
View File
@@ -21,10 +21,7 @@ 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) |
@@ -45,18 +42,14 @@ 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). 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`) |
| 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) |
| `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 |
@@ -95,7 +88,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 → Install 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 → Add 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) |
+61
View File
@@ -0,0 +1,61 @@
# 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).
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Ember & Ice",
"base": "portal",
"dual": true,
"colors": {
"ACCENT": "#FF5A36",
"ACCENT_SOFT": "#3A1710",
"SECONDARY": "#5CE1E6",
"SECONDARY_SOFT": "#0E3033",
"PC": "#5CE1E6",
"INFO": "#5CE1E6"
}
}
+1 -1
View File
@@ -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 config can be shared from the app (**Share working config…**).\n"
"suggests patches for them, and a working recipe can be shared from the app (**Share working recipe…**).\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"
+3
View File
@@ -69,6 +69,9 @@ 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
+153 -80
View File
@@ -70,7 +70,11 @@ 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"
@@ -152,40 +156,6 @@ 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",
@@ -283,12 +253,19 @@ class FakeMonitorSession:
"procs": procs, "filter": "game"}
def _run(self) -> None:
for self.t in range(100000):
if self.closed:
return
self.t = 0
while not self.closed and self.t < 100000:
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
@@ -304,6 +281,29 @@ 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,18 +349,25 @@ def add_fake_linux_apps() -> None:
pipeline.add_linux_app(app)
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
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, done_step: list):
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."""
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()
time.sleep(STEP_SECONDS)
if not shot_done.wait(STEP_TIMEOUT):
ERRORS.append(f"{name}: no screenshot within {STEP_TIMEOUT} s, stopping")
return
def find_button(root, text: str):
@@ -410,18 +417,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")
"right-clicks (menus) in Files, Screenshots and the Library; prints what got selected "
"(positions for --viewport 1440x900)")
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)
@@ -432,17 +439,20 @@ 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"))]
("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"))]
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)))]
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))))
# 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))]
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
if args.docs:
steps = [("library", lambda a: a.navigate(0))]
@@ -458,10 +468,33 @@ 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"))))
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
# 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
def drag(*points):
def run(page):
page.mouse.move(*points[0])
@@ -498,11 +531,12 @@ 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((680, 280), (950, 280), (950, 430)), # 3 shots
"screenshots-dragged": right_click(950, 430),
"screenshots": drag((400, 300), (680, 300), (950, 300)), # 3 shots (the first row)
"screenshots-dragged": right_click(950, 300),
"screenshots-menu": escape,
"library": drag((360, 300), (560, 300), (760, 300)), # 3 cards, select mode on
"library-dragged": drag((560, 300), (760, 300))} # from a selected card: deselects 2
# 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
if args.linux:
def linux_filter(a, value):
a.navigate(0)
@@ -514,9 +548,7 @@ 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-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)))]
("linux-menu", lambda a: (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
@@ -540,10 +572,7 @@ def main() -> int:
ImageDraw.Draw(im).ellipse((72, 72, 184, 184), fill=(255, 214, 102, 255))
im.save(icon)
def add_menu(a):
a.navigate(0)
time.sleep(2)
steps = [("library", add_menu),
steps = [("library", lambda a: a.navigate(0)),
("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")))]
@@ -554,7 +583,31 @@ 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)
mouse["library"] = add_menu_open # tall pages: run with e.g. --viewport 1280x3200
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)))
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]
@@ -615,7 +668,11 @@ 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]
ready, done = threading.Event(), []
# 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(), []
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
from frameport.frame import keyboard, monitor
@@ -630,40 +687,56 @@ def main() -> int:
try:
app = FramePortApp(page)
if args.fake_frame:
app.monitor_hub.session_factory = FakeMonitorSession # the shared stream (Monitor tab, Frame card)
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, done), daemon=True).start()
threading.Thread(target=driver, args=(app, steps, ready, shot_done, 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
time.sleep(6)
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"])
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 = 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)
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])
time.sleep(1.5)
page.screenshot(path=str(args.out / f"{shot:02d}-{name}.png"))
shot += 1
if name in mouse:
mouse[name](page)
time.sleep(2)
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
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())
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

+3 -66
View File
@@ -16,67 +16,12 @@ UNITY_GGM = "assets/bin/Data/globalgamemanagers"
IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
# 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)."""
def _read_manifest_info(path: Path) -> tuple[str, str, str, str | None]:
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(), 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}
return apk.package, apk.version_name or "", label, apk.get_main_activity()
def _unreal_version(z: zipfile.ZipFile, lib: str) -> str | None:
@@ -167,7 +112,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, apk_info = _read_manifest_info(path)
package, version, label, activity = _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")
@@ -214,7 +159,6 @@ 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,
@@ -272,13 +216,6 @@ 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),
},
)
-45
View File
@@ -15,9 +15,6 @@ 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"
@@ -217,48 +214,6 @@ 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}
+62
View File
@@ -110,3 +110,65 @@ 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 -15
View File
@@ -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, upstream
from .patches import base
from .patches.overport import OVERPORT_PATCHES
from .tools import overport as overport_tool
from .validate.static import check_apk
@@ -36,28 +36,25 @@ def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
return ids
def apk_patches(selection: dict) -> list[base.Patch]:
patches = [base.get(pid) for pid in selection if base.get(pid).stage == "apk"]
def apk_patches(recipe: Recipe) -> list[base.Patch]:
patches = [base.get(pid) for pid in recipe.patches 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], 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))."""
reporter: Reporter) -> tuple[list[str], list[dict]]:
applied, checks = [], []
with ApkWorkspace(apk_in) as ws:
selection = upstream.resolve(ws, recipe.patches, reporter)
for patch in apk_patches(selection.patches):
for patch in apk_patches(recipe):
reporter.check_cancel()
ctx = base.ApkContext(ws, analysis, selection.patches.get(patch.id) or {}, reporter, selection.patches)
ctx = base.ApkContext(ws, analysis, recipe.params(patch.id), reporter, recipe.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, selection.superseded
return applied, checks
def build(source: SourceGame, analysis: Analysis, recipe: Recipe, outdir: Path, reporter: Reporter,
@@ -76,7 +73,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, superseded = [], [], {}
all_checks, applied = [], []
try:
for variant, alt in variants:
input_apk = alt_copy if converted and alt and alt_copy.exists() else Path(source.apk)
@@ -91,9 +88,7 @@ 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, left_out = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
if variant == "primary":
superseded = left_out
applied, checks = apply_frame_fixes(patched, unsigned, analysis, recipe, reporter)
patched.unlink(missing_ok=True)
reporter.stage(f"sign ({variant})")
final = outdir / (f"{pkg}.apk" if not alt else f"{pkg}.alt-noforcequit.apk")
@@ -111,4 +106,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, "superseded": superseded})
if alt_apk else None})
+1 -47
View File
@@ -229,8 +229,6 @@ 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")
@@ -331,9 +329,6 @@ 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))
@@ -342,8 +337,6 @@ 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"):
@@ -421,20 +414,13 @@ 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)"),
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")):
"a test build signed with the game's key)")):
"""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))
@@ -567,38 +553,6 @@ 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 -1
View File
@@ -15,6 +15,8 @@ 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
@@ -101,6 +103,18 @@ 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]
@@ -219,7 +233,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
+327
View File
@@ -0,0 +1,327 @@
"""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)
-98
View File
@@ -1,98 +0,0 @@
"""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
+2 -8
View File
@@ -228,8 +228,6 @@ 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:
@@ -238,7 +236,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, dest=plan.dest)
prep = frame.agent("prepare_linux", package=plan.package, title=plan.title)
if plan.files:
manifest = {name: (plan.root / name).stat().st_size for name in plan.files}
else:
@@ -261,12 +259,8 @@ 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), dest=plan.dest,
desktop_entry=plan.desktop_entry, timeout=900)
executables=executables, tags=_tags(plan.package), 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
+154 -111
View File
@@ -19,11 +19,9 @@
" · 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": "",
@@ -54,7 +52,7 @@
"90 Hz": "",
"920 × 430": "",
"96 Hz": "",
"A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games.": "",
"A folder with Android games (APK + OBB, for example 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": "",
@@ -62,14 +60,15 @@
"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 config for this game is available (you changed this game's settings, so it wasn't applied by itself).": "",
"A newer known-good recipe 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 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 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 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": "",
@@ -78,33 +77,33 @@
"Activity": "",
"Add a Linux app folder…": "",
"Add a Linux app…": "",
"Add a PC game folder…": "",
"Add a tag": "",
"Add a Windows program (.exe)…": "",
"Add an APK file": "",
"Add a Windows program…": "",
"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": "",
@@ -125,8 +124,10 @@
"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 built for arm64 (aarch64). x86_64 builds can't run on the Frame. The app must bring the libraries SteamOS doesn't have.": "",
"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 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": "",
@@ -143,6 +144,7 @@
"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}": "",
@@ -166,39 +168,38 @@
"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 (e.g. 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 (for example 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": "",
"cancelled": "",
"Cancelled": "",
"canceled": "",
"Canceled": "",
"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 that the Frame and this computer are on the same network.": "",
"Check program": "",
"Check that the Frame and this PC are on the same network.": "",
"Checking for updates": "",
"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…": "",
@@ -213,6 +214,7 @@
"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": "",
@@ -222,7 +224,7 @@
"Collect logs: {title}": "",
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, e.g. Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
"Connect": "",
"Connect the Frame's USB-C port to this computer with a USB cable (a data cable, not a charge-only one).": "",
"Connect the Frame's USB-C port to this PC with a USB cable (a data cable, not a charge-only one).": "",
"Connect your Frame": "",
"Connect your Frame first": "",
"Connect your Steam Frame": "",
@@ -240,7 +242,7 @@
"Controllers": "",
"Converts Quest games to OpenXR": "",
"Copied": "",
"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.": "",
"Copied: save it as a .json file, change the colors and install it": "",
"Copy": "",
"Copy all": "",
"Copy failed; select the text instead": "",
@@ -251,9 +253,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}": "",
@@ -261,6 +263,7 @@
"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).": "",
@@ -293,15 +296,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 → 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.": "",
"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.": "",
"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": "",
@@ -309,12 +312,10 @@
"done": "",
"Done": "",
"Done: look for the cable": "",
"Download": "",
"Download all": "",
"Download all ({n})…": "",
"Download all…": "",
"Download and install": "",
"Download selected": "",
"Download to this PC": "",
"Download selected…": "",
"Download to this PC…": "",
"Download to which folder on this PC?": "",
"Download {name}": "",
@@ -334,6 +335,7 @@
"",
""
],
"Downloading tools": "",
"Downloading…": "",
"Downloads": "",
"Download…": "",
@@ -346,16 +348,17 @@
"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 (e.g. steamos@frame.local or its IP)": "",
"Enter the Frame's address (for example 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.": "",
@@ -365,7 +368,6 @@
"Every {n}": "",
"everything in ~ (not seen by games)": "",
"Everything is up to date": "",
"Executable": "",
"experimental": "",
"Experimental Vulkan mutable-format workaround.": "",
"Extra diagnostics (log)": "",
@@ -380,13 +382,15 @@
"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 (e.g. .pak, Binaries)": "",
"Filter files (for example .pak, Binaries)": "",
"Find another Frame…": "",
"Find artwork and store details": "",
"Find artwork…": "",
"Find automatically": "",
@@ -403,6 +407,7 @@
"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": "",
@@ -425,24 +430,28 @@
"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 kill": "",
"Force kill all": "",
"Force kill all {n}": "",
"Force kill all {n} {name}?": "",
"Force kill {name}?": "",
"Force quit": "",
"Force quit all": "",
"Force quit all {n} {name}?": "",
"Force quit all {n}…": "",
"Force quit {name}?": "",
"Force quit…": "",
"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.": "",
@@ -463,6 +472,7 @@
"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.": "",
@@ -470,6 +480,7 @@
"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}": "",
@@ -478,13 +489,12 @@
"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. ": "",
@@ -533,7 +543,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).": "",
@@ -544,21 +554,20 @@
"If you know the Frame's address.": "",
"Ignore focus dips up to": "",
"Image for the {label}": "",
"In the menu and on the desktop": "",
"In the headset, select a text field, then type here.": "",
"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 from a link…": "",
"Install it anyway to try it, e.g. with different patches.": "",
"Install it anyway to try it, for example 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": "",
@@ -566,6 +575,7 @@
"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}?": "",
@@ -574,14 +584,19 @@
"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)": "",
"Internal storage": "",
"Install…": "",
"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.": "",
@@ -589,14 +604,10 @@
"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": "",
@@ -615,11 +626,9 @@
"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. ": "",
@@ -666,9 +675,11 @@
"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 for drives…": "",
"Looking at the app": "",
"Looking at the program": "",
"Looking for Frames on your network…": "",
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this computer).": "",
"Looking for games": "",
"Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this PC).": "",
"Looking for your Frame…": "",
"Looking up the store description and screenshots…": "",
"Low": "",
@@ -700,12 +711,8 @@
"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.": "",
@@ -718,12 +725,11 @@
"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": "",
@@ -734,12 +740,13 @@
"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 make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
"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 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": "",
@@ -755,8 +762,6 @@
"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.": "",
@@ -771,11 +776,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": "",
@@ -787,32 +792,36 @@
"On Frame": "",
"On the Frame, open Steam's Settings (Steam button → Settings).": "",
"On the Frame, place Revive's runtime (FramePort's Revive, or the repack's bundled LibRevive) where the game's Oculus SDK looks for LibOVRRT so it can load a VR runtime (pure runtime substitution — what Revive's LoadLibrary hook does on Windows; that hook doesn't work under Proton on the Frame). It does NOT bypass the Oculus runtime signature check: a game that verifies the runtime's signature will still refuse it. Ignored on this PC (Revive's hook handles it there).": "",
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run (it reaches this computer over the USB cable):": "",
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run (it reaches this PC 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 VR game folder": "",
"One PC 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 → 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.": "",
"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": "",
"Other": "",
"other file": "",
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
@@ -829,6 +838,7 @@
"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": "",
@@ -865,11 +875,14 @@
"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)": "",
"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.": "",
"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.": "",
"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}": "",
@@ -879,9 +892,10 @@
"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)": "",
@@ -897,12 +911,15 @@
"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)": "",
@@ -910,31 +927,34 @@
"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 this PC": "",
"Remove from this PC's Steam library": "",
"Remove from 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.": "",
@@ -966,12 +986,16 @@
"",
""
],
"Reset": "",
"Reset to recommended": "",
"Reset to suggested recipe": "",
"Reset to suggested recipe…": "",
"Reset {title}'s 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.": "",
@@ -980,12 +1004,16 @@
"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": "",
@@ -1005,7 +1033,6 @@
"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.": "",
@@ -1014,7 +1041,6 @@
"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.": "",
@@ -1027,14 +1053,15 @@
"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 config · {title}": "",
"Share working config…": "",
"Share working recipe · {title}": "",
"Share working recipe…": "",
"Sharpness": "",
"Sharpness, refresh rate, controllers, menus and more for this game. Changes are used the next time it starts.": "",
"Show 360° backgrounds and videos": "",
@@ -1055,6 +1082,7 @@
"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}": "",
@@ -1063,11 +1091,12 @@
"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": "",
@@ -1076,7 +1105,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 → Install 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 → Add 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.": "",
@@ -1087,9 +1116,11 @@
"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": "",
@@ -1111,6 +1142,7 @@
"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).": "",
@@ -1131,6 +1163,7 @@
""
],
"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": "",
@@ -1141,6 +1174,7 @@
"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:": "",
@@ -1153,12 +1187,14 @@
"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. 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. Turning on Developer Mode closes the Frame's desktop; the setup finishes on its own and FramePort connects by itself. 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.": [
"",
@@ -1177,8 +1213,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.": "",
@@ -1186,6 +1222,7 @@
"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.": "",
@@ -1194,16 +1231,17 @@
"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.": "",
@@ -1212,19 +1250,18 @@
"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 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 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 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…": "",
@@ -1238,6 +1275,7 @@
"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": "",
@@ -1259,6 +1297,7 @@
"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)": "",
@@ -1266,8 +1305,9 @@
"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. ": "",
@@ -1282,17 +1322,18 @@
"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 configs from GitHub automatically (confirmed configs arrive without a FramePort update)": "",
"Update game recipes from GitHub automatically (confirmed recipes arrive without a FramePort update)": "",
"Update now": "",
"Update on Frame": "",
"Update on this PC": "",
@@ -1302,11 +1343,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 files": "",
"Upload": "",
"Upload files…": "",
"Upload folder": "",
"Upload folder…": "",
"Upload to {label}": "",
"Uploaded {n} file": [
@@ -1319,14 +1360,14 @@
"Use FramePort": "",
"Use FramePort for these links": "",
"Use tags to group and filter your library": "",
"Use the new config": "",
"Use the new recipe": "",
"Use the repack's launcher (bundled Revive)": "",
"Use this computer's keyboard on the Frame": "",
"Use this PC'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: 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 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 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.": "",
@@ -1361,16 +1402,15 @@
"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}?": "",
@@ -1382,6 +1422,7 @@
"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).": "",
@@ -1405,7 +1446,9 @@
"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": "",
@@ -1432,7 +1475,6 @@
"{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}.": "",
@@ -1464,11 +1506,11 @@
"",
""
],
"{n} game config was updated": [
"{n} game didn't work out": [
"",
""
],
"{n} game didn't work out": [
"{n} game recipe was updated": [
"",
""
],
@@ -1508,7 +1550,6 @@
"{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}": "",
@@ -1516,15 +1557,17 @@
"{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": "",
@@ -1,63 +0,0 @@
"""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 -40
View File
@@ -7,16 +7,12 @@ 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. 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).
Upstream: Android-XR-Bridge/OVRPort#2, ovrport/app#73; tracked in GitHub #73 (drop this patch once fixed upstream).
"""
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"
@@ -35,8 +31,9 @@ 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 _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)."""
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)."""
try:
dynsym = elf._elf(data).get_section_by_name(".dynsym")
except Exception: # noqa: BLE001
@@ -44,31 +41,9 @@ def _function_offset(data: bytes, size: int) -> int | 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
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:
vaddr = found[0]["st_value"]
off = elf.vaddr_to_offset(elf.load_segments(data), vaddr)
if off is None or off + 28 > len(data):
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]:
@@ -113,11 +88,3 @@ 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",
))
-152
View File
@@ -1,152 +0,0 @@
"""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
+3 -42
View File
@@ -9,7 +9,6 @@ PC (Revive + local Steam) or on the Frame (Proton + Revive).
"""
from __future__ import annotations
import dataclasses
import threading
import time
from pathlib import Path
@@ -22,7 +21,6 @@ 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
@@ -286,30 +284,6 @@ 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)."""
@@ -452,8 +426,7 @@ 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")),
desktop_entry=entry.get("desktop_entry", True) is not False)
reporter, x86_64=bool(extra.get("x86_64")))
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)")
@@ -513,12 +486,7 @@ def set_recipe(package: str, recipe: Recipe) -> None:
def reset_recipe(package: str) -> Recipe:
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
"""Back to the suggested recipe (catalog or heuristics) for the game's analysis (Quest and Rift alike)."""
entry = library.game(package)
recipe = engine.suggest(library.analysis_from_dict(entry["analysis"]))
set_recipe(package, recipe)
@@ -643,8 +611,7 @@ 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"]),
"superseded": res.meta.get("superseded") or {}}
"overport": res.meta.get("overport"), "recipe_fp": recipe_fingerprint(entry["recipe"])}
library.upsert_game(package, build=build_info, title=entry.get("title") or store_title)
return build_info
@@ -667,8 +634,6 @@ 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)
@@ -830,10 +795,6 @@ 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"
+1 -1
View File
@@ -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 config" publish)."""
"""A catalog recipe from a library entry (what "Save as known-good" and "Share working recipe" publish)."""
import time
from ..core import library
-34
View File
@@ -80,7 +80,6 @@ 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
@@ -178,39 +177,6 @@ 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
+8 -89
View File
@@ -3,10 +3,7 @@
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
@@ -42,91 +39,11 @@ 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 = find_data_dir(path.parent, pkg) if pkg else None
data = next((d for d in (path.parent / (pkg or ""), path.parent / "obb") if pkg and d.is_dir()), None)
return SourceGame(display_name(path.stem), path, data, path.parent)
if not path.is_dir():
return None
@@ -136,9 +53,12 @@ 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 = 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])
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])
def _no_quest_games_below(d: Path) -> bool:
@@ -157,8 +77,7 @@ def _other_folders(d: Path, game: SourceGame) -> list[Path]:
except OSError:
return []
own = {game.data_dir.resolve()} if game.data_dir else set()
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")]
return [p for p in subs if p.resolve() not in own and p.name.lower() not in ("obb", "android")]
def scan(root: Path, depth: int = 5) -> list[SourceGame]:
+1 -2
View File
@@ -33,8 +33,7 @@ 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,
desktop_entry=True) -> dict:
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False) -> 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")
+5 -25
View File
@@ -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 drives, installer
from ..install import installer
from ..validate import device
from .base import Target
@@ -19,12 +19,6 @@ 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():
@@ -40,7 +34,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, dest=self.install_dest())
plan = installer.InstallPlan(package, title, apk, data_dir, recipe, apk_only)
return installer.install(self.connect().frame, plan, reporter)
def add_to_library(self, packages, reporter):
@@ -66,27 +60,13 @@ class FrameLeptonTarget(Target):
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
dest=self.install_dest())
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"))
return installer.install_pcvr(self.connect().frame, plan, reporter)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
dest=self.install_dest(), desktop_entry=desktop_entry)
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)
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)
+483 -339
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -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.")
+607 -90
View File
@@ -3,48 +3,44 @@ 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 = {
"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),
}
STATUS_STYLE: dict[str, tuple] = {}
# per-game state on a target: label, icon, color
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),
}
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)
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)."""
@@ -79,8 +75,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 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
return G.PC if g.get("kind") == "rift" else \
ft.Icons.TERMINAL_ROUNDED if g.get("kind") == "linux" else G.FRAME
def missing_libraries(game: dict, frame_info: dict | None) -> list[str]:
@@ -103,9 +99,7 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
if frame_info is None:
return None
dep = _deployment(game, frame_info)
# 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")):
if not dep or dep.get("apk_present") is False: # no record, or the game files are gone (half uninstalled)
return "missing"
b = game.get("build") or {}
built = {b.get("sha256"), b.get("alt_sha256")} - {None}
@@ -180,6 +174,8 @@ 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)
@@ -191,25 +187,27 @@ 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)],
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)],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch")
def body(text: str, color: str = T.TEXT_2, size: int | None = None, **kw) -> ft.Text:
size = size or T.T_BODY
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
return ft.Text(text, size=size, color=color, **kw)
def meta(text: str, color: str = T.TEXT_3, **kw) -> ft.Text:
return ft.Text(text, size=T.T_META, 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)
# ------------------------------------------------------------------------------------------ chips / badges
def pill(text: str, color: str = T.TEXT_2, icon: str | None = None, solid: bool = False,
def pill(text: str, color: str | None = None, 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 = ([ft.Icon(icon, size=T.px(13), color=fg)] if icon else []) + \
items = ([as_icon(icon, T.px(13), 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),
@@ -228,6 +226,8 @@ 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,7 +260,67 @@ 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)
@@ -270,7 +330,8 @@ 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=icon, on_click=on_click, disabled=disabled, tooltip=tooltip,
return ft.FilledButton(label, icon=_btn_icon(icon, T.ON_ACCENT, disabled), 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,
@@ -282,30 +343,201 @@ 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:
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),
# 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),
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 = 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,
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,
ft.ControlState.DISABLED: T.TEXT_3}))
def icon_btn(icon: str, tooltip: str, on_click: Callable | None = None, disabled: bool = False,
color: str = T.TEXT_2) -> ft.IconButton:
return ft.IconButton(icon, tooltip=tooltip, on_click=on_click, disabled=disabled, icon_color=color,
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,
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 = T.SURFACE, **kw) -> ft.Container:
def card(content: ft.Control, padding: int | None = None, bgcolor: str | None = None, **kw) -> ft.Container:
padding = T.S4 if padding is None else padding
return ft.Container(content, padding=padding, bgcolor=bgcolor, border_radius=T.RADIUS,
return ft.Container(content, padding=padding, bgcolor=bgcolor or T.SURFACE, border_radius=T.RADIUS,
border=ft.Border.all(1, T.BORDER), **kw)
@@ -321,20 +553,33 @@ 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": ft.Icons.COMPUTER_ROUNDED}[kind]
"pc": G.PC}[kind]
content = body(text, T.TEXT) if isinstance(text, str) else text
return ft.Container(ft.Row([ft.Icon(icon, color=color, size=T.px(18)), ft.Container(content, expand=True)],
return ft.Container(ft.Row([as_icon(icon, T.px(18), color), 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([
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),
portal_scene(icon),
ft.Container(height=T.S2),
title(heading, 22),
title(heading, T.T_DISPLAY),
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),
@@ -348,20 +593,56 @@ def kv(label: str, value: str | ft.Control, help: str | None = None) -> ft.Row:
expand=True)], vertical_alignment=ft.CrossAxisAlignment.START)
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)))
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 art_fill(src: str | None, radius: int | None = None, placeholder_icon: str = ft.Icons.VIDEOGAME_ASSET_ROUNDED,
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,
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."""
@@ -369,12 +650,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 = 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))
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))
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.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
colors=[T.SECONDARY_SOFT, T.SURFACE, T.ACCENT_SOFT] if T.DUAL
else [T.ACCENT_SOFT, T.SURFACE, T.BG]), **kw)
def bottom_fade(height: int | None = None, strength: float = 0.85) -> ft.Container:
@@ -383,40 +664,130 @@ 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.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))]))
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"))
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 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 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())
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])):
continue
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:
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:
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."""
@@ -506,10 +877,11 @@ 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 = T.ACCENT, height: int = 36, slots: int = 120, lo: float = 0.0,
def __init__(self, color: str | None = None, 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)
@@ -565,6 +937,151 @@ 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."""
+56
View File
@@ -0,0 +1,56 @@
"""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")
+120
View File
@@ -0,0 +1,120 @@
"""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"
+26 -25
View File
@@ -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 executable) to pick it.",
"game (or right-click → Change program…) 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 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_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_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,8 +39,9 @@ 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 built for arm64 (aarch64). x86_64 "
"builds can't run on the Frame. 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, 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.",
"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 "
@@ -69,7 +70,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 → Install from a link….",
"link into Add games → Add 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.",
@@ -96,23 +97,27 @@ 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 → 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.",
"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.",
"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. "
"No password needed.",
"Turning on Developer Mode closes the Frame's desktop; the setup finishes on its own and "
"FramePort connects by itself. 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 "
@@ -125,21 +130,17 @@ 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 (e.g. 4XVR's Internal "
"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.",
"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 "
@@ -160,9 +161,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.",
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 264 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 369 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 297 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 342 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 339 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 261 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 288 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 326 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 361 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 321 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 286 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.9 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.0 KiB

+125
View File
@@ -0,0 +1,125 @@
"""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
+217 -28
View File
@@ -1,40 +1,223 @@
"""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."""
"""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)."""
from __future__ import annotations
import json
import re
import shutil
from collections.abc import Callable
from pathlib import Path
import flet as ft
# 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"
# 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")
# text
TEXT = "#ECEDF3"
TEXT_2 = "#A9ADBD" # secondary
TEXT_3 = "#737889" # meta / disabled
# "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]] = []
# 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"
# 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)
# spacing / shape / type (at 100 %; set_scale() multiplies them)
_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}
# 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}
S1, S2, S3, S4, S5, S6 = 4, 8, 12, 16, 24, 32
RADIUS = 12
RADIUS_SM = 8
T_TITLE, T_H2, T_BODY, T_META, T_SMALL = 28, 16, 13, 12, 11
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
SCALE = 1.0
@@ -91,7 +274,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=PC, on_secondary=ON_ACCENT, surface=SURFACE, on_surface=TEXT, on_surface_variant=TEXT_2,
secondary=SECONDARY, 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,
@@ -108,13 +291,19 @@ 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=6,
decoration=ft.BoxDecoration(bgcolor=SURFACE_3, border_radius=RADIUS_XS,
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
+115
View File
@@ -0,0 +1,115 @@
"""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)
+26 -24
View File
@@ -15,6 +15,7 @@ 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:
@@ -29,12 +30,17 @@ 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._version = C.meta("", color=T.ON_ACCENT)
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.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=False,
padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=bool(old and old.visible),
tooltip=tr("A new FramePort version is ready to install"), on_click=lambda e: self.show_dialog())
# ---------------------------------------------------------------- checking
@@ -93,13 +99,13 @@ class Updater:
return
page = self.app.page
kind = updates.install_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]
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]
notes = up.notes.strip() or tr("No release notes.")
def later(e):
def skip(e):
updates.skip(up.version)
self._set(None)
page.pop_dialog()
@@ -107,11 +113,9 @@ class Updater:
def go(e):
page.pop_dialog()
self.install()
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([
page.show_dialog(C.dialog(
tr("FramePort {version} is available").format(version=up.version),
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(),
@@ -119,8 +123,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),
width=T.px(560), height=T.px(min(420, 130 + 26 * len(notes.splitlines())))),
actions=[C.ghost(tr("Skip this version"), on_click=later),
height=T.px(min(420, 130 + 26 * len(notes.splitlines()))),
actions=[C.ghost(tr("Skip this version"), on_click=skip),
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)]))
@@ -147,11 +151,9 @@ class Updater:
page.pop_dialog()
self.found = up
self.install()
page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Install dev build {version}?").format(version=up.version), color=T.TEXT,
weight=ft.FontWeight.W_600),
bgcolor=T.SURFACE_2,
content=ft.Container(ft.Column([
page.show_dialog(C.dialog(
tr("Install dev build {version}?").format(version=up.version),
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"),
@@ -161,10 +163,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),
width=T.px(560), height=T.px(min(440, 190 + 26 * len(notes.splitlines())))),
height=T.px(min(440, 190 + 26 * len(notes.splitlines()))),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
C.primary(tr("Install dev build"), ft.Icons.SCIENCE_OUTLINED, go)]))
C.primary(tr("Install dev build"), G.TEST, go)]))
# ---------------------------------------------------------------- installing
def install(self) -> None:
@@ -209,7 +211,7 @@ class Updater:
raise RuntimeError(tr("{value} failed ({returncode})")
.format(value=cmd[0], returncode=out.returncode))
self.restart = self._restart_process
rep.stage("Restarting")
rep.stage(tr("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)
@@ -268,8 +270,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("Later"), on_click=lambda e: (updates.skip(up.version), app.updater._set(None))),
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))),
], spacing=T.S3), "info", ft.Icons.SYSTEM_UPDATE_ROUNDED)
+37 -30
View File
@@ -15,13 +15,22 @@ from ..jobs import Job
if TYPE_CHECKING:
from ..app import FramePortApp
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"))}
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)
def _dur(job: Job) -> str:
@@ -31,18 +40,14 @@ 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 -> (progress bar, message, stage text, log text or None)
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
# 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
@@ -95,11 +100,9 @@ class ActivityPanel:
self._tiles[j.id] = cached
rebuilt = True
elif running and j.id in self._live:
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]
transit, log = self._live[j.id]
transit.set(j, **self.app.transit_info(j))
live.append(transit.control)
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)
@@ -119,6 +122,8 @@ 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())
@@ -161,11 +166,10 @@ 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.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)
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)
head = ft.Row([
ft.ProgressRing(width=T.px(18), height=T.px(18), stroke_width=T.px(2), color=T.ACCENT) if running
C.spinner() 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],
@@ -174,11 +178,13 @@ class ActivityPanel:
if job.active else []),
], vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S3)
parts: list[ft.Control] = [head]
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 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 expanded:
if job.stages:
parts.append(ft.Column([
@@ -202,21 +208,22 @@ 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_ROUNDED,
*([C.ghost(tr("Full launch log"), ft.Icons.DESCRIPTION_OUTLINED,
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][:3], log)
self._live[job.id] = (self._live[job.id][0], 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))
on_click=None if running else lambda e: self._toggle(self.expanded, job.id),
ink=not running)
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
+5 -9
View File
@@ -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 ft.Dropdown(value=str(v), width=T.px(220), dense=True, border_color=T.BORDER,
return C.dropdown(value=str(v), width=T.px(220),
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,12 +201,8 @@ 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(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))
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)))
apply_visibility()
+20 -24
View File
@@ -19,9 +19,7 @@ if TYPE_CHECKING:
def show_art_dialog(app: FramePortApp, package: str) -> None:
g = library.game(package)
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)
term = C.search(value=g.get("title") or package, expand=True)
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("")
@@ -43,7 +41,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 on Frame"), on_action=lambda e: app.update_steam_art(package))
action=tr("Update Steam art 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)
@@ -56,7 +54,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.px(8), height=T.px(120)), height=T.px(120)),
ft.Container(C.art_fill(r["preview"], radius=T.RADIUS_SM, 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,
@@ -80,20 +78,19 @@ 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 on Frame"), on_action=lambda e: app.update_steam_art(package))
action=tr("Update Steam art 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(ft.AlertDialog(
title=ft.Text(tr("Artwork for {get}").format(get=g.get('title')), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([
app.page.show_dialog(C.dialog(
tr("Artwork for {get}").format(get=g.get('title')),
ft.Column([
ft.Row([term, C.secondary(tr("Search"), ft.Icons.SEARCH_ROUNDED, search)], spacing=T.S2),
status, results,
], 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,
], spacing=T.S3, tight=True),
actions=[C.ghost(tr("Use your own images…"), ft.Icons.UPLOAD_FILE_OUTLINED,
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())]))
@@ -125,7 +122,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 on Frame"), on_action=lambda e: app.update_steam_art(package))
action=tr("Update Steam art on Frame"), on_action=lambda e: app.update_steam_art(package))
else:
app.toast(message)
@@ -155,16 +152,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.px(8), bgcolor=T.SURFACE_3, alignment=ft.Alignment.CENTER)
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3, alignment=ft.Alignment.CENTER)
else:
box = (C.art_fill(art, radius=T.px(8), height=T.px(110)) if art else
box = (C.art_fill(art, radius=T.RADIUS_SM, height=T.px(110)) if art else
ft.Container(C.meta(tr("None")), height=T.px(110), alignment=ft.Alignment.CENTER,
border_radius=T.px(8), bgcolor=T.SURFACE_3))
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_ROUNDED,
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_3))
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_OUTLINED,
lambda e, k=kind, lb=label: app.page.run_task(choose, k, lb))]
if art:
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)))
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))
return ft.Container(ft.Column([
box,
C.body(label, T.TEXT, size=T.T_META, weight=ft.FontWeight.W_600),
@@ -178,14 +175,13 @@ 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(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([
app.page.show_dialog(C.dialog(
tr("Your own artwork for {title}").format(title=g.get("title")),
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), width=T.px(660)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
], spacing=T.S3, tight=True),
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())]))
+20 -9
View File
@@ -42,6 +42,18 @@ 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
@@ -50,7 +62,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("/")
group.content.controls.append(ft.Container(ft.Row([
rows[c["path"]] = row = C.hoverable(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))],
@@ -59,8 +71,9 @@ 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 if c["path"] != current else T.ACCENT_SOFT,
on_click=lambda e, p=c["path"]: (setattr(group, "value", p), group.update())))
border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, on_click=lambda e, p=c["path"]: choose(p)))
group.content.controls.append(row)
paint()
def use(e):
choice = group.value
@@ -88,12 +101,10 @@ 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(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),
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)),
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))]))
+73 -32
View File
@@ -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_OUTLINED,
SHARED_ICONS = {"videos": ft.Icons.MOVIE_OUTLINED, "downloads": ft.Icons.DOWNLOAD_ROUNDED,
"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_ROUNDED
return ft.Icons.FOLDER_OUTLINED
ext = Path(name).suffix.lower()
if ext in VIDEO_EXT:
return ft.Icons.MOVIE_OUTLINED
@@ -84,21 +84,24 @@ 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_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.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.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)
@@ -175,12 +178,14 @@ 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"]
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),
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),
padding=ft.Padding(T.S2, T.px(6), T.S2, T.px(6)), border_radius=T.RADIUS_SM, ink=True,
bgcolor=T.ACCENT_SOFT if selected else None, on_click=lambda e, loc_=loc: self.open_location(loc_))
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
def _render_locations(self) -> None:
rows = [self._location_row(loc_, SHARED_ICONS.get(loc_["id"], ft.Icons.FOLDER_OUTLINED), loc_["android"])
@@ -279,16 +284,15 @@ 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(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)))
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))
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.checks, self.rows, self.hot = {}, {}, set()
if self.path != loc["path"]:
rows.append(self._row(None))
rows += [self._row(e) for e in self.entries]
@@ -311,27 +315,34 @@ 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 = (("folder" if e.is_dir else human(e.size)) + (tr(" · link") if e.link else "")
info = ((tr("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_ROUNDED, tr("Rename"),
actions += [C.icon_btn(ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, 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([check,
ft.Row([quiet_check,
ft.Icon(file_icon(e.name, e.is_dir), size=T.px(20), color=T.ACCENT if e.is_dir else T.TEXT_2),
ft.Column([C.body(e.name, T.TEXT, weight=ft.FontWeight.W_500, max_lines=1,
overflow=ft.TextOverflow.ELLIPSIS), C.meta(info)], spacing=0, expand=True),
*actions], spacing=T.S3),
quiet_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.drag.hover(p, ev.data in (True, "true")))
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)
return ft.GestureDetector(content=row, on_secondary_tap_down=lambda ev, x=e: self.open_menu(x,
ev.global_position))
@@ -340,8 +351,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_ROUNDED, self.upload_files),
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_ROUNDED, self.upload_folder),
return [(tr("Upload files…"), ft.Icons.UPLOAD_FILE_OUTLINED, self.upload_files),
(tr("Upload folder…"), ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, 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())]
@@ -360,7 +371,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_ROUNDED, lambda ev: self.rename(e)))
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, 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))))
@@ -408,8 +419,35 @@ 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()
@@ -435,6 +473,11 @@ 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):
@@ -509,7 +552,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 "file"
what = tr("folder and everything in it") if any(x.is_dir for x in items) else tr("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),
@@ -531,14 +574,12 @@ class FilesView:
def _ask_name(self, heading: str, label: str, value: str, ok: str, on_ok) -> None:
page = self.app.page
field = ft.TextField(label=label, value=value, autofocus=True, width=T.px(380))
field = C.field(label=label, value=value, autofocus=True, width=T.DIALOG_S)
def go(e=None):
page.pop_dialog()
if (field.value or "").strip():
on_ok(field.value.strip())
field.on_submit = go
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)]))
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"))
+7 -14
View File
@@ -96,14 +96,11 @@ 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 = ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT)
ring = C.spinner()
body = ft.Column([ft.Row([ring, status],
spacing=T.S2)], spacing=T.S3, expand=True)
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())])
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))
app.page.show_dialog(dialog)
def load():
@@ -113,19 +110,15 @@ 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 = 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)
search = C.search(hint_text=tr("Filter files (for example .pak, Binaries)"), expand=True)
def row(depth: int, node: Node | int, root_key: str) -> ft.Control:
pad = ft.Padding(8 + depth * 18, T.px(3), T.px(8), T.px(3))
pad = ft.Padding(T.px(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_ROUNDED) if node.is_dir \
icon = (ft.Icons.FOLDER_OPEN_ROUNDED if key in expanded else ft.Icons.FOLDER_OUTLINED) 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))
@@ -138,7 +131,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.px(6), ink=node.is_dir,
], spacing=T.px(6)), padding=pad, border_radius=T.RADIUS_XS, ink=node.is_dir,
on_click=(lambda e, k=key: toggle(k)) if node.is_dir else None)
def render():
+93 -127
View File
@@ -7,9 +7,9 @@ from typing import TYPE_CHECKING
import flet as ft
from ...core import library
from ...errors import explain
from ...i18n import tr, tr_n
from ...i18n import tr
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(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.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.Column([
ft.Row([C.title(info.get("hostname") or t.label, 22), C.pill(tr("Connected"), T.OK, ft.Icons.CIRCLE)],
spacing=T.S3),
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),
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,103 +43,78 @@ 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"), 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()),
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()),
], 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
rows = []
items = []
lepton = info.get("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"))
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)))
pr = info.get("proton") or {}
ready, sug = pr.get("ready"), pr.get("suggested")
if pr:
rows.append(C.status_row(
items.append(C.Check(
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")),
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"))
"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))
xr = (pr.get("openxr") or {}).get("name")
rows.append(C.status_row(bool(xr), tr("OpenXR runtime"), xr or tr("None found"), help="openxr"))
items.append(C.Check(bool(xr), tr("OpenXR runtime"), xr or tr("None found"), "openxr"))
keys = info.get("kernel_keys") or {}
if keys.get("max_keys"):
high = keys["keys"] > keys["max_keys"] * 0.75
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")
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)))
def installed(self, info: dict) -> ft.Control:
"""The list fills in the background (icon thumbnails may need creating the first time)."""
@@ -152,7 +127,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_ROUNDED,
action=ft.Row([send, C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED,
lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))], spacing=T.S2, tight=True)
if items else send)
@@ -164,7 +139,6 @@ 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
@@ -181,12 +155,10 @@ 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)) if size or not d.get("drive_missing") \
else ""
sub += C.drive_note(drive_of(d))
else tr(" · {value:.0f} MiB").format(value=size / 2**20))
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.px(8), width=T.px(44), height=T.px(44)),
C.art_fill(art, radius=T.RADIUS_SM, 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
@@ -197,7 +169,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(ft.Icons.SCIENCE_OUTLINED, C.tip(tr("Launch test. ") + HELP["launch_test"]),
C.icon_btn(G.TEST, 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")),
@@ -213,7 +185,7 @@ class FrameView:
app = self.app
found = ft.Column(spacing=T.S2)
searching = ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
searching = ft.Row([C.spinner(),
C.meta(tr("Looking for Frames on your network…"))], spacing=T.S2)
def discover():
@@ -224,20 +196,20 @@ class FrameView:
res = browse(4)
items = []
for f in res:
label = {"devkit": "SteamOS · Developer Mode", "frameport": "Set up for FramePort",
"saved": "Remembered", "scan": "SSH found by network scan"}.get(f.source, f.source)
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)
items.append(ft.Container(ft.Row([
ft.Icon(ft.Icons.VIEW_IN_AR_ROUNDED if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED,
color=T.ACCENT),
C.as_icon(G.FRAME if f.source != "scan" else ft.Icons.DEVICES_OTHER_ROUNDED, T.px(24), 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 [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 = 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.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
C.update(found)
@@ -267,7 +239,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 computer are on the same network.")
"Check that the Frame and this PC are on the same network.")
if app.route[0] == "frame" and app.pairing is server:
show_command()
app.run_bg(check_network)
@@ -286,12 +258,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 computer with a USB cable (a data cable, not a charge-only "
tr("Connect the Frame's USB-C port to this PC with a USB cable (a data cable, not a charge-only "
"one)."),
]
dlg = ft.AlertDialog(
title=ft.Text(tr("Turn on Developer Mode first"), weight=ft.FontWeight.W_600),
content=ft.Container(ft.Column([
dlg = C.dialog(
tr("Turn on Developer Mode first"),
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),
@@ -300,8 +272,7 @@ 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), width=T.px(480)),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS),
], spacing=T.S3, tight=True), size="s",
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)
@@ -313,11 +284,11 @@ class FrameView:
from ...frame import usb
from ...frame.connection import FrameTarget, server_key
status = ft.Row([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
status = ft.Row([C.spinner(),
C.meta(tr("Waiting for the cable…"))], spacing=T.S2)
pair_box.controls = [
C.body(tr("Looking for the Frame on a USB cable (Developer Mode on, USB-C port connected to this "
"computer)."), T.TEXT),
"PC)."), T.TEXT),
status,
]
C.update(pair_box)
@@ -356,7 +327,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 computer over the USB cable):") if over_usb else
"Konsole, and run (it reaches this PC 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),
@@ -364,12 +335,11 @@ 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([ft.ProgressRing(width=T.px(14), height=T.px(14), stroke_width=T.px(2), color=T.ACCENT),
ft.Row([C.spinner(),
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
spacing=T.S2),
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.")),
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)),
]
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
@@ -377,9 +347,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),
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.")),
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)),
], spacing=T.S2), "warn"))
if update:
C.update(pair_box) # (a redraw of the page may have replaced it)
@@ -387,12 +357,9 @@ class FrameView:
if app.pairing and app.pairing.running:
show_command(update=False)
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)
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"]))
saved = saved_targets()
offline = None
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
@@ -407,9 +374,8 @@ 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 '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),
connecting = tr("Connecting to {value}…").format(value=saved[0].label if saved else tr("your Frame"))
offline = C.callout(ft.Row([C.spinner(),
C.body(connecting, T.TEXT)],
spacing=T.S3), "info")
import time as _time
@@ -453,5 +419,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.storage(info), self.installed(info),
self.device_card(info), self.readiness(info), self.installed(info),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
+96 -125
View File
@@ -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,13 +93,15 @@ class GameView:
if self.rift and pkg in app.pc_installs():
chips.append(C.install_badge("on_pc"))
actions = self.actions()
return ft.Container(
self._wash = ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS) # cover tint (_tint)
self._hero = ft.Container(
ft.Stack([
C.art_fill(art, radius=T.px(16), hero=True, left=0, right=0, top=0, bottom=0,
C.art_fill(art, radius=T.RADIUS, 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.px(16), gradient=ft.LinearGradient(
ft.Container(left=0, right=0, top=0, bottom=0, border_radius=T.RADIUS, 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")),
@@ -113,24 +115,46 @@ 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.px(16), border=ft.Border.all(1, T.BORDER))
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)
def actions(self) -> ft.Control:
app, g, pkg = self.app, self.g, self.package
job = app.jobs.busy_with(pkg)
if job:
pct = (f" {job.fraction:.0%}" if job.fraction is not None else "") + \
(f" · {job.speed.split(' · ')[0]}" if job.speed else "")
# 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)
return ft.Row([
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)))),
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)),
C.ghost(tr("Cancel"), ft.Icons.CLOSE_ROUNDED, lambda e: app.jobs.cancel(job)),
], spacing=T.S2)
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.END)
app.hero_transit = None
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
@@ -141,8 +165,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 = 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)))
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"))
return ft.Row(buttons + [more], spacing=T.S2, wrap=True)
# ---------------------------------------------------------------- sections
@@ -157,10 +181,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": "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_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_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", {})
@@ -169,10 +193,6 @@ 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 "
@@ -183,15 +203,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(
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"]),
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"]),
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(
ft.Icons.COMPUTER_ROUNDED, tr("This PC (Steam + Revive)"),
G.PC, 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,
@@ -199,15 +219,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(ft.Icons.SCIENCE_OUTLINED, tr("Launch test on this PC"),
[C.icon_btn(G.TEST, tr("Launch test on this PC"),
lambda e: app.test_game(pkg, "pc")),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Remove from this PC's Steam library"),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Uninstall from this PC…"),
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(ft.Icon(icon, color=color, size=T.px(22)), width=T.px(44), height=T.px(44),
ft.Container(C.as_icon(icon, T.px(22), color), 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),
@@ -227,18 +247,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_ROUNDED))
ft.Icons.EXTENSION_OFF_OUTLINED))
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: 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"))
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"))
installed = C.install_state(g, self.app.frame_info) in ("installed", "outdated")
if should_ask_to_share(g, installed):
def dismiss(e):
@@ -262,44 +282,19 @@ class GameView:
from ... import pipeline
pipeline.apply_catalog_update(pkg)
self.app.toast(tr("New config applied: use Update on Frame to install it"))
self.app.toast(tr("New recipe applied: use Update on Frame to install it"))
self.app.render()
out.append(C.callout(ft.Row([
C.body(tr("A newer known-good config for this game is available (you changed this game's settings, "
C.body(tr("A newer known-good recipe 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 config"), ft.Icons.AUTO_FIX_HIGH_OUTLINED, take_update)],
C.secondary(tr("Use the new recipe"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, 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))
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":
if 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"))
@@ -312,8 +307,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='Rift' if other_rift else 'Quest'),
ft.Icons.COMPUTER_ROUNDED if other_rift else ft.Icons.VIEW_IN_AR_ROUNDED,
.format(value=tr("Rift") if other_rift else tr("Quest")),
G.PC if other_rift else G.FRAME,
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))
@@ -331,7 +326,7 @@ class GameView:
if "details" in self.g:
return None
return C.section(tr("About this game"), C.card(ft.Row([
ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT),
C.spinner(),
C.meta(tr("Looking up the store description and screenshots…"))], spacing=T.S2)))
parts: list[ft.Control] = []
if shots:
@@ -362,15 +357,14 @@ class GameView:
def more(e):
state["open"] = not state["open"]
body.value = text if state["open"] else short
e.control.text = tr("Show less") if state["open"] else tr("Show more")
e.control.content = tr("Show less") if state["open"] else tr("Show more")
body.update()
e.control.update()
parts.append(ft.TextButton(tr("Show more"), on_click=more, style=ft.ButtonStyle(color=T.ACCENT)))
parts.append(C.ghost(tr("Show more"), on_click=more, color=T.ACCENT))
links = det.store_links(d)
if links:
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))
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))
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)),
@@ -389,13 +383,12 @@ class GameView:
counter.value = f"{state['i'] + 1} / {len(shots)}"
img.update()
counter.update()
page.show_dialog(ft.AlertDialog(
content=ft.Container(ft.Column([img, ft.Row([
page.show_dialog(C.viewer(
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)),
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
spacing=T.S2, tight=True), width=T.px(1100))))
def tags(self) -> ft.Control:
from .library import all_tags, auto_tags, normalize_tag, user_tags
@@ -420,10 +413,8 @@ class GameView:
else:
e.control.update()
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)
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)
def fill():
mine = user_tags(self.g)
@@ -432,7 +423,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"))
on_click=lambda e, t=t: remove(t), tooltip=tr("Remove tag"), ink=True)
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)),
@@ -442,7 +433,8 @@ 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"))
on_click=lambda e, t=t: save(user_tags(self.g) + [t]), tooltip=tr("Add this tag"),
border_radius=T.px(20), ink=True)
for t in suggestions]
row.controls = chips
fill()
@@ -466,28 +458,12 @@ 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()
@@ -497,7 +473,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 = ft.Icons.VERIFIED_ROUNDED, T.OK
icon, color = G.RECIPE, T.OK
elif recipe.source == "user":
lead, icon, color = tr("Your custom recipe"), ft.Icons.TUNE_ROUNDED, T.ACCENT
else:
@@ -505,7 +481,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.px(6),
bgcolor=T.SURFACE_3, border_radius=T.RADIUS_XS,
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)
@@ -541,7 +517,7 @@ class GameView:
return C.section(
tr("What FramePort will do"),
C.card(ft.Column([
ft.Row([ft.Icon(icon, color=color, size=T.px(18)), C.body(lead, T.TEXT, weight=ft.FontWeight.W_500,
ft.Row([C.as_icon(icon, T.px(18), color), 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."))),
@@ -561,9 +537,6 @@ 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"
@@ -623,16 +596,12 @@ 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")), *fixed_note], spacing=T.px(2),
expand=True),
else tr("All at their defaults"))], 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)))
@@ -649,8 +618,6 @@ 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)
@@ -661,10 +628,9 @@ 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),
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)),
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)),
], 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
@@ -672,15 +638,13 @@ class GameView:
pass
elif cat == "adapter":
val = recipe.params(p.id).get("value", p.params[0].default)
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))
extra = C.field(value=str(val), width=T.px(90), on_blur=set_value(p.id, p.params[0].kind))
elif p.params:
q = p.params[0]
multi = q.kind == "text"
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))
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))
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"]))]
@@ -737,7 +701,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("Executable"), g.get("exe") or ""),
rows += [C.kv(tr("Folder"), g.get("game_dir") or ""), C.kv(tr("Program"), 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))]
@@ -761,6 +725,7 @@ 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)
@@ -771,7 +736,13 @@ 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, expand=True, on_scroll=remember, scroll_interval=100)
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)
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():
@@ -780,7 +751,7 @@ class GameView:
except Exception: # noqa: BLE001 - the page changed again meanwhile
pass
threading.Timer(0.08, restore).start()
return col
return ft.Stack([self._backdrop, col], expand=True)
def _known(pid: str) -> bool:
+9 -8
View File
@@ -14,6 +14,7 @@ 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:
@@ -39,18 +40,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 = 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.paste = C.field(hint_text=tr("Or paste text to type it in one go"), expand=True,
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 computer's keyboard on the Frame")
heading, sub = tr("Type on Frame"), tr("Use this PC'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(ft.Icons.KEYBOARD_ROUNDED, tr("Connect your Frame first"),
C.empty_state(G.KEYS, 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:
@@ -60,12 +61,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_ROUNDED, self._send_text)],
ft.Row([self.paste, C.secondary(tr("Type it"), ft.Icons.SEND_OUTLINED, 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
+223 -69
View File
@@ -13,6 +13,7 @@ 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
@@ -140,6 +141,57 @@ 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"}}
@@ -152,6 +204,7 @@ 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:
@@ -173,12 +226,22 @@ 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
@@ -186,12 +249,11 @@ 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)
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)
# 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))
# 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
@@ -208,33 +270,22 @@ 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.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.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.body = ft.Container(self.menu, expand=True)
add = ft.PopupMenuButton(
content=ft.Container(ft.Row([ft.Icon(ft.Icons.ADD_ROUNDED, color=T.ON_ACCENT, size=T.px(18)),
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="")
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)]))
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,
@@ -293,18 +344,17 @@ class LibraryView:
games = library.games()
self.games = games
pc = set(local_installs())
tw = twins(games)
tw = self.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_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))
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))
self.cards.clear()
C.update(self.root)
return
@@ -341,8 +391,11 @@ 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()
C.update(self.grid, self.sel_bar, self.select_btn)
self._update_shelf()
C.update(self.grid, self.sel_bar, self.select_btn, self.shelf)
def toggle_selected(self, pkg: str) -> None:
self.selected.symmetric_difference_update({pkg})
@@ -350,8 +403,19 @@ 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}
@@ -372,11 +436,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)), ft.Icons.COMPUTER_ROUNDED,
*([C.secondary(tr("Install {len} on this PC").format(len=len(rift)), G.PC,
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"),
ft.Icons.VIEW_IN_AR_ROUNDED,
G.FRAME,
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")),
@@ -421,8 +485,36 @@ 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.f["q"] = e.control.value
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
if self.search.suffix.visible != bool(self.f["q"]):
self.search.suffix.visible = bool(self.f["q"])
C.update(self.search)
@@ -446,8 +538,75 @@ 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)
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)))
# ---------------------------------------------------------------- header / filter bar
def _update_hint(self):
@@ -456,7 +615,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", ft.Icons.VIEW_IN_AR_ROUNDED)
"info", G.FRAME)
self.hint.visible = True
else:
self.hint.visible = False
@@ -503,28 +662,18 @@ class LibraryView:
C.update(self.subtitle, self.hint, self.filters)
def _seg(self, key: str, options: list[tuple[str, str]]) -> ft.Control:
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))
return C.segmented(options, self.f.get(key), lambda v: self._set(key, v))
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 ft.PopupMenuButton(
return C.menu_button(
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=[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="")
items=[C.check_item(text, self.f.get(key) == value, lambda e, v=value: self._set(key, v))
for value, text in options])
def _toggle_tag(self, tag: str):
tags = list(self.f.get("tags") or [])
@@ -534,13 +683,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 = [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)]
items = [C.check_item(t, t in chosen, lambda e, t=t: self._toggle_tag(t)) for t in all_tags(games)]
if chosen:
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),
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
C.meta(tr("Tags:")),
C.body(label if len(label) < 28
else tr("{len} selected").format(len=len(chosen)), T.TEXT,
@@ -549,7 +698,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, bgcolor=T.SURFACE_2, tooltip=C.tip(HELP["tags"]))
items=items, tooltip=C.tip(HELP["tags"]))
# ---------------------------------------------------------------- cards
def _key(self, g: dict, pc: set[str], tw: set[str]) -> tuple:
@@ -577,16 +726,17 @@ 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 exe"), T.WARN, ft.Icons.HELP_OUTLINE_ROUNDED, overlay=True,
badges.append(C.pill(tr("Check program"), 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.px(8), left=T.px(6), top=T.px(40),
bgcolor=T.soft("#000000", 0.6), border_radius=T.RADIUS_SM, 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),
@@ -608,6 +758,7 @@ 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([
@@ -628,13 +779,16 @@ class LibraryView:
on = e.data in (True, "true")
self.drag.hover(pkg, on)
tile.scale = 1.03 if on else 1.0
tile.border = ft.Border.all(1, T.ACCENT if on else T.BORDER)
self.marks[pkg] = (tile, overlay, on)
self._paint_card(pkg)
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,
+8 -8
View File
@@ -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 = ft.TextField(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
width=T.px(560), border_color=T.BORDER)
field = C.field(label=tr("Link"), hint_text="https://framedropvr.com/install?manifest=…", autofocus=True,
expand=True)
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(ft.AlertDialog(
title=ft.Text(tr("Install from a link")), bgcolor=T.SURFACE_2,
content=ft.Column([
app.page.show_dialog(C.dialog(
tr("Install from a link"),
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, width=T.px(600)),
], tight=True, spacing=T.S3),
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("Reading the install link")
job.reporter.stage(tr("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("Adding to the library")
rep.stage(tr("Adding to the library"))
g = pipeline.add_from_link(m, path, rep, icon=icon)
pkg = g["package"]
try:
+26 -17
View File
@@ -15,6 +15,7 @@ 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
@@ -69,13 +70,14 @@ 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 = ft.Dropdown(label=tr("Quality"), value=self.quality, width=T.px(260), dense=True,
self.quality_dd = C.dropdown(label=tr("Quality"), value=self.quality, width=T.px(230),
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, big=True)
self.start_btn = C.primary(tr("Start live view"), ft.Icons.PLAY_ARROW_ROUNDED, self._start)
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)
@@ -88,28 +90,34 @@ class LiveView:
if not (app.target and app.frame_state == "connected"):
return ft.Column([
app.top_bar(heading, sub),
C.empty_state(ft.Icons.CAST_ROUNDED, tr("Connect your Frame first"),
C.empty_state(G.LIVE, 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, 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),
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),
self.url,
], spacing=T.S2)),
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.idle,
], spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, expand=True)
self._refresh(update=False)
self._ensure_ticker()
return self.root
@@ -134,11 +142,12 @@ 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.quality_dd, self.url, self.idle)
def _ensure_ticker(self) -> None:
if self._ticker and self._ticker.is_alive():
+91 -122
View File
@@ -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). 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.
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.
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,11 +23,21 @@ 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 %)
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
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)
def label(name: str) -> str:
@@ -121,9 +131,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"))
@@ -131,10 +141,7 @@ class ProcRow:
class MonitorView:
def __init__(self, app: FramePortApp):
self.app = app
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.stopped = True # not subscribed to the app's monitor hub (the tab isn't shown)
self.root = None
self.history = M.History()
self.static: dict = {}
@@ -151,14 +158,13 @@ class MonitorView:
# ---------------------------------------------------------------- building
def _build(self) -> None:
self.live_dot = C.dot(T.TEXT_3, T.px(8))
self.live_dot = C.dot(T.TEXT_3, 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 = ft.Dropdown(value=f"{M.DEFAULT_INTERVAL:g}", dense=True, width=T.px(150),
border_color=T.BORDER, text_size=T.T_BODY,
self.interval = C.dropdown(value=f"{M.DEFAULT_INTERVAL:g}", width=T.px(150),
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"))
@@ -195,7 +201,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_ROUNDED, T.PC, hi=100, help_key="monitor_pressure")
self.t_mem = Tile(tr("Memory"), ft.Icons.STORAGE_OUTLINED, 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)
@@ -226,13 +232,10 @@ class MonitorView:
self.details_btn = C.ghost(tr("Show details"), ft.Icons.EXPAND_MORE_ROUNDED, lambda e: self.toggle_details())
# processes
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.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.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),
@@ -248,7 +251,8 @@ 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_ROUNDED)
"the filter to see everything on the Frame."),
icon=ft.Icons.SPORTS_ESPORTS_OUTLINED)
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],
@@ -288,74 +292,63 @@ 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
if self.session is not None or self._connecting:
hub = self.hub
if hub.subscribed("monitor"):
hub.reconnect() # the Reconnect button (after an error)
return
self._connecting = True
self._set_live(tr("Connecting…"), T.TEXT_3, update=self.root is not None)
gen, target = self._gen, self.app.target
self.app.run_bg(self._connect, gen, target)
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
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])
hub.set_filter(self.filter.data)
hub.subscribe("monitor", "all", float(self.interval.value), self._on_sample, self._on_state)
if self.paused:
session.pause(True)
self._set_live(tr("Live"), T.OK)
hub.pause("monitor", True)
def _on_end(self, gen: int, why: str) -> None:
if gen != self._gen:
def _on_state(self, name: str, state: str, detail) -> None:
"""The hub's stream state (its connect/reader/timer threads)."""
if self.stopped:
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()
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)
def stop(self) -> None:
"""End the stream (leaving the tab, disconnecting, closing the app)."""
self._gen += 1
"""Stop receiving (leaving the tab, disconnecting, closing the app); the hub ends the stream when nobody else
(the live Frame card) needs it."""
self.stopped = True
session, self.session = self.session, None
if session is not None:
session.close()
self.hub.unsubscribe("monitor")
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:
C.update(self.live, self.reconnect)
self._updater()(self.live, self.reconnect)
def _set_interval(self, e) -> None:
if self.session:
self.session.set_interval(float(self.interval.value))
self.hub.update("monitor", interval=float(self.interval.value))
def toggle_pause(self) -> None:
self.paused = not self.paused
if self.session:
self.session.pause(self.paused)
self.hub.pause("monitor", self.paused) # paused: no samples here (the card keeps its own)
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")
self._set_live(tr("Paused") if self.paused else tr("Live"), T.TEXT_3 if self.paused else T.OK, update=False)
if self.paused:
self._set_live(tr("Paused"), T.TEXT_3, update=False)
C.update(self.pause_btn, self.live)
def toggle_details(self) -> None:
@@ -366,10 +359,8 @@ 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, e) -> None:
which = (self.filter.selected or ["game"])[0]
if self.session:
self.session.set_filter(which)
def _set_filter(self, which: str) -> None:
self.hub.set_filter(which)
def set_sort(self, key: str) -> None:
if self.sort_key == key:
@@ -422,7 +413,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, T.px(8)), t], spacing=T.px(6), tight=True))
legend.append(ft.Row([C.dot(color, 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
@@ -437,8 +428,8 @@ class MonitorView:
return self.push
# ---------------------------------------------------------------- samples
def _on_sample(self, gen: int, s: dict) -> None:
if gen != self._gen or self.root is None:
def _on_sample(self, s: dict) -> None:
if self.stopped or self.root is None:
return
self.last_sample = s
for name, value in M.series_of(s).items():
@@ -448,7 +439,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])
@@ -470,7 +461,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)
@@ -509,7 +500,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.selected or ["game"])[0] == "game"
self.no_game.visible = game is None and self.filter.data == "game"
if game is None:
if self.game_pkg is not None:
self.game_pkg = None
@@ -555,7 +546,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")
@@ -620,35 +611,13 @@ 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")
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)
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__))
C.update(self.menu)
self.app.page.run_task(self.menu.open, global_position=position)
@@ -656,13 +625,13 @@ class MonitorView:
name, pid = p.get("name", "?"), p.get("pid")
if p.get("kind") == "group":
n = p["count"]
heading = (tr("Force kill all {n} {name}?") if sig == "KILL" else tr("End all {n} {name}?")).format(
heading = (tr("Force quit 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 kill all") if sig == "KILL" else tr("End all"),
C.confirm(self.app.page, heading, text, tr("Force quit all") if sig == "KILL" else tr("End all"),
lambda: self.app.run_bg(self._kill, p, sig), danger=True)
return
if p.get("critical"):
@@ -670,20 +639,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 kill {name}?").format(name=name)
heading = tr("Force quit {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 kill") if sig == "KILL" else tr("End process")
label = tr("Force quit") 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.session
if session is None:
session = self.hub
if session.session is None:
self.app.toast(tr("The monitor isn't connected."), error=True)
return
name = p.get("name", "?")
@@ -697,7 +666,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 kill"),
action=None if sig == "KILL" else tr("Force quit"),
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))
@@ -712,7 +681,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 kill"),
self.app.toast(tr("{name} is still running.").format(name=name), action=tr("Force quit"),
on_action=lambda e: self.app.run_bg(self._kill, p, "KILL"))
def ask_end_game(self, pkg: str | None = None) -> None:
@@ -726,8 +695,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.session
if session is None:
session = self.hub
if session.session is None:
self.app.toast(tr("The monitor isn't connected."), error=True)
return
self.end_btn.disabled = True
+52 -33
View File
@@ -17,6 +17,7 @@ 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
@@ -69,24 +70,27 @@ 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 = ft.Dropdown(label=tr("Game"), value=ALL, width=T.px(300), dense=True,
self.dropdown = C.dropdown(label=tr("Game"), value=ALL, width=T.px(300),
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,
tooltip=tr("Select all"), on_change=self._toggle_all)
label=tr("Select all"), label_style=ft.TextStyle(color=T.TEXT_2, size=T.T_BODY),
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)
@@ -110,7 +114,7 @@ class ScreenshotsView:
self._loaded = False # load again once connected
return ft.Column([
app.top_bar(heading, sub),
C.empty_state(ft.Icons.PHOTO_LIBRARY_OUTLINED, tr("Connect your Frame first"),
C.empty_state(G.SHOT, 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:
@@ -157,7 +161,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.checks, self.tiles, self.hot = {}, {}, set()
self.grid.controls = []
if not self.shots:
empty = C.empty_state(
@@ -224,14 +228,22 @@ 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),
caption, ft.Container(check, left=0, top=0)]),
overlay, caption, quiet_check]),
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.drag.hover(p, e.data in (True, "true")))
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"])
return ft.GestureDetector(content=tile, on_secondary_tap_down=lambda e, s=s: self.open_menu(s,
e.global_position))
@@ -292,46 +304,54 @@ 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:
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.selected = {s["path"] for s in self.shots} if e.control.value else set()
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]
@@ -377,16 +397,15 @@ class ScreenshotsView:
def delete(e):
page.pop_dialog()
self.delete([shots[state["i"]]])
page.show_dialog(ft.AlertDialog(
content=ft.Container(ft.Column([img, ft.Row([
page.show_dialog(C.viewer(
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)),
bgcolor=T.BG, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), content_padding=T.S3))
vertical_alignment=ft.CrossAxisAlignment.CENTER)], spacing=T.S2, tight=True), width=T.px(1100))))
show()
# ---------------------------------------------------------------- actions
+206 -62
View File
@@ -1,6 +1,7 @@
"""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
@@ -11,6 +12,7 @@ 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:
@@ -19,6 +21,20 @@ 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:
@@ -26,6 +42,8 @@ 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
@@ -41,37 +59,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.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
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)]
C.update(self.tools)
def fill_pc(self):
from ...core import winhost
if not winhost.available():
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)"))]
items = [C.Check("warn", 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()
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"),
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"),
]
except Exception as exc: # noqa: BLE001
self.pc.controls = [C.status_row(False, tr("Couldn't check this PC"), explain(exc))]
items = [C.Check(False, tr("Couldn't check this PC"), explain(exc))]
self.pc.controls = [C.checklist(items)]
C.update(self.pc)
def installing(self) -> ft.Control:
@@ -93,7 +111,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_ROUNDED,
return ft.Column([launch, keep, C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED,
clean, tooltip=tr("Your own game files aren't touched."))],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
@@ -106,7 +124,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 "never"
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else tr("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),
@@ -120,8 +138,8 @@ class SettingsView:
def auto_install(e):
library.set_setting("update.auto_install", bool(e.control.value))
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()]
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()]
return ft.Column([
ft.Row([C.kv(tr("Installed"), tr("FramePort {version} · {kind}").format(version=__version__, kind=kind)),
ft.Container(expand=True),
@@ -134,7 +152,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…"), ft.Icons.SCIENCE_OUTLINED,
C.ghost(tr("Install the latest dev build…"), G.TEST,
lambda e: app.updater.install_dev())],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
], spacing=T.S3)
@@ -149,7 +167,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 configs from GitHub: last checked {when}").format(when=when)
line = tr("Game recipes 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"]))
@@ -160,7 +178,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 configs from GitHub automatically (confirmed configs arrive without a "
C.switch(tr("Update game recipes from GitHub automatically (confirmed recipes arrive without a "
"FramePort update)"), value=bool(library.setting("catalog.auto_update", True)),
on_change=toggle),
], spacing=T.S2)
@@ -225,6 +243,95 @@ 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
@@ -242,15 +349,22 @@ class SettingsView:
else tr("Now {scale:.0%}.").format(scale=T.SCALE))
C.update(note)
dd = ft.Dropdown(label=tr("Text and layout size"), value=str(current) if current != "auto" else "auto",
dd = C.dropdown(label=tr("Text and layout size"), value=str(current) if current != "auto" else "auto",
options=options, width=T.px(260), on_select=changed)
controls = [dd, note]
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]
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 = ft.Dropdown(label=tr("Language"), value=i18n.language(), width=T.px(260), on_select=language_changed,
lang = C.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]
@@ -262,7 +376,7 @@ class SettingsView:
from ...frame.connection import bundled_agent_version
mine = bundled_agent_version()
text = f"v{mine}" if mine else "unknown"
text = f"v{mine}" if mine else tr("unknown")
info = self.app.frame_info or {}
remote = info.get("agent_version")
if self.app.frame_state == "connected" and remote:
@@ -272,29 +386,55 @@ 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 = [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)]
self.tools.controls = [C.checklist([C.Check(None, tr("Checking tools…"))])]
app.run_bg(self.fill_tools)
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)]
self.pc.controls = [C.checklist([C.Check(None, tr("Checking this PC…"))])]
app.run_bg(self.fill_pc)
from ... import __version__ as ver
data = str(user_data_dir())
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([
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([
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"),
@@ -303,7 +443,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)
C.section(tr("Problems and feedback"), C.card(ft.Column([
"feedback": 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,
@@ -311,23 +451,27 @@ 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"),
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([
"about": 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"), ft.TextButton(REPO_URL.removeprefix("https://"), icon=ft.Icons.OPEN_IN_NEW_ROUNDED,
url=REPO_URL)),
C.kv(tr("Source"), C.ghost(REPO_URL.removeprefix("https://"), ft.Icons.OPEN_IN_NEW_ROUNDED,
color=T.ACCENT, url=REPO_URL)),
C.meta(tr("Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. "
"Not affiliated with Valve or Meta.")),
], spacing=T.S2))),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
"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)
+5 -8
View File
@@ -32,8 +32,7 @@ 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 = ft.ProgressRing(width=T.px(20), height=T.px(20), stroke_width=T.px(2),
color=T.ACCENT) if state == "busy" else \
marker = C.spinner("m") 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([
@@ -63,9 +62,7 @@ class WelcomeView:
g_state = "done" if library.games() else "todo"
return ft.Column([
ft.Container(height=T.S5),
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.Row([C.logo(T.px(60)),
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),
@@ -79,9 +76,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, 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)],
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)],
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())
+21 -2
View File
@@ -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 library.setting("update.skipped") == up.version:
if not force and hidden(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 library.setting("update.skipped") == up.version:
if not up or not is_newer(up.version) or hidden(up.version):
return None
return up
@@ -165,9 +165,28 @@ 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:
-15
View File
@@ -119,21 +119,6 @@ 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})")
+1 -4
View File
@@ -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" | "int" | "ref".
"""elements: list of ("start", name, [(attr, kind, value)]) / ("end", name). kind: "str" | "bool".
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,9 +61,6 @@ 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)
Binary file not shown.
Binary file not shown.
-94
View File
@@ -1,94 +0,0 @@
// 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
-60
View File
@@ -76,63 +76,3 @@ 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
+2 -2
View File
@@ -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))
-416
View File
@@ -1,416 +0,0 @@
"""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"
+59
View File
@@ -0,0 +1,59 @@
"""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)
-19
View File
@@ -73,22 +73,3 @@ 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
+1 -1
View File
@@ -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", "status_row"}
"section", "Text", "TextButton", "empty_state", "confirm", "kv"}
@pytest.fixture(autouse=True)
-138
View File
@@ -1,138 +0,0 @@
"""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
+106
View File
@@ -0,0 +1,106 @@
"""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"
+39
View File
@@ -0,0 +1,39 @@
"""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")
+130
View File
@@ -0,0 +1,130 @@
"""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
+1 -1
View File
@@ -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():
+269
View File
@@ -0,0 +1,269 @@
"""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()
+1 -1
View File
@@ -489,7 +489,7 @@ def test_an_already_converted_apk_is_not_converted_again(tmp_path, monkeypatch):
used = []
monkeypatch.setattr(build.overport_tool, "patch", lambda *a, **k: (_ for _ in ()).throw(AssertionError("OVRPort")))
monkeypatch.setattr(build, "apply_frame_fixes",
lambda src, out, *a: (used.append(src.read_bytes()), out.write_bytes(b"u"), ([], [], {}))[2])
lambda src, out, *a: (used.append(src.read_bytes()), out.write_bytes(b"u"), ([], []))[2])
monkeypatch.setattr(build.sign, "sign", lambda src, dst, pkg: dst.write_bytes(b"signed"))
monkeypatch.setattr(build, "check_apk", lambda *a, **k: [])
analysis = _analysis(package="com.x", is_overport_output=True)
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more