Compare commits

...
2 Commits
7 changed files with 209 additions and 13 deletions

No files matched your search

+1
View File
@@ -492,6 +492,7 @@ lacks (ActivityNotFoundException) → imported by hand: libmain.so + game.json
DATA/ → external files/WiiCompiledOpenXRVR/DATA, MOD/ → .../RetroRewind6 (decompiled with jadx). Lepton has no
picker at all: a FramePort fix would be an injected picker activity. Generated cover/banner art used to suppress the
Steam placeholder set (no Steam art at all) → fixed in artwork/steam.py.
**Steam library failures (GitHub #21/#30, open):** on 0.7.0 some Frames have FramePort's shortcut in shortcuts.vdf (so the install skips the Steam restart) yet Steam answers Play with "Game configuration unavailable" (= `Steam_AppUpdateError_9`; console_log: `LaunchGameAction: launch error 9 <appid>`). Not reproducible on the dev Frame (same SteamOS build). Agent v42: `launch` returns `steam` = what Steam's console_log says about that launch (started / error + code / silent; the app logs it and offers Report a problem on an error) and `collect_diag` adds `steam_library` (real Steam dir behind ~/.steam, client beta, Steam start vs shortcuts.vdf write time, FramePort's entries per account) + `steam-console.txt`. Verified on the dev Frame 2026-10-04 (Play → `started` within ~1 s). The dev Frame runs Steam's **arm64 beta** client (`package/beta` = linux_arm64_beta_…): check whether reporters are on the stable client. Next: read those from a reporter's zip.
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
+104 -2
View File
@@ -35,7 +35,7 @@ import sys
import time
import zlib
AGENT_VERSION = 41
AGENT_VERSION = 42
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1203,10 +1203,41 @@ def cmd_launch(args):
if appid is None: # Steam would only say "Game configuration unavailable"
raise AgentError(f"{NOT_IN_LIBRARY}: {dep.get('title') or pkg}")
gid = steam_gameid(appid) # the shortcut's own id (it keeps its first one when the title changes)
log = os.path.join(STEAM, "logs/console_log.txt")
start = os.path.getsize(log) if os.path.exists(log) else 0
# systemd-run: the launch request must outlive this SSH session
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-launch-{int(time.time())}",
"steam", "-ifrunning", f"steam://rungameid/{gid}"])
return {"package": pkg, "gameid": gid, "title": dep.get("title")}
return {"package": pkg, "gameid": gid, "title": dep.get("title"),
"steam": steam_launch_result(log, start, appid, wait=args.get("wait", 10))}
STEAM_LAUNCH_ERROR = re.compile(r"launch error (\d+) (\d+)(.*)")
def steam_launch_result(log, start, appid, wait=10):
"""What Steam's console log says about the launch of shortcut `appid` (lines written after byte `start`): "started"
(Steam created the process), "error" (e.g. "launch error 9 <appid>" = Steam's "Game configuration unavailable",
GitHub #21/#30), "silent" (Steam logged nothing about this app: it didn't know the shortcut) or "unknown"."""
tag, lines = f"[AppID {appid}", []
deadline = time.time() + wait
while True:
try:
with open(log, "rb") as f:
f.seek(start)
new = f.read(1 << 20).decode("utf-8", "replace").splitlines()
except OSError:
return {"result": "unknown", "lines": []}
lines = [ln for ln in new if tag in ln or "launch error" in ln or "rungameid" in ln][-40:]
err = next((m for m in map(STEAM_LAUNCH_ERROR.search, lines) if m and m.group(2) == str(appid)), None)
if err:
return {"result": "error", "code": int(err.group(1)), "detail": err.group(3).strip(), "lines": lines}
if any(tag in ln and ("CreatingProcess" in ln or "WaitingGameWindow" in ln or "Completed" in ln)
for ln in lines):
return {"result": "started", "lines": lines}
if time.time() >= deadline:
return {"result": "silent" if not any(tag in ln for ln in lines) else "unknown", "lines": lines}
time.sleep(1)
LEPTON_LINK = re.compile(r'ln -s "\$\{HOME\}/([^"/]+)" "\$\{TARGET_PATH\}/([^"/]+)"')
@@ -2348,6 +2379,71 @@ def launch_test_pcvr(dep, anchor, log, seconds):
"kind": "pcvr", "game_process": bool(game_seen)}
def steam_library_report():
"""Why a game installed by FramePort may be missing from Steam or fail with "Game configuration unavailable"
(GitHub #21/#30): where Steam really lives, Steam's client version/beta, each account's shortcuts.vdf (when it
was written vs when Steam started, FramePort's entries in it) and Steam's log lines about these shortcuts.
Accounts are numbered, not named (the PC redacts ids anyway)."""
rep = {"steam_dir": STEAM}
for link in ("~/.steam/steam", "~/.steam/root"):
path = os.path.expanduser(link)
rep[link] = os.path.realpath(path) if os.path.lexists(path) else None
pkg_dir = os.path.join(STEAM, "package")
rep["beta"] = (_tail(os.path.join(pkg_dir, "beta"), 200) or "").strip() or None
rep["client_manifests"] = sorted(n for n in os.listdir(pkg_dir) if n.endswith(".manifest"))[:10] \
if os.path.isdir(pkg_dir) else []
p = run(["pgrep", "-o", "-x", "steam"])
pid = p.stdout.split()[0] if p.returncode == 0 and p.stdout.split() else None
rep["steam_started"] = os.stat(f"/proc/{pid}").st_mtime if pid and os.path.exists(f"/proc/{pid}") else None
if pid:
try:
rep["steam_cmdline"] = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\0", b" ").decode()[:500]
except OSError:
pass
ours = {}
for dep_path in glob.glob(os.path.join(ANCHORS, "*/deployment.json")):
try:
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
ours[f'"{os.path.dirname(dep_path)}/launch.sh"'] = dep.get("package")
active = active_steam_user()
accounts, appids = [], set()
for i, user in enumerate(steam_users()):
acc = {"account": i + 1, "most_recent_login": user == active}
cfg = os.path.join(STEAM, "userdata", user, "config")
vdf = os.path.join(cfg, "shortcuts.vdf")
acc["shortcuts_vdf_written"] = os.path.getmtime(vdf) if os.path.exists(vdf) else None
acc["localconfig_written"] = os.path.getmtime(os.path.join(cfg, "localconfig.vdf")) \
if os.path.exists(os.path.join(cfg, "localconfig.vdf")) else None
acc["backups"] = len(glob.glob(vdf + ".backup-*"))
try:
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
entries = [v for v in (root.get("shortcuts") or {}).values() if isinstance(v, dict)]
acc["shortcuts"] = len(entries)
acc["frameport"] = []
for v in entries:
if v.get("Exe") in ours:
appids.add(v.get("appid", 0) & 0xFFFFFFFF)
acc["frameport"].append({"package": ours[v["Exe"]], "appid": v.get("appid", 0) & 0xFFFFFFFF,
"title": v.get("AppName", v.get("appname")),
"start_dir": v.get("StartDir"), "openvr": v.get("OpenVR"),
"options": v.get("LaunchOptions")})
acc["exe_dupes"] = len(entries) - len({v.get("Exe") for v in entries})
except Exception as exc: # noqa: BLE001
acc["error"] = f"shortcuts.vdf unreadable: {exc}"
accounts.append(acc)
rep["accounts"] = accounts
lines = []
for name in ("console_log.previous.txt", "console_log.txt"):
text = _tail(os.path.join(STEAM, "logs", name), 8 << 20) or ""
keep = [ln for ln in text.splitlines() if "launch error" in ln or "rungameid" in ln
or ("shortcut" in ln.lower() and "path_shortcut" not in ln) or any(f"[AppID {a}" in ln for a in appids)]
if keep:
lines += [f"==> {name}"] + keep[-400:]
return rep, "\n".join(lines)
def _tail(path, max_bytes):
try:
size = os.path.getsize(path)
@@ -2390,6 +2486,12 @@ def cmd_collect_diag(args):
files[name] = text
host["installed"] = [{k: g.get(k) for k in ("package", "title", "kind", "appid", "version", "agent_version")}
for g in cmd_list_installed({})["games"]]
try:
host["steam_library"], console = steam_library_report()
if console:
files["steam-console.txt"] = console[-max_bytes:]
except Exception as exc: # noqa: BLE001 (diagnostics must not fail on it)
host["steam_library"] = {"error": str(exc)}
out = {"agent_version": AGENT_VERSION, "host": host, "files": files}
pkg = args.get("package")
if not pkg:
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.7.1"
__version__ = "0.7.2"
+1
View File
@@ -847,6 +847,7 @@
"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 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.": "",
"The Frame's Steam library still shows the old artwork.": "",
"the game didn't pass its checks (see the list above)": "",
"the installed game (APK and its data)": "",
+47 -10
View File
@@ -34,6 +34,22 @@ NAV = [("library", tr("Library"), ft.Icons.GRID_VIEW_ROUNDED),
POLL_SECONDS = 30
def window_geometry(saved: dict | None, scale: float) -> dict:
"""Window properties for the start from the saved `ui.window` setting: the size (first start: 16:10 sized for the
UI scale), the position when it is plausible (a window left on a monitor that is gone would open off screen; far
off values are ignored, the system then places it) and maximized."""
saved = saved or {}
w, h = saved.get("size") or (round(1280 * scale), round(780 * scale))
out = {"width": max(int(w), 1000), "height": max(int(h), 680)}
pos = saved.get("pos")
if isinstance(pos, (list, tuple)) and len(pos) == 2 and all(isinstance(v, (int, float)) for v in pos) \
and -16000 < pos[0] < 16000 and -8 <= pos[1] < 9000:
out["left"], out["top"] = int(pos[0]), int(pos[1])
if saved.get("maximized"):
out["maximized"] = True
return out
class FramePortApp:
def __init__(self, page: ft.Page):
from .views.activity import ActivityPanel
@@ -67,9 +83,10 @@ class FramePortApp:
page.window.min_width, page.window.min_height = 1000, 680
if not page.web:
# 16:10 sized for the UI scale (the first start used to open too narrow at 125 %: toolbars were cut off);
# later starts reopen at the size the window was left at
w, h = (library.setting("ui.window") or {}).get("size") or (round(1280 * T.SCALE), round(780 * T.SCALE))
page.window.width, page.window.height = max(int(w), 1000), max(int(h), 680)
# later starts reopen where the window was left: size, position (GitHub #32: always top left on Windows),
# maximized
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
page.on_keyboard_event = self._on_key
if page.web: # the library's right-click menu; otherwise the browser shows its own
@@ -108,12 +125,25 @@ class FramePortApp:
# ================================================================== shell
def _on_window_event(self, e) -> None:
"""Remember the window size (once a resize ends; not while maximized or full screen)."""
win = self.page.window
if e.type == ft.WindowEventType.RESIZED and not (win.maximized or win.full_screen) and win.width and \
win.height:
size = [round(win.width), round(win.height)]
self.page.run_thread(library.update_setting, "ui.window", lambda v: {**(v or {}), "size": size}, {})
"""Remember the window's size and position (once a resize/move ends; not while maximized or full screen) and
whether it is maximized."""
win, change = self.page.window, None
if e.type in (ft.WindowEventType.MAXIMIZE, ft.WindowEventType.UNMAXIMIZE):
change = {"maximized": e.type == ft.WindowEventType.MAXIMIZE}
elif win.maximized or win.full_screen:
return
elif e.type == ft.WindowEventType.RESIZED and win.width and win.height:
change = {"size": [round(win.width), round(win.height)]}
if win.left is not None and win.top is not None: # resizing from the left/top edge moves it too
change["pos"] = [round(win.left), round(win.top)]
elif e.type in (ft.WindowEventType.MOVED, ft.WindowEventType.CLOSE) and win.left is not None and \
win.top is not None:
change = {"pos": [round(win.left), round(win.top)]}
if e.type == ft.WindowEventType.CLOSE: # the app is going away: write it now
library.update_setting("ui.window", lambda v: {**(v or {}), **change}, {})
return
if change:
self.page.run_thread(library.update_setting, "ui.window", lambda v: {**(v or {}), **change}, {})
def top_bar(self, heading: str, subtitle: str = "", actions: list[ft.Control] | None = None) -> ft.Control:
heads = [C.title(heading), C.body(subtitle)] if subtitle else [C.title(heading)]
@@ -522,7 +552,14 @@ class FramePortApp:
self._add_then_play(pkg, title) # e.g. the shortcut step failed during the install
return
library.upsert_game(pkg, last_played=time.time())
if to == "frame":
steam = res.get("steam") or {}
if to == "frame" and steam: # what the Frame's Steam logged about this launch (GitHub #21/#30)
applog.log.info("Play %s: Steam %s %s", pkg, steam.get("result"), steam.get("lines"))
if to == "frame" and steam.get("result") == "error": # 9 = "Game configuration unavailable"
self.toast(tr("The Frame's Steam couldn't start {title} (Steam error {code}). Please send a problem "
"report so we can see why.").format(title=title, code=steam.get("code")), error=True,
action=tr("Report a problem"), on_action=lambda e: self.report_problem_dialog(pkg))
elif to == "frame":
self.toast(tr("Starting {title} on the Frame — put the headset on").format(title=title))
elif res.get("steamvr") is False:
msg = tr("Starting {title}, but SteamVR isn't running — it may open as a flat window. "
+41
View File
@@ -913,3 +913,44 @@ def test_shortcuts_lost_after_steam_restart(monkeypatch, tmp_path):
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
assert a.shortcuts_lost(["42"], ["com.x.y"]) == []
def test_steam_launch_result_reads_steams_log(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a.time, "sleep", lambda s: None)
log = tmp_path / "console_log.txt"
old = "[2026-10-04 12:00:00] SteamUI: WARNING: LaunchGameAction: launch error 9 3554078061 LaunchApp AppError_18\n"
log.write_text(old)
start = log.stat().st_size # lines from before this launch don't count
assert a.steam_launch_result(str(log), start, 3554078061, wait=0)["result"] == "silent"
with open(log, "a") as f: # GitHub #21/#30: Steam's "Game configuration unavailable"
f.write("[2026-10-04 12:09:52] SteamUI: WARNING: LaunchGameAction: launch error 9 3554078061 LaunchApp "
"AppError_18 \n")
got = a.steam_launch_result(str(log), start, 3554078061, wait=0)
assert got["result"] == "error" and got["code"] == 9 and got["detail"] == "LaunchApp AppError_18"
log.write_text(old + "[2026-10-04 19:12:16] GameAction [AppID 2369265159, ActionID 4] : LaunchApp changed task "
"to CreatingProcess with \"\"\n")
assert a.steam_launch_result(str(log), start, 2369265159, wait=0)["result"] == "started"
def test_steam_library_report(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = tmp_path / "Applications/quest-frame/com.x.y"
anchor.mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
cfg.mkdir(parents=True)
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
appid = a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), '"/usr/bin/other"', "Other", "/", "")
logs = tmp_path / ".local/share/Steam/logs"
logs.mkdir()
(logs / "console_log.txt").write_text(f"noise\n[x] GameAction [AppID {appid}, ActionID 1] : LaunchApp changed "
"task to Completed\n path_shortcut: \"/a.desktop\"\n")
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=1, stdout=""))
rep, console = a.steam_library_report()
acc = rep["accounts"][0]
assert acc["shortcuts"] == 2 and acc["exe_dupes"] == 0 and acc["shortcuts_vdf_written"]
assert acc["frameport"] == [{"package": "com.x.y", "appid": appid, "title": "X", "start_dir": start,
"openvr": acc["frameport"][0]["openvr"], "options": acc["frameport"][0]["options"]}]
assert f"[AppID {appid}" in console and "noise" not in console and "path_shortcut" not in console
+14
View File
@@ -511,3 +511,17 @@ def test_one_build_per_game_at_a_time():
assert pipeline._build_lock("com.x.y") is pipeline._build_lock("com.x.y")
assert pipeline._build_lock("com.x.y") is not pipeline._build_lock("com.x.z")
def test_window_geometry_restores_size_position_and_maximized():
from frameport.ui.app import window_geometry
first = window_geometry(None, 1.25)
assert first == {"width": 1600, "height": 975} # no position: the system places it
got = window_geometry({"size": [1400, 900], "pos": [320, 140], "maximized": True}, 1.0)
assert got == {"width": 1400, "height": 900, "left": 320, "top": 140, "maximized": True}
assert window_geometry({"size": [800, 500]}, 1.0) == {"width": 1000, "height": 680} # minimum size
# a monitor to the left (negative x) is fine; far off or malformed positions are ignored (GitHub #32)
assert window_geometry({"pos": [-1800, 50]}, 1.0)["left"] == -1800
for bad in ([-20000, 0], [100, -400], [99999, 0], ["a", 1], [1]):
assert "left" not in window_geometry({"pos": bad}, 1.0)