mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 19:00:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af791a45b4 | ||
|
|
11c8802b07 |
No files matched your search
@@ -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
@@ -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,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"
|
||||
@@ -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
@@ -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. "
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user