Compare commits

..
5 Commits
17 changed files with 339 additions and 45 deletions

No files matched your search

+4
View File
@@ -69,6 +69,10 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
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 v73), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
Games in a sample (agent v81): FramePort's own (launch.sh / container / Proton folder), any other game Steam's
reaper runs (`steam:<appid>`, title from the appmanifest, e.g. Job Simulator from Steam's store; not SteamVR, not
devkit entries; End game = Steam's Exit game), overlay apps flagged `overlay` and listed last; newest first. The
UI takes `frame/monitor.main_game(sample)` (never an overlay app alone). The agent's own workers never count.
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
+52 -5
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 79
AGENT_VERSION = 81
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -6604,6 +6604,10 @@ MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor",
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
AGENT_FILES = {os.path.abspath(__file__), os.path.realpath(__file__)}
STEAM_GAME = "steam:" # monitor: a game Steam started that FramePort didn't install ("steam:<appid>", e.g. a Lepton
# game from Steam's store); its title comes from the appmanifest
REAPER_APPID = re.compile(r"\bAppId=(\d+)")
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
("steam", ("steam", "reaper", "fossilize")),
@@ -6935,6 +6939,7 @@ class Monitor:
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
self.deps = []
self.deps_at = -1e9
self.steam_titles = {}
self.tick = 0
self.last = None # time of the previous sample
self.disk_at = -1e9
@@ -7035,10 +7040,16 @@ class Monitor:
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
if m and m.group(1) in by_appid:
root = (by_appid[m.group(1)]["package"], "container")
else:
elif not any(a in cmd for a in AGENT_FILES):
# the agent's own workers name a game's launch.sh too (uninstall's shortcut removal while Steam
# restarts): they aren't the game (helpers launch.sh starts still count, as its children)
pkg = next((k for path, k in anchors if path in cmd), None) or \
next((k for base, k in bases if base in cmd), None)
root = (pkg, "launcher") if pkg else None
m = REAPER_APPID.search(cmd) if comm == "reaper" and not pkg else None
# any other game Steam runs (its reaper); not SteamVR itself, not a devkit entry of ours
if m and m.group(1) not in by_appid and m.group(1) != STEAMVR_APPID and "/devkit-game/" not in cmd:
root = (STEAM_GAME + m.group(1), "steam")
self.root_cache[key] = root
if self.root_cache[key]:
roots[pid] = self.root_cache[key]
@@ -7116,7 +7127,7 @@ class Monitor:
deps = {d["package"]: d for d in self.deployments(now)}
out = []
for pkg in sorted({v[0] for v in self.roots.values()}):
d = deps.get(pkg) or {}
d = deps.get(pkg) or (self.steam_game(pkg[len(STEAM_GAME):]) if pkg.startswith(STEAM_GAME) else {})
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
@@ -7136,10 +7147,25 @@ class Monitor:
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
"fps": fps, "frame_ms": ms, "processes": len(pids)})
"fps": fps, "frame_ms": ms, "processes": len(pids), "overlay": is_overlay(d)})
# the game in front first: overlay apps (they run next to games) last, then the most recently started
out.sort(key=lambda g: (g["overlay"], g["elapsed"] is None, g["elapsed"] or 0))
self.games = out
return out
def steam_game(self, appid):
"""{title, kind, appid} of a Steam game FramePort didn't install (cached: appmanifest/shortcuts reads)."""
if appid not in self.steam_titles:
app = find_app_id(appid)
name = (app or {}).get("name")
if not name:
for user in steam_users():
name = shortcut_names(user).get(appid)
if name:
break
self.steam_titles[appid] = name or f"App {appid}"
return {"title": self.steam_titles[appid], "kind": "steam", "appid": appid}
def filtered(self, now=None):
procs = self.procs
span = (now - self.scan_prev) if now is not None and self.scan_prev is not None else 0.0
@@ -7285,8 +7311,29 @@ class Monitor:
return {"ended": False, "pid": pid, "name": comm}
def steam_app_running(appid):
"""A Steam reaper runs this app id (a Steam game FramePort didn't install)."""
for name in os.listdir(PROC):
if name.isdigit() and _rd(f"{PROC}/{name}/comm").strip() == "reaper" and \
re.search(rf"\bAppId={appid}\b", _rd(f"{PROC}/{name}/cmdline").replace("\0", " ")):
return True
return False
def monitor_end_game(pkg, wait=6.0):
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs."""
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs.
A Steam game FramePort didn't install ("steam:<appid>"): Steam's Exit game only."""
if str(pkg).startswith(STEAM_GAME):
appid = str(pkg)[len(STEAM_GAME):]
if not appid.isdigit():
raise AgentError(f"bad Steam app id {appid!r}")
if not steam_app_running(appid):
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
steam_js(f"SteamClient.Apps.TerminateApp('{appid}', false)")
end = time.monotonic() + wait
while time.monotonic() < end and steam_app_running(appid):
time.sleep(0.5)
return {"ended": not steam_app_running(appid), "via": "steam", "package": pkg}
pkg = check_pkg(pkg)
dep = deployment(pkg)
if not dep:
+1
View File
@@ -149,6 +149,7 @@ def main() -> int:
("transit-activity", lambda a: a.show_activity(True))]
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
+7 -2
View File
@@ -79,7 +79,7 @@ const latest = latestCheck(games);
<script type="application/json" data-snapshot set:html={JSON.stringify(snapshot).replaceAll('<', '\\u003c')} />
<script>
import { counts, engines, latestCheck, rowsHtml, sortGames, type Snapshot } from '../scripts/catalog';
import { counts, engines, latestCheck, rowsHtml, shuffleGames, type Game, type Snapshot } from '../scripts/catalog';
import { patchHref } from '../scripts/patchlinks';
import { refreshCatalog } from '../scripts/catalog-live';
@@ -139,6 +139,11 @@ const latest = latestCheck(games);
apply();
});
search.addEventListener('input', apply);
// a random order on every visit (working games still first); the built page lists them alphabetically
const rank = new Map<string, number>();
const built = Object.values((JSON.parse(document.querySelector('[data-snapshot]')?.textContent || '{"files":{}}') as
Snapshot).files).map((f) => f.game).filter((g): g is Game => !!g);
if (built.length) list.innerHTML = rowsHtml(shuffleGames(built, rank), patchHref);
apply();
// bring the board up to date with the catalog on GitHub
@@ -152,7 +157,7 @@ const latest = latestCheck(games);
src.title = `The catalog on GitHub's main branch, as of ${when}`;
src.classList.add('live');
if (!live.changed) return;
const games = sortGames(live.games);
const games = shuffleGames(live.games, rank);
list.innerHTML = rowsHtml(games, patchHref);
const n = counts(games);
for (const s of ['works', 'issues', 'unsupported'] as const) set(`[data-n="${s}"]`, String(n[s]));
+111 -12
View File
@@ -1,14 +1,23 @@
{
"updated": "2026-10-11",
"updated": "2026-10-12",
"people": [
{
"login": "spoopyghosty0",
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
"url": "https://github.com/spoopyghosty0",
"commits": 523,
"issues": 4,
"commits": 542,
"issues": 5,
"prs": 3,
"total": 550
"total": 572
},
{
"login": "Klownicle",
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
"url": "https://github.com/Klownicle",
"commits": 0,
"issues": 20,
"prs": 0,
"total": 60
},
{
"login": "StevenPartridge",
@@ -29,13 +38,13 @@
"total": 45
},
{
"login": "Klownicle",
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
"url": "https://github.com/Klownicle",
"login": "Sheebalove",
"avatar": "https://avatars.githubusercontent.com/u/176067652?v=4",
"url": "https://github.com/Sheebalove",
"commits": 0,
"issues": 13,
"issues": 11,
"prs": 0,
"total": 39
"total": 33
},
{
"login": "inseend",
@@ -100,6 +109,15 @@
"prs": 0,
"total": 15
},
{
"login": "Scoota23",
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
"url": "https://github.com/Scoota23",
"commits": 0,
"issues": 5,
"prs": 0,
"total": 15
},
{
"login": "eepykith",
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
@@ -226,6 +244,15 @@
"prs": 0,
"total": 6
},
{
"login": "ganona",
"avatar": "https://avatars.githubusercontent.com/u/112239200?v=4",
"url": "https://github.com/ganona",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "GreySyntax",
"avatar": "https://avatars.githubusercontent.com/u/242626?v=4",
@@ -244,6 +271,15 @@
"prs": 0,
"total": 6
},
{
"login": "Minibattle",
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
"url": "https://github.com/Minibattle",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
@@ -289,6 +325,15 @@
"prs": 0,
"total": 3
},
{
"login": "Aryetis",
"avatar": "https://avatars.githubusercontent.com/u/4246001?v=4",
"url": "https://github.com/Aryetis",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "bcain2005-gif",
"avatar": "https://avatars.githubusercontent.com/u/258048498?v=4",
@@ -316,6 +361,15 @@
"prs": 0,
"total": 3
},
{
"login": "DolphinOfficial",
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
"url": "https://github.com/DolphinOfficial",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "dontstealthisplease-gif",
"avatar": "https://avatars.githubusercontent.com/u/337959488?v=4",
@@ -343,6 +397,15 @@
"prs": 0,
"total": 3
},
{
"login": "felis-lynx",
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
"url": "https://github.com/felis-lynx",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Froghut",
"avatar": "https://avatars.githubusercontent.com/u/1702171?v=4",
@@ -370,6 +433,15 @@
"prs": 0,
"total": 3
},
{
"login": "hokey1981",
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
"url": "https://github.com/hokey1981",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "InsideCat02",
"avatar": "https://avatars.githubusercontent.com/u/111202797?v=4",
@@ -398,9 +470,18 @@
"total": 3
},
{
"login": "Minibattle",
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
"url": "https://github.com/Minibattle",
"login": "Mitchlett",
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
"url": "https://github.com/Mitchlett",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Momofier",
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
"url": "https://github.com/Momofier",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -415,6 +496,15 @@
"prs": 0,
"total": 3
},
{
"login": "Obliviate28",
"avatar": "https://avatars.githubusercontent.com/u/341108069?v=4",
"url": "https://github.com/Obliviate28",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Puchor",
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
@@ -433,6 +523,15 @@
"prs": 0,
"total": 3
},
{
"login": "ssgod101",
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
"url": "https://github.com/ssgod101",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
+25 -1
View File
@@ -1,6 +1,30 @@
{
"updated": "2026-10-11",
"updated": "2026-10-12",
"releases": [
{
"tag": "v1.0.4",
"name": "FramePort v1.0.4",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.4",
"dev": false,
"notes": "FramePort v1.0.4: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Launch tests no longer report \"Failed to launch\"** for games that run fine but crash once the test ends their VR session because no headset is worn (e.g. BlazeRush).\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.3",
"name": "FramePort v1.0.3",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.3",
"dev": false,
"notes": "FramePort v1.0.3: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **One FramePort at a time:** starting it again brings the open window to the front, and FramePort fully closes with its window.\n- **After an update, FramePort restarts with a working window** instead of a black one (Windows).\n- The update dialogs' \"All releases\" and \"Release page\" buttons work again.\n- When several play sessions are checked at once, one message lists them instead of one per game.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.2",
"name": "FramePort v1.0.2",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.2",
"dev": false,
"notes": "FramePort v1.0.2: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Progress is kept:** games that save under your Meta user (for example Walkabout Mini Golf) no longer start with an empty profile each time. Click **Update on Frame** on their page.\n- A new troubleshooting FAQ in the docs: where a game's settings are and what to try when a game misbehaves.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.1",
"name": "FramePort v1.0.1",
+16 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { rowHtml, short, sortGames, toGame } from './catalog.ts';
import { rowHtml, short, shuffleGames, sortGames, toGame } from './catalog.ts';
test('catalog entries become rows like docs/GAMES.md', () => {
assert.equal(toGame({ title: 'x', status: 'maybe' }), null);
@@ -26,6 +26,21 @@ test('order: works, issues, unsupported, then title', () => {
assert.deepEqual(sortGames(games).map((g) => g.title), ['a', 'z', 'a', 'b']);
});
test('shuffle: status groups stay in order, a random order inside, kept across calls', () => {
const games = ['unsupported:b', 'works:z', 'issues:a', 'works:a', 'works:m', 'issues:q'].map((s) => {
const [status, title] = s.split(':');
return toGame({ status, title, package: `p.${status}.${title}` });
});
const seq = [0.9, 0.1, 0.5, 0.3, 0.7, 0.2];
const rank = new Map();
const first = shuffleGames(games, rank, () => seq.shift());
assert.deepEqual(first.map((g) => g.status), ['works', 'works', 'works', 'issues', 'issues', 'unsupported']);
assert.deepEqual(first.filter((g) => g.status === 'works').map((g) => g.title), ['z', 'a', 'm']);
// the same page reorders the same way (the live refresh), new games get their own place
const again = shuffleGames([...games].reverse(), rank, () => 0.0);
assert.deepEqual(again.map((g) => g.title), first.map((g) => g.title));
});
test('rows escape what comes from GitHub', () => {
const html = rowHtml(toGame({ title: '<img src=x onerror=alert(1)>', status: 'works', engine: '"x' }));
assert.ok(!html.includes('<img'));
+8
View File
@@ -69,6 +69,14 @@ export function sortGames(games: Game[]): Game[] {
|| a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }));
}
/** Working games first, then with issues, then not running; a random order inside each group, so every visit leads
* with other games. `rank` keeps each game's place for the page's lifetime (the live refresh doesn't reshuffle). */
export function shuffleGames(games: Game[], rank = new Map<string, number>(), rand: () => number = Math.random): Game[] {
const key = (g: Game) => g.package || g.title;
for (const g of games) if (!rank.has(key(g))) rank.set(key(g), rand());
return [...games].sort((a, b) => ORDER[a.status] - ORDER[b.status] || rank.get(key(a))! - rank.get(key(b))!);
}
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
/** Where a recipe chip links to (its entry in the patch reference), or null for none. */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 229 KiB

+7 -1
View File
@@ -179,13 +179,19 @@ class History:
self.series.clear()
def main_game(sample: dict | None) -> dict | None:
"""The game in front: the agent lists games newest first with SteamVR overlay apps (they run next to games, e.g.
a desktop overlay) last; an overlay alone isn't a game being played. Older agents: the first non-overlay."""
return next((g for g in (sample or {}).get("games") or [] if not g.get("overlay")), None)
def series_of(sample: dict) -> dict[str, float | None]:
"""The values one sample adds to the History."""
mem = sample.get("mem") or {}
total = mem.get("total") or 0
temps = sample.get("temps") or {}
bat = sample.get("battery") or {}
game = (sample.get("games") or [None])[0] or {}
game = main_game(sample) or {}
extra = {f"temp:{g}": v for g, v in temps.items()}
if sample.get("fan") is not None:
extra["fan"] = sample["fan"]
+1
View File
@@ -1391,6 +1391,7 @@
"Steam Frame": "",
"Steam Frame patches": "",
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
"Steam game": "",
"Steam on the Frame downloads Proton (about 1 GiB). Steam restarts once, which closes a running game.": "",
"Steam on the Frame isn't responding. Restart the Frame and try again.": "",
"SteamOS · Developer Mode": "",
+25 -18
View File
@@ -9,6 +9,7 @@ The UI only calls `frameport.pipeline`, the targets and the toolchain; long work
from __future__ import annotations
import os
import sys
import threading
import time
import traceback
@@ -20,6 +21,7 @@ import flet as ft
from .. import pipeline
from ..core import applog, library
from ..errors import explain
from ..frame import monitor as M
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
from ..i18n import fmt_size, tr, tr_n
from ..recommend import catalog
@@ -160,6 +162,8 @@ class FramePortApp:
for key, value in window_geometry(library.setting("ui.window"), T.SCALE).items():
setattr(page.window, key, value)
page.window.on_event = self._on_window_event
if sys.platform == "win32" and (icon := G.window_icon(Path(assets_dir()))):
page.window.icon = str(icon) # source runs: FramePort's icon instead of Flet's
page.on_keyboard_event = self._on_key
if page.web: # the library's right-click menu; otherwise the browser shows its own
try:
@@ -513,7 +517,7 @@ class FramePortApp:
def _on_card_sample(self, s: dict) -> None:
"""A sample for the card (the stream's thread): remember it, keep the fps history, redraw at most once a
second (with the Monitor open the stream runs faster than the card's 5 s)."""
game = (s.get("games") or [None])[0]
game = M.main_game(s)
pkg = game.get("package") if game else None
if pkg != self._card_pkg:
self._card_pkg = pkg
@@ -527,7 +531,7 @@ class FramePortApp:
self._card_art[pkg] = thumbs.url(pkg, CARD_ART, width=160, wait=False) if library.game(pkg) else None
was = self._card_sample
self._card_sample, self._card_state = s, "live"
shown_game = bool(was and was.get("games"))
shown_game = M.main_game(was) is not None
now = time.monotonic()
if now - self._card_shown < 1.0 and shown_game == bool(game):
return
@@ -946,7 +950,11 @@ class FramePortApp:
from .views.library import display_title, twins
g = library.game(pkg)
return display_title(g, twins(library.games())) if g else pkg
if g:
return display_title(g, twins(library.games()))
# a game on the Frame that isn't in this library (Frame page → Installed): the name it has there
on_frame = next((d for d in (self.frame_info or {}).get("installed", []) if d.get("package") == pkg), None)
return (on_frame or {}).get("title") or pkg
def install_options(self, g: dict) -> list[tuple]:
"""[(label, icon, on_click, disabled, tooltip)] — the first is the primary action."""
@@ -1671,9 +1679,9 @@ class FramePortApp:
delete_local, extra = self._delete_local_option(pkg)
delete_data = None
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
delete_data = ft.Checkbox(label=tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = delete_data if extra is None else ft.Column([delete_data, extra], spacing=T.px(8), tight=True)
delete_data, row = C.checkbox(tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = row if extra is None else ft.Column([row, extra], spacing=T.px(8), tight=True)
def run(job: Job):
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
@@ -1696,12 +1704,12 @@ class FramePortApp:
return None, None
size = sum((sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) if p.is_dir() else p.stat().st_size)
for p in files)
box = ft.Checkbox(label=tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
box, row = C.checkbox(tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
from ..core import winhost
shown = [winhost.to_windows(p) if winhost.is_wsl() else str(p) for p in files]
return box, ft.Column([box, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
return box, ft.Column([row, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
def remove_from_library(self, pkg: str) -> None:
title = self._title(pkg)
@@ -1904,7 +1912,7 @@ class FramePortApp:
else "works"))
notes = C.field(label=tr("Notes (what you checked, known issues)"), multiline=True, min_lines=2,
max_lines=6, width=T.DIALOG_M)
played = ft.Checkbox(label=tr("I played it in the headset with this recipe"), value=False)
played, played_row = C.checkbox(tr("I played it in the headset with this recipe"), value=False)
send = C.primary(tr("Open GitHub issue"), ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=None)
def sync(e=None):
@@ -1932,7 +1940,7 @@ class FramePortApp:
get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]),
T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played,
ft.Row([status, C.help_icon("share_config")]), notes, played_row,
C.body(tr("Launch tests can't check the picture or controls — only you can."),
T.TEXT_3),
], tight=True, spacing=T.S3),
@@ -2751,12 +2759,11 @@ class FramePortApp:
shown_dir = winhost.to_windows(backup_dir) if winhost.is_wsl() else str(backup_dir)
keys_label = tr("Back up the signing keys ({len}) to {shown_dir} first").format(len=len(pl.keys),
shown_dir=shown_dir)
cb_keys = ft.Checkbox(label=keys_label,
value=bool(pl.keys), active_color=T.ACCENT)
cb_frame = ft.Checkbox(label=tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
disabled=not connected, active_color=T.ACCENT)
cb_saves = ft.Checkbox(label=tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
cb_keys, keys_row = C.checkbox(keys_label, value=bool(pl.keys), active_color=T.ACCENT)
cb_frame, frame_row = C.checkbox(tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
disabled=not connected, active_color=T.ACCENT)
cb_saves, saves_row = C.checkbox(tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
frame_items = [i for i in pl.items if i.kind == "frame"]
steam_items = [i for i in pl.items if i.kind == "steam"]
other = [i for i in pl.items if i.kind == "pc"]
@@ -2792,7 +2799,7 @@ class FramePortApp:
self.page.show_dialog(C.dialog(
tr("Uninstall FramePort?"),
ft.Column([C.body(tr("This removes:"), T.TEXT), *lines, ft.Container(height=T.S2),
cb_keys, cb_frame, cb_saves,
keys_row, frame_row, saves_row,
C.meta(tr("Keep the signing keys: without them, updating a game "
"loses its saves."))],
spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO),
+17
View File
@@ -213,6 +213,23 @@ def h2(text: str) -> ft.Text:
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
def checkbox(label: str, **kw) -> tuple[ft.Checkbox, ft.Control]:
"""(the checkbox, the control to show): a checkbox whose label wraps (Material's own label stays on one line and
runs past a dialog's edge); clicking the label toggles it too."""
box = ft.Checkbox(**kw)
def click_label(e):
if box.disabled:
return
box.value = not box.value
update(box)
if box.on_change:
box.on_change(SimpleNamespace(control=box))
return box, ft.Row([box, 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.S1, vertical_alignment=ft.CrossAxisAlignment.CENTER)
def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
"""A labelled switch in the theme's text colour (Material's default label is dark text). The label wraps (a
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
+2 -3
View File
@@ -24,10 +24,9 @@ def now_playing(sample: dict | None, fps_series: list | None = None) -> dict | N
"""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:
g = M.main_game(sample)
if g is None:
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")
+21
View File
@@ -45,6 +45,27 @@ def install(assets: Path) -> Path:
return target
APP_ICON = Path(__file__).resolve().parents[2] / "assets" / "icon.png" # src/assets: the bundles' app icon
def window_icon(assets: Path, source: Path = APP_ICON) -> Path | None:
"""<assets>/ui-icons/app.ico made from the app icon, for the window and taskbar of source runs on Windows (they
run Flet's prebuilt client, which shows Flet's logo; `flet build` bundles have the icon built in). None when the
icon isn't there (bundles) or can't be converted."""
dest = assets / FOLDER / "app.ico"
try:
if dest.is_file() and dest.stat().st_mtime >= source.stat().st_mtime:
return dest
from PIL import Image
dest.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as im:
im.convert("RGBA").save(dest, sizes=[(n, n) for n in (16, 20, 24, 32, 40, 48, 64, 128, 256)])
return dest
except Exception: # noqa: BLE001 (Flet's icon then; never worth failing the start)
return None
PORTAL_REV = 2
+3 -2
View File
@@ -518,7 +518,7 @@ class MonitorView:
def _apply_game(self, s: dict) -> list:
games = s.get("games") or []
self.last_games = games
game = games[0] if games else None
game = M.main_game(s)
was = self.game_card.visible
self.game_card.visible = game is not None
self.no_game.visible = game is None and self.filter.data == "game"
@@ -534,7 +534,8 @@ class MonitorView:
# once per game (not per tick): its artwork, by asset URL
self.game_art.content = C.art_fill(meta["art"], radius=T.RADIUS_SM, width=T.px(96), height=T.px(96))
self.page_btn.visible = meta["in_library"]
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app")}.get(game.get("kind"), tr("Quest game"))
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app"),
"steam": tr("Steam game")}.get(game.get("kind"), tr("Quest game"))
self.game_title.value = game.get("title") or pkg
self.game_sub.value = " · ".join(x for x in (kind, tr("running for {t}").format(
t=M.fmt_duration(game.get("elapsed"))) if game.get("elapsed") is not None else "") if x)
+39
View File
@@ -108,6 +108,9 @@ def frame_tree(root, home):
proc(root, 302, "lus4xvrplayerov", 301, cpu=50, fds={3: ("/dev/kgsl-3d0", 5000)},
cgroup="/user.slice/libpod-abc.scope/container", cmdline=["cn.vr4p.oculus4xvrplayerov"])
proc(root, 400, "bash", 1, cmdline=["bash", "-c", "sleep 9"])
# the agent's shortcut worker during an uninstall names the game's launch.sh: not the game
proc(root, 500, "python3", 1, cmdline=["python3", "{agent}", "_shortcuts_worker",
json.dumps({"remove": [{"exe": f'"{launcher}"'}]})])
return base
@@ -119,6 +122,9 @@ def fa(monkeypatch, tmp_path):
a = load_agent(monkeypatch, home)
root = tmp_path / "frame"
frame_tree(root, home)
agent_file = next(iter(a.AGENT_FILES))
cmd = root / "proc" / "500" / "cmdline"
cmd.write_text(cmd.read_text().replace("{agent}", agent_file))
monkeypatch.setattr(a, "PROC", str(root / "proc"))
monkeypatch.setattr(a, "SYS", str(root / "sys"))
monkeypatch.setattr(a, "POWER_SUPPLY", str(root / "power_supply"))
@@ -177,6 +183,7 @@ def test_processes_games_and_filters(fa):
assert 3 not in names and 2 not in names # kernel threads
assert {names[pid]["game"] for pid in (200, 300, 301, 302)} == {GAME}
assert names[100]["group"] == "steam" and names[101]["group"] == "steamvr" and names[400]["group"] == "other"
assert names[500]["game"] is None
assert names[302]["name"] == "cn.vr4p.oculus4xvrplayerov" # the full name, not the truncated comm
assert names[101]["critical"] and names[1]["locked"]
# default filter: the game's processes + the busiest others as context
@@ -356,3 +363,35 @@ def test_fast_ticks_scan_processes_every_2_s(fa):
seen.append("procs" in m.sample(wall=2000.0 + clock["t"] - 100.0))
assert seen == [False] * 7 + [True]
assert 0.1 in fa.MON_INTERVALS and fa.MON_DEFAULT_INTERVAL == 0.5
def test_steam_games_and_overlay_apps(fa):
"""A Steam store game (Job Simulator in Lepton, started by Steam's reaper) counts as a game with its Steam name;
an overlay app (Temporal Reality) is flagged and listed after it, and isn't the game in front."""
from frameport.frame.monitor import main_game
from frameport.ui.frame_card import now_playing
root, home = fa.ROOT, fa.ROOT.parent / "home"
w(home / ".local/share/Steam/steamapps/appmanifest_448280.acf",
'"AppState"\n{\n\t"appid"\t\t"448280"\n\t"name"\t\t"Job Simulator"\n\t"installdir"\t\t"Job Simulator"\n}\n')
ov = home / "Applications/quest-frame/linux.overlay"
w(ov / "deployment.json", json.dumps({"package": "linux.overlay", "appid": 1234, "title": "Temporal Reality",
"kind": "linux", "overlay": {"registered": True}}))
proc(root, 600, "bash", 1, start=50, cmdline=["bash", str(ov / "launch.sh")]) # started long before the game
proc(root, 601, "TemporalReality", 600, start=50, cmdline=[str(ov / "app/TemporalReality")])
proc(root, 700, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", "AppId=448280", "--", "lepton"])
proc(root, 701, "lepton", 700, start=150, cmdline=["/bin/bash", "lepton", "waitforexitandrun"])
proc(root, 702, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", f"AppId={fa.STEAMVR_APPID}"])
m, _ = monitor(fa)
s = m.sample(wall=2000.0)
games = {g["package"]: g for g in s["games"]}
steam = games["steam:448280"]
assert steam["title"] == "Job Simulator" and steam["kind"] == "steam" and not steam["overlay"]
assert games["linux.overlay"]["overlay"] and f"steam:{fa.STEAMVR_APPID}" not in games
assert s["games"][-1]["package"] == "linux.overlay" # overlay apps last
assert {p["pid"]: p["game"] for p in m.procs}[701] == "steam:448280"
# the newest game first (the fixture's game started at 100, Job Simulator at 150)
assert main_game(s)["package"] == "steam:448280" and now_playing(s)["title"] == "Job Simulator"
# an overlay alone is nobody's game
alone = {"games": [g for g in s["games"] if g["overlay"]]}
assert main_game(alone) is None and now_playing(alone) is None