mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 17:01:30 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9ccc3606 | ||
|
|
83a614d645 | ||
|
|
5734c1fdee | ||
|
|
af791a45b4 | ||
|
|
11c8802b07 |
No files matched your search
@@ -76,6 +76,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
auto-start SteamVR on Play (`winhost.start_steamvr`); the Frame launcher always uses `/openxr`. UI (`game.py`
|
||||
where()) states per-game where it runs; installing an Oculus game on the Frame shows a warning. Migration
|
||||
`rift_frame_native` re-analyzes + re-derives existing entries.
|
||||
- Own artwork (0.8.0): game menu → "Use your own artwork…" (also from the Find artwork dialog) =
|
||||
`views/art_dialog.show_custom_art_dialog`: one slot per kind (portrait/landscape/hero/logo/icon), FilePicker →
|
||||
`sources.apply_custom` (Pillow check, ≥64 px, ≤40 MB, scaled to ≤3840 px, PNG if alpha/logo/icon else JPEG,
|
||||
replaces only that kind + its thumbnails, drops the generated cover/banner, writes `.picked` = "custom") /
|
||||
`remove_custom`; Steam shapes are still composed from what exists (`steam.py` PREFER).
|
||||
- Art for Rift games: `artwork/sources.py` — Quest version package (OculusDB packageName, exact name or +
|
||||
"Unplugged"-type suffix, never sequels) → Meta art; OculusDB square cover; Steam (exact names only); exe icon.
|
||||
- Store details (`artwork/details.py`, entry `details`): OculusDB (description, genres, publisher, website; by Quest
|
||||
@@ -492,6 +497,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):** on some Frames Steam never loads FramePort's shortcuts.vdf entries (same SteamOS build and arm64 beta client as the dev Frame; IDs correct; file written before Steam starts): Play → console_log `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` (= "Game configuration unavailable", Steam treats the id as a store app). Cause unknown; agent v42/43 diagnostics: `launch` returns `steam` (started / error + code / silent from console_log), `collect_diag` adds `steam_library` (real Steam dir, beta, Steam start vs vdf write time, entries per account, devkit games) + `steam-console.txt` (incl. Steam's `logs/shortcuts.previous.txt` if present; steamclient.so has "LoadShortcuts: rejecting attempt to load shortcuts: invalid account ID"). **Fallback (agent v43):** when Play gets AppError_9 the agent registers the game through Steam's devkit interface (what Valve's Devkit Management Tool does, MIT: `devkit-1 steam://devkit-1/<~/.steam/steam.token>/create-shortcut?response=<file>&gameid=<id>&directory=~/devkit-game` written to ~/.steam/steam.pipe; answer file / `.error`; `~/devkit-game/<id>/launch.sh` = link to the game's launch.sh, `<id>-argv.json` ["launch.sh"] (relative to the folder), `<id>-settings.json` {steam_play 0, compat_tool ""}). Steam adds it live (no restart), picks its own appid (read back from shortcuts.vdf by DevkitGameID), shows it as a normal non-Steam game named "Devkit Game: <id>" (OpenVR 0, but VR works: owner-verified 2026-10-04), art copied to grid/<appid>*. Ids: letter first, then letters/digits/_ only (spaces, '-', leading digit → "missing/invalid arguments"). Later Plays use the devkit entry; uninstall (`devkit_unregister`, `delete-shortcut` live) and purge remove it. Verified on the dev Frame: register/launch/unregister; the AppError_9 trigger itself only in unit tests.
|
||||
**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.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Install games that target the Meta Quest, Android, or general PCVR onto your **V
|
||||
[What it changes](docs/FRAME_SETUP.md).
|
||||
- **Type on Frame:** use your computer's keyboard on the Frame: in VR apps, Android apps, Steam and the desktop.
|
||||
[More](#type-on-frame).
|
||||
- **One click per game:** convert, patch, sign, upload, add to Steam with artwork, launch test.
|
||||
- **One click per game:** convert, patch, sign, upload, add to Steam with artwork, launch test. Artwork that can't be found automatically can be picked from the stores or replaced with your own images.
|
||||
- **Per-game recipes:** a tested catalog plus detection rules; every patch explained in plain words.
|
||||
- **FrameBridge:** FramePort's OpenXR adapter emulates what the Frame natively lacks (passthrough, room, controller models,
|
||||
curved and 360° layers); game settings as simple switches.
|
||||
|
||||
+292
-6
@@ -35,7 +35,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 41
|
||||
AGENT_VERSION = 43
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -1189,6 +1189,151 @@ def steam_gameid(appid):
|
||||
|
||||
NOT_IN_LIBRARY = "not in the Frame's Steam library"
|
||||
|
||||
# Fallback library entry (GitHub #21/#30): on some Frames Steam never picks up the shortcuts FramePort writes into
|
||||
# shortcuts.vdf (Play: "Game configuration unavailable", Steam's log: RequestingLicense → AppError_9), while games
|
||||
# registered through Steam's devkit interface work there (Valve's Devkit Management Tool, MIT: the same pipe command,
|
||||
# ~/devkit-game/<gameid>/ + <gameid>-argv.json/-settings.json). Steam adds those live, without a restart, and runs
|
||||
# argv[0] relative to the game's folder: a link to the game's launch.sh. Steam names them "Devkit Game: <gameid>"
|
||||
# (no spaces allowed in the id), so they're only used where the normal entry fails.
|
||||
DEVKIT_GAMES = os.path.join(HOME, "devkit-game")
|
||||
|
||||
|
||||
def devkit_request(command, timeout=15):
|
||||
"""Send a devkit-1 command to the running Steam (its command pipe + session token) and wait for its answer file.
|
||||
Returns the answer text; AgentError on an error answer or no answer."""
|
||||
import tempfile
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
try:
|
||||
token = open(os.path.join(HOME, ".steam/steam.token")).read().strip()
|
||||
except OSError:
|
||||
raise AgentError("Steam isn't running on the Frame (no session token)") from None
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-devkit") as tmp:
|
||||
resp = os.path.join(tmp, "response")
|
||||
line = f"devkit-1 steam://devkit-1/{token}/{command.format(response=quote_plus(resp))}\n"
|
||||
with open(os.path.realpath(os.path.join(HOME, ".steam/steam.pipe")), "wb", 0) as pipe:
|
||||
pipe.write(line.encode())
|
||||
for _ in range(timeout * 4):
|
||||
time.sleep(0.25)
|
||||
if os.path.exists(resp + ".error"):
|
||||
raise AgentError(f"Steam: {open(resp + '.error', errors='replace').read().strip()}")
|
||||
if os.path.exists(resp) and not os.path.exists(resp + ".lock"):
|
||||
return open(resp, errors="replace").read()
|
||||
raise AgentError("Steam didn't answer the devkit request")
|
||||
|
||||
|
||||
def devkit_gameid(pkg):
|
||||
"""The devkit game id whose launcher links to this game's launch.sh, if it has one."""
|
||||
target = os.path.join(ANCHORS, pkg, "launch.sh")
|
||||
try:
|
||||
names = sorted(os.listdir(DEVKIT_GAMES))
|
||||
except OSError:
|
||||
return None
|
||||
for name in names:
|
||||
link = os.path.join(DEVKIT_GAMES, name, "launch.sh")
|
||||
if os.path.islink(link) and os.readlink(link) == target:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def devkit_appid(gameid):
|
||||
"""The appid Steam gave a devkit game (it picks it itself), from any account's shortcuts.vdf."""
|
||||
for user in steam_users():
|
||||
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
|
||||
try:
|
||||
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
|
||||
except (OSError, AgentError):
|
||||
continue
|
||||
for v in (root.get("shortcuts") or {}).values():
|
||||
if isinstance(v, dict) and v.get("DevkitGameID") == gameid and v.get("appid"):
|
||||
return v["appid"] & 0xFFFFFFFF
|
||||
return None
|
||||
|
||||
|
||||
def devkit_register(pkg):
|
||||
"""Add the game to Steam through the devkit interface (Steam must run); copies its art. Returns the appid."""
|
||||
dep = deployment(pkg) or {}
|
||||
gameid = devkit_gameid(pkg)
|
||||
if not gameid:
|
||||
# Steam only accepts ids like identifiers: a letter first, then letters, digits and "_" (no spaces or "-")
|
||||
slug = re.sub(r"[^A-Za-z0-9]+", "_", dep.get("title") or "").strip("_")[:60] or pkg.replace(".", "_")
|
||||
slug = re.sub(r"[^A-Za-z0-9_]", "_", slug)
|
||||
if not slug[:1].isalpha():
|
||||
slug = "Game_" + slug
|
||||
gameid, n = slug, 2
|
||||
while os.path.exists(os.path.join(DEVKIT_GAMES, gameid)):
|
||||
gameid, n = f"{slug}_{n}", n + 1
|
||||
folder = os.path.join(DEVKIT_GAMES, gameid)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
link = os.path.join(folder, "launch.sh")
|
||||
if os.path.lexists(link):
|
||||
os.remove(link)
|
||||
os.symlink(os.path.join(ANCHORS, pkg, "launch.sh"), link)
|
||||
with open(os.path.join(DEVKIT_GAMES, f"{gameid}-argv.json"), "w") as f:
|
||||
json.dump(["launch.sh"], f)
|
||||
with open(os.path.join(DEVKIT_GAMES, f"{gameid}-settings.json"), "w") as f:
|
||||
json.dump({"steam_play": "0", "compat_tool": ""}, f)
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
try:
|
||||
devkit_request("create-shortcut?response={response}&gameid=" + gameid + "&directory=" +
|
||||
quote_plus(DEVKIT_GAMES))
|
||||
except AgentError:
|
||||
_devkit_remove_files(gameid)
|
||||
raise
|
||||
appid = None
|
||||
for _ in range(20): # Steam writes the new entry to shortcuts.vdf right after answering
|
||||
appid = devkit_appid(gameid)
|
||||
if appid:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if not appid:
|
||||
raise AgentError("Steam added the devkit entry but didn't save it")
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
for user in steam_users():
|
||||
grid = os.path.join(STEAM, "userdata", user, "config/grid")
|
||||
os.makedirs(grid, exist_ok=True)
|
||||
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
|
||||
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
|
||||
if img:
|
||||
for old in grid_files(grid, appid):
|
||||
if re.match(rf"^{appid}{re.escape(suffix)}\.", os.path.basename(old)):
|
||||
os.remove(old)
|
||||
shutil.copy(img, os.path.join(grid, f"{appid}{suffix}{os.path.splitext(img)[1]}"))
|
||||
return appid
|
||||
|
||||
|
||||
def devkit_unregister(pkg, steam_running=True):
|
||||
"""Remove the game's devkit entry: Steam's entry (live when Steam runs; else from shortcuts.vdf), its art and
|
||||
the ~/devkit-game files. Returns whether there was one."""
|
||||
gameid = devkit_gameid(pkg)
|
||||
if not gameid:
|
||||
return False
|
||||
appid = devkit_appid(gameid)
|
||||
exe = f'"{os.path.join(DEVKIT_GAMES, gameid, "launch.sh")}"'
|
||||
if steam_running:
|
||||
try:
|
||||
devkit_request("delete-shortcut?response={response}&gameid=" + gameid)
|
||||
except AgentError:
|
||||
pass
|
||||
else:
|
||||
for user in steam_users():
|
||||
remove_shortcut(os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf"), exe)
|
||||
if appid:
|
||||
for user in steam_users():
|
||||
for art in grid_files(os.path.join(STEAM, "userdata", user, "config/grid"), appid):
|
||||
os.remove(art)
|
||||
_devkit_remove_files(gameid)
|
||||
return True
|
||||
|
||||
|
||||
def _devkit_remove_files(gameid):
|
||||
remove_tree(os.path.join(DEVKIT_GAMES, gameid))
|
||||
for suffix in ("argv", "settings", "env"):
|
||||
p = os.path.join(DEVKIT_GAMES, f"{gameid}-{suffix}.json")
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
|
||||
def cmd_launch(args):
|
||||
"""Start an installed game the way the headset's library does: ask the running Steam to launch its shortcut, so
|
||||
@@ -1199,14 +1344,68 @@ def cmd_launch(args):
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
if run(["pgrep", "-x", "steam"]).returncode != 0:
|
||||
raise AgentError("Steam isn't running on the Frame")
|
||||
appid = shortcut_appid_for(f'"{os.path.join(ANCHORS, pkg)}/launch.sh"')
|
||||
devkit = devkit_gameid(pkg)
|
||||
appid = devkit_appid(devkit) if devkit else None # this Frame needed the fallback entry before: use it
|
||||
via = "devkit" if appid else "shortcut"
|
||||
appid = appid or shortcut_appid_for(f'"{os.path.join(ANCHORS, pkg)}/launch.sh"')
|
||||
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)
|
||||
steam = steam_launch(appid, args.get("wait", 10))
|
||||
out = {"package": pkg, "gameid": steam_gameid(appid), "title": dep.get("title"), "via": via, "steam": steam}
|
||||
if via == "shortcut" and steam["result"] == "error" and steam.get("code") == 9 and args.get("fallback", True):
|
||||
# Steam doesn't know the shortcut at all (it tried to license a store app with that id): devkit entry
|
||||
try:
|
||||
appid = devkit_register(pkg)
|
||||
except AgentError as exc:
|
||||
out["fallback_error"] = str(exc)
|
||||
return out
|
||||
out.update(via="devkit", gameid=steam_gameid(appid), first_try=steam,
|
||||
steam=steam_launch(appid, args.get("wait", 10)))
|
||||
return out
|
||||
|
||||
|
||||
def steam_launch(appid, wait=10):
|
||||
"""Ask the running Steam to start shortcut `appid`; what its log says about it (steam_launch_result)."""
|
||||
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")}
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-launch-{time.time_ns()}",
|
||||
"steam", "-ifrunning", f"steam://rungameid/{steam_gameid(appid)}"])
|
||||
return steam_launch_result(log, start, appid, wait=wait)
|
||||
|
||||
|
||||
STEAM_LAUNCH_ERROR = re.compile(r"launch error (\d+) (\d+)(.*)")
|
||||
STEAM_APP_ERROR = re.compile(r"LaunchApp failed with AppError_(\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:]
|
||||
mine = [ln for ln in lines if tag in ln]
|
||||
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}
|
||||
# e.g. "GameAction [AppID 3554078061, ActionID 3] : LaunchApp failed with AppError_9" (GitHub #21)
|
||||
failed = next((m for m in map(STEAM_APP_ERROR.search, mine) if m), None)
|
||||
if failed:
|
||||
return {"result": "error", "code": int(failed.group(1)), "detail": failed.group(0), "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\}/([^"/]+)"')
|
||||
@@ -2191,6 +2390,11 @@ def cmd_uninstall(args):
|
||||
if args.get("remove_shortcut") and steam_users():
|
||||
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
|
||||
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
|
||||
if args.get("remove_shortcut"):
|
||||
try:
|
||||
devkit_unregister(pkg, steam_running=run(["pgrep", "-x", "steam"]).returncode == 0)
|
||||
except Exception: # noqa: BLE001 (the files go anyway at the next purge)
|
||||
pass
|
||||
if not keep_data or base != anchor:
|
||||
remove_tree(anchor)
|
||||
else: # saves live next to the launcher (Quest games): keep them, drop what marks the game as installed
|
||||
@@ -2348,6 +2552,76 @@ 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
|
||||
rep["devkit_games"] = sorted(n for n in os.listdir(DEVKIT_GAMES) if os.path.isdir(os.path.join(DEVKIT_GAMES, n))) \
|
||||
if os.path.isdir(DEVKIT_GAMES) else []
|
||||
lines = []
|
||||
shortcut_log = _tail(os.path.join(STEAM, "logs/shortcuts.previous.txt"), 200000)
|
||||
if shortcut_log:
|
||||
lines += ["==> shortcuts.previous.txt", shortcut_log]
|
||||
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 +2664,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:
|
||||
@@ -2478,6 +2758,12 @@ def purge_worker(payload):
|
||||
except AgentError as exc:
|
||||
result["errors"].append(str(exc))
|
||||
service = True
|
||||
for d in games: # devkit fallback entries (Steam is closed: removed from shortcuts.vdf)
|
||||
try:
|
||||
if devkit_unregister(d["package"], steam_running=False):
|
||||
result["removed"].append(f"Steam devkit entry: {d.get('title')}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{d.get('title')}: {exc}")
|
||||
for u in users:
|
||||
vdf = os.path.join(STEAM, "userdata", u, "config/shortcuts.vdf")
|
||||
grid = os.path.join(STEAM, "userdata", u, "config/grid")
|
||||
|
||||
+2
-1
@@ -174,7 +174,8 @@ def main() -> int:
|
||||
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)))]
|
||||
("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)))]
|
||||
# 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),
|
||||
a.library_view.open_menu(game))))
|
||||
|
||||
@@ -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.8.0"
|
||||
@@ -251,3 +251,68 @@ def apply_choice(package: str, choice: dict) -> bool:
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
|
||||
|
||||
# The user's own images (Artwork dialog → "Your own images"), for games whose art can't be found automatically.
|
||||
CUSTOM_KINDS = ("portrait", "landscape", "hero", "logo", "icon")
|
||||
CUSTOM_MAX_BYTES = 40 << 20
|
||||
CUSTOM_MIN_PX = 64
|
||||
CUSTOM_MAX_PX = 3840 # larger images are scaled down (Steam's biggest shape is 1920 px wide)
|
||||
|
||||
|
||||
class ArtError(ValueError):
|
||||
"""The chosen file can't be used as artwork (the message says why, for the GUI)."""
|
||||
|
||||
|
||||
def apply_custom(package: str, kind: str, path: str | Path) -> Path:
|
||||
"""Store an image file the user chose as one kind of artwork, replacing that kind only. It is checked and
|
||||
re-encoded (PNG when it has transparency, else JPEG; logos and icons always PNG), marked as the user's pick so
|
||||
automatic fetches never replace it, and the old thumbnails go. FramePort's generated cover/banner (games without
|
||||
store art) are dropped once a real cover shape exists. Returns the stored file."""
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
if kind not in CUSTOM_KINDS:
|
||||
raise ArtError(f"unknown artwork kind {kind!r}")
|
||||
path = Path(path)
|
||||
try:
|
||||
if path.stat().st_size > CUSTOM_MAX_BYTES:
|
||||
raise ArtError(f"{path.name} is larger than {CUSTOM_MAX_BYTES >> 20} MB")
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
im = im.copy()
|
||||
except (OSError, UnidentifiedImageError, Image.DecompressionBombError):
|
||||
raise ArtError(f"{path.name} isn't an image FramePort can read (use PNG, JPEG or WebP)") from None
|
||||
if min(im.size) < CUSTOM_MIN_PX:
|
||||
raise ArtError(f"{path.name} is too small ({im.width}×{im.height}); use at least {CUSTOM_MIN_PX} px")
|
||||
if max(im.size) > CUSTOM_MAX_PX:
|
||||
im.thumbnail((CUSTOM_MAX_PX, CUSTOM_MAX_PX), Image.LANCZOS)
|
||||
alpha = im.mode in ("RGBA", "LA", "PA") or (im.mode == "P" and "transparency" in im.info)
|
||||
png = alpha or kind in ("logo", "icon")
|
||||
d = fetch.artwork_dir(package)
|
||||
tmp = d / f".custom-{kind}.tmp"
|
||||
if png:
|
||||
im.convert("RGBA" if alpha else "RGB").save(tmp, "PNG", optimize=True)
|
||||
else:
|
||||
im.convert("RGB").save(tmp, "JPEG", quality=92, optimize=True)
|
||||
_drop_kind(d, kind)
|
||||
out = d / f"{kind}{'.png' if png else '.jpg'}"
|
||||
tmp.replace(out)
|
||||
if kind in ("portrait", "landscape", "hero"):
|
||||
for stem in ("cover", "banner"):
|
||||
_drop_kind(d, stem)
|
||||
(d / fetch.PICKED).write_text("custom", encoding="utf-8") # automatic fetches leave the art alone
|
||||
return out
|
||||
|
||||
|
||||
def remove_custom(package: str, kind: str) -> None:
|
||||
"""Remove one kind of artwork (the Steam set then composes it from the other kinds, or uses a placeholder)."""
|
||||
if kind not in CUSTOM_KINDS:
|
||||
raise ArtError(f"unknown artwork kind {kind!r}")
|
||||
d = fetch.artwork_dir(package)
|
||||
_drop_kind(d, kind)
|
||||
(d / fetch.PICKED).write_text("custom", encoding="utf-8") # "Find automatically" brings store art back
|
||||
|
||||
|
||||
def _drop_kind(d: Path, kind: str) -> None:
|
||||
for f in list(d.glob(f"{kind}.*")) + list(d.glob(f"t_{kind}_*")):
|
||||
f.unlink(missing_ok=True)
|
||||
@@ -48,6 +48,7 @@
|
||||
"72 Hz": "",
|
||||
"80 Hz": "",
|
||||
"90 Hz": "",
|
||||
"920 × 430": "",
|
||||
"96 Hz": "",
|
||||
"A folder with Android games (APK + OBB, e.g. Quest games) or PC VR games.": "",
|
||||
"A game is running on the Frame. Close it in the headset first, then try again.": "",
|
||||
@@ -132,6 +133,7 @@
|
||||
"Automatic SteamVR performance settings (this PC)": "",
|
||||
"Back to library": "",
|
||||
"Back up the signing keys ({len}) to {shown_dir} first": "",
|
||||
"Banner": "",
|
||||
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
|
||||
"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": "",
|
||||
@@ -159,7 +161,9 @@
|
||||
"Check that the Frame and this computer are on the same network.": "",
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Choose PNG, JPEG or WebP images. Steam fills any missing shape from the ones you choose; FramePort never replaces your images automatically.": "",
|
||||
"Chooses which Proton version runs the game on the Frame.": "",
|
||||
"Choose…": "",
|
||||
"Clean up Frida leftovers in smali": "",
|
||||
"Cleans up graphics data that crashes some Unreal games on the Frame.": "",
|
||||
"Clear": "",
|
||||
@@ -206,6 +210,7 @@
|
||||
"Couldn't read the Frame's folders: {exc}": "",
|
||||
"Couldn't read {path}: {exc}": "",
|
||||
"Couldn't use {choice}: {exc}": "",
|
||||
"Cover": "",
|
||||
"Create": "",
|
||||
"Customize": "",
|
||||
"Cylinder layers (curved menus and movie screens, e.g. 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
|
||||
@@ -380,10 +385,12 @@
|
||||
"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).": "",
|
||||
"I played it in the headset with this recipe": "",
|
||||
"Icon": "",
|
||||
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
|
||||
"If Windows asked whether FramePort may use the network, allow it (Private networks).": "",
|
||||
"If you know the Frame's address.": "",
|
||||
"Ignore focus dips up to": "",
|
||||
"Image for the {label}": "",
|
||||
"In your Steam library": "",
|
||||
"In your Steam library · launch settings changed — update it": "",
|
||||
"Install": "",
|
||||
@@ -468,6 +475,7 @@
|
||||
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
|
||||
"Loads libglshim.so ahead of libEGL in every engine library that fetches GL through eglGetProcAddress. Fixes Quest-only leniency that Mesa/Zink rejects: #pragma before #extension, implicit int/float conversions (enables GL_EXT_shader_implicit_conversions), and multiview shaders used on single-view framebuffers (hides GL_OVR_multiview; gl_hide_multiview=0 turns that off). Logs every failed shader with its source lines (logcat tag GLShim). Symptom: black screen with working audio in a GLES game.": "",
|
||||
"Locations": "",
|
||||
"Logo": "",
|
||||
"Logs details for debugging: composition layers and swapchains, focus changes, play-space creation, pointer vs grip poses, refresh rates. No effect on the game.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Looking for Frames on your network…": "",
|
||||
@@ -521,6 +529,7 @@
|
||||
"No new games in {name}": "",
|
||||
"No release notes.": "",
|
||||
"No Unreal crash reporter": "",
|
||||
"None": "",
|
||||
"None found": "",
|
||||
"Normal": "",
|
||||
"Not enough space on the Frame": "",
|
||||
@@ -734,6 +743,7 @@
|
||||
"Search": "",
|
||||
"Search again": "",
|
||||
"Search games and tags": "",
|
||||
"Search the stores instead": "",
|
||||
"Searched a moment ago.": "",
|
||||
"Searching…": "",
|
||||
"See every patch and turn them on or off": "",
|
||||
@@ -787,6 +797,7 @@
|
||||
"Something went wrong": "",
|
||||
"Sort": "",
|
||||
"Source": "",
|
||||
"square, 256 × 256": "",
|
||||
"Standard for every game": "",
|
||||
"Starting SteamVR and {title} — put your headset on": "",
|
||||
"Starting {title} on the Frame — put the headset on": "",
|
||||
@@ -829,6 +840,7 @@
|
||||
"Switch Frame…": "",
|
||||
"Tags": "",
|
||||
"Tags:": "",
|
||||
"tall, 600 × 900": "",
|
||||
"Test": "",
|
||||
"Test Proton on the Frame": "",
|
||||
"Text and layout size": "",
|
||||
@@ -847,6 +859,8 @@
|
||||
"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 didn't accept {title}'s library entry, so FramePort added it as \"Devkit Game: …\" instead. Starting it now — put the headset on.": "",
|
||||
"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)": "",
|
||||
@@ -882,6 +896,7 @@
|
||||
"Tools": "",
|
||||
"Tools are up to date": "",
|
||||
"Translator for games built on Meta's oldest VR interface.": "",
|
||||
"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.": "",
|
||||
"Troubleshooting": "",
|
||||
@@ -954,6 +969,8 @@
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use this computer's keyboard on the Frame": "",
|
||||
"Use this program": "",
|
||||
"Use your own artwork…": "",
|
||||
"Use your own images…": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the Oculus Platform SDK: it checks your Oculus license. Normally that needs the Oculus app on this PC with a license you own, so it may quit right after starting on the headset. You can still try it.": "",
|
||||
"Uses the repack's bundled Revive — experimental on the Frame": "",
|
||||
@@ -987,6 +1004,8 @@
|
||||
"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}?": "",
|
||||
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
|
||||
"Wide cover": "",
|
||||
"wide, 1920 × 620": "",
|
||||
"Windows": "",
|
||||
"Windows game (Proton)": "",
|
||||
"Windows not detected": "",
|
||||
@@ -1015,6 +1034,7 @@
|
||||
"Your Frame isn't set up for FramePort yet. Run the first-time setup once (step 1).": "",
|
||||
"Your Frame restarted unexpectedly while {title} was running (or soon after). If that game crashed it, please report it.": "",
|
||||
"Your headset, what it's ready for and what's installed": "",
|
||||
"Your own artwork for {title}": "",
|
||||
"Your own game files aren't touched.": "",
|
||||
"Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, VR API and store genres. Filter the library by either.": "",
|
||||
"{count} files · {human}": "",
|
||||
@@ -1032,9 +1052,12 @@
|
||||
"{int} h ago": "",
|
||||
"{int} min ago": "",
|
||||
"{label} ({host}) isn't reachable. Make sure it's switched on and on the same network.": "",
|
||||
"{label} removed for {title}.": "",
|
||||
"{label} updated for {title}.": "",
|
||||
"{len} known-good recipes (bundled, remote and yours)": "",
|
||||
"{len} results": "",
|
||||
"{len} selected": "",
|
||||
"{message} The Frame's Steam library still shows the old art.": "",
|
||||
"{n_pc} on this PC": "",
|
||||
"{n} file": [
|
||||
"",
|
||||
|
||||
+63
-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,24 @@ 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)
|
||||
if res.get("first_try"):
|
||||
applog.log.info("Play %s: Steam refused the library entry %s; devkit entry: %s", pkg,
|
||||
res["first_try"].get("lines"), res.get("gameid"))
|
||||
applog.log.info("Play %s (%s): Steam %s %s", pkg, res.get("via"), steam.get("result"),
|
||||
steam.get("lines"))
|
||||
if to == "frame" and res.get("fallback_error"):
|
||||
applog.log.info("Play %s: devkit entry failed: %s", pkg, res["fallback_error"])
|
||||
if to == "frame" and res.get("first_try") and steam.get("result") != "error":
|
||||
# Steam ignored FramePort's library entry: the game was added the way Valve's devkit tool does it
|
||||
self.toast(tr("The Frame's Steam didn't accept {title}'s library entry, so FramePort added it as "
|
||||
"\"Devkit Game: …\" instead. Starting it now — put the headset on.").format(title=title))
|
||||
elif 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. "
|
||||
@@ -626,6 +673,7 @@ class FramePortApp:
|
||||
if rift:
|
||||
out.append((tr("Change executable…"), ft.Icons.TERMINAL_ROUNDED, lambda e: self.choose_exe(pkg)))
|
||||
out.append((tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, lambda e: self.find_artwork(pkg)))
|
||||
out.append((tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_ROUNDED, lambda e: self.custom_artwork(pkg)))
|
||||
if not job and self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated"):
|
||||
out.append((tr("Update Steam art on Frame"), ft.Icons.WALLPAPER_ROUNDED,
|
||||
@@ -1346,6 +1394,11 @@ class FramePortApp:
|
||||
|
||||
show_art_dialog(self, pkg)
|
||||
|
||||
def custom_artwork(self, pkg: str) -> None:
|
||||
from .views.art_dialog import show_custom_art_dialog
|
||||
|
||||
show_custom_art_dialog(self, pkg)
|
||||
|
||||
# ================================================================== Frame
|
||||
def _startup(self):
|
||||
from ..frame.connection import parse_target, saved_targets
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""'Find artwork' — search the Meta/Oculus store (via OculusDB) and Steam, pick a result, or fetch automatically."""
|
||||
"""'Find artwork' — search the Meta/Oculus store (via OculusDB) and Steam, pick a result, fetch automatically, or
|
||||
choose your own image files."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -92,6 +93,99 @@ def show_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
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("Find automatically"), ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
actions=[C.ghost(tr("Use your own images…"), ft.Icons.UPLOAD_FILE_ROUNDED,
|
||||
lambda e: (app.page.pop_dialog(), show_custom_art_dialog(app, package))),
|
||||
C.ghost(tr("Find automatically"), ft.Icons.AUTO_AWESOME_ROUNDED, auto),
|
||||
C.ghost(tr("Close"), on_click=lambda e: app.page.pop_dialog())]))
|
||||
search()
|
||||
|
||||
|
||||
def custom_slots() -> tuple[tuple[str, str, str], ...]:
|
||||
"""(kind, label, the shape Steam shows it in) for each kind of artwork the user can replace."""
|
||||
return (
|
||||
("portrait", tr("Cover"), tr("tall, 600 × 900")),
|
||||
("landscape", tr("Wide cover"), tr("920 × 430")),
|
||||
("hero", tr("Banner"), tr("wide, 1920 × 620")),
|
||||
("logo", tr("Logo"), tr("transparent PNG")),
|
||||
("icon", tr("Icon"), tr("square, 256 × 256")),
|
||||
)
|
||||
|
||||
|
||||
def show_custom_art_dialog(app: FramePortApp, package: str) -> None:
|
||||
"""'Your own images': choose an image file for each kind of artwork (for games whose art can't be found
|
||||
automatically). Missing shapes are composed from the others for Steam, as with store art."""
|
||||
g = library.game(package)
|
||||
grid = ft.Row(wrap=True, spacing=T.S3, run_spacing=T.S3)
|
||||
|
||||
def changed(message: str) -> None:
|
||||
thumbs.prewarm(package)
|
||||
installed = C.install_state(library.game(package), app.frame_info) in ("installed", "outdated")
|
||||
library.upsert_game(package, art_source="custom", **({"steam_art_stale": True} if installed else {}))
|
||||
render()
|
||||
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))
|
||||
else:
|
||||
app.toast(message)
|
||||
|
||||
async def choose(kind: str, label: str) -> None:
|
||||
files = await ft.FilePicker().pick_files(dialog_title=tr("Image for the {label}").format(label=label),
|
||||
allowed_extensions=["png", "jpg", "jpeg", "webp"])
|
||||
path = next((f.path for f in files or [] if f.path), None)
|
||||
if not path:
|
||||
return
|
||||
|
||||
def work():
|
||||
try:
|
||||
sources.apply_custom(package, kind, path)
|
||||
except sources.ArtError as exc:
|
||||
app.toast(str(exc), error=True)
|
||||
return
|
||||
changed(tr("{label} updated for {title}.").format(label=label, title=g.get("title")))
|
||||
app.run_bg(work)
|
||||
|
||||
def remove(kind: str, label: str) -> None:
|
||||
def work():
|
||||
sources.remove_custom(package, kind)
|
||||
changed(tr("{label} removed for {title}.").format(label=label, title=g.get("title")))
|
||||
app.run_bg(work)
|
||||
|
||||
def slot(kind: str, label: str, shape: str) -> ft.Control:
|
||||
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)
|
||||
else:
|
||||
box = (C.art_fill(art, radius=T.px(8), height=T.px(110)) if art else
|
||||
ft.Container(C.meta(tr("None")), height=T.px(110), alignment=ft.Alignment.CENTER,
|
||||
border_radius=T.px(8), bgcolor=T.SURFACE_3))
|
||||
buttons = [C.ghost(tr("Choose…"), ft.Icons.UPLOAD_FILE_ROUNDED,
|
||||
lambda e, k=kind, lb=label: app.page.run_task(choose, k, lb))]
|
||||
if art:
|
||||
buttons.append(ft.IconButton(ft.Icons.DELETE_OUTLINE_ROUNDED, icon_size=T.px(18), icon_color=T.TEXT_3,
|
||||
tooltip=tr("Remove"), on_click=lambda e, k=kind, lb=label: remove(k, lb)))
|
||||
return ft.Container(ft.Column([
|
||||
box,
|
||||
C.body(label, T.TEXT, size=T.T_META, weight=ft.FontWeight.W_600),
|
||||
C.meta(shape),
|
||||
ft.Row(buttons, spacing=0),
|
||||
], spacing=T.px(4), tight=True), width=T.px(190), padding=T.S2, border_radius=T.RADIUS_SM,
|
||||
bgcolor=T.SURFACE)
|
||||
|
||||
def render() -> None:
|
||||
grid.controls = [slot(*s) for s in custom_slots()]
|
||||
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([
|
||||
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),
|
||||
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())]))
|
||||
@@ -913,3 +913,160 @@ 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
|
||||
|
||||
|
||||
def test_steam_launch_result_app_error(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
||||
log = tmp_path / "console_log.txt"
|
||||
log.write_text('[x] GameAction [AppID 3554078061, ActionID 3] : LaunchApp changed task to RequestingLicense\n'
|
||||
'[x] GameAction [AppID 3554078061, ActionID 3] : LaunchApp failed with AppError_9 with ""\n'
|
||||
'[x] GameAction [AppID 1234, ActionID 1] : LaunchApp failed with AppError_18 with ""\n')
|
||||
got = a.steam_launch_result(str(log), 0, 3554078061, wait=0) # GitHub #21: Steam didn't know the shortcut
|
||||
assert got["result"] == "error" and got["code"] == 9
|
||||
assert a.steam_launch_result(str(log), 0, 35540780, wait=0)["result"] == "silent" # no prefix matches
|
||||
|
||||
|
||||
def _fake_steam(a, monkeypatch, tmp_path, appid=2772269798):
|
||||
"""Steam's devkit side: answers create/delete requests and writes/removes the shortcut like Steam does."""
|
||||
sent = []
|
||||
vdf = tmp_path / ".local/share/Steam/userdata/42/config/shortcuts.vdf"
|
||||
vdf.parent.mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / ".steam").mkdir(exist_ok=True)
|
||||
(tmp_path / ".steam/steam.token").write_text("tok")
|
||||
pipe = tmp_path / ".steam/steam.pipe"
|
||||
pipe.write_text("")
|
||||
|
||||
real_open = open
|
||||
|
||||
def fake_open(path, mode="r", *args, **kw):
|
||||
if str(path) == str(pipe) and "w" in mode:
|
||||
class P:
|
||||
def write(self, data):
|
||||
line = data.decode().strip()
|
||||
sent.append(line)
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
url = urlparse(line.split(" ", 1)[1])
|
||||
q = parse_qs(url.query)
|
||||
cmd = url.path.rsplit("/", 1)[-1]
|
||||
if cmd == "create-shortcut":
|
||||
gid = q["gameid"][0]
|
||||
a.upsert_shortcut(str(vdf), f'"{a.DEVKIT_GAMES}/{gid}/launch.sh"', f"Devkit Game: {gid}",
|
||||
f"{a.DEVKIT_GAMES}/{gid}", "")
|
||||
root = a.vdf_decode(vdf.read_bytes())
|
||||
for v in root["shortcuts"].values():
|
||||
if v["Exe"].endswith(f'/{gid}/launch.sh"'):
|
||||
v.update(appid=appid, Devkit=1, DevkitGameID=gid)
|
||||
vdf.write_bytes(a.vdf_encode(root))
|
||||
elif cmd == "delete-shortcut":
|
||||
a.remove_shortcut(str(vdf), f'"{a.DEVKIT_GAMES}/{q["gameid"][0]}/launch.sh"')
|
||||
real_open(q["response"][0], "w").write("ok")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *e):
|
||||
return False
|
||||
return P()
|
||||
return real_open(path, mode, *args, **kw)
|
||||
monkeypatch.setattr(a, "open", fake_open, raising=False)
|
||||
monkeypatch.setattr("builtins.open", fake_open)
|
||||
return sent, vdf
|
||||
|
||||
|
||||
def test_devkit_fallback_register_and_unregister(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.camouflaj.manta"
|
||||
(anchor / "artwork").mkdir(parents=True)
|
||||
(anchor / "launch.sh").write_text("#!/bin/sh\n")
|
||||
(anchor / "artwork/portrait.jpg").write_bytes(b"jpg")
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "com.camouflaj.manta", "appid": 3554078061,
|
||||
"title": "Batman: Arkham Shadow", "base": str(anchor)}))
|
||||
sent, vdf = _fake_steam(a, monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
||||
appid = a.devkit_register("com.camouflaj.manta")
|
||||
assert appid == 2772269798 and a.devkit_gameid("com.camouflaj.manta") == "Batman_Arkham_Shadow"
|
||||
assert "create-shortcut" in sent[0] and "gameid=Batman_Arkham_Shadow" in sent[0] # no spaces in the id
|
||||
dk = tmp_path / "devkit-game"
|
||||
assert json.loads((dk / "Batman_Arkham_Shadow-argv.json").read_text()) == ["launch.sh"]
|
||||
assert os.readlink(dk / "Batman_Arkham_Shadow/launch.sh") == str(anchor / "launch.sh")
|
||||
grid = vdf.parent / "grid"
|
||||
assert (grid / "2772269798p.jpg").exists() # its art under Steam's new appid
|
||||
assert a.devkit_register("com.camouflaj.manta") == appid # registering again reuses the same id
|
||||
assert a.devkit_unregister("com.camouflaj.manta")
|
||||
assert not (dk / "Batman_Arkham_Shadow").exists() and not list(grid.iterdir())
|
||||
assert a.devkit_appid("Batman_Arkham_Shadow") is None and not a.devkit_unregister("com.camouflaj.manta")
|
||||
# Steam rejects ids that don't start with a letter (e.g. "4XVR_Video_Player")
|
||||
dep = json.loads((anchor / "deployment.json").read_text())
|
||||
(anchor / "deployment.json").write_text(json.dumps({**dep, "title": "4XVR Video-Player"}))
|
||||
a.devkit_register("com.camouflaj.manta")
|
||||
assert a.devkit_gameid("com.camouflaj.manta") == "Game_4XVR_Video_Player"
|
||||
|
||||
|
||||
def test_launch_falls_back_to_devkit_entry(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": 111, "title": "X",
|
||||
"base": str(anchor)}))
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=0, stdout=""))
|
||||
monkeypatch.setattr(a, "shortcut_appid_for", lambda exe: 111)
|
||||
tried, registered = [], {}
|
||||
|
||||
def launch(appid, wait=10):
|
||||
tried.append(appid)
|
||||
return {"result": "error", "code": 9} if appid == 111 else {"result": "started"}
|
||||
|
||||
def register(pkg):
|
||||
registered["id"] = 222
|
||||
return 222
|
||||
monkeypatch.setattr(a, "steam_launch", launch)
|
||||
monkeypatch.setattr(a, "devkit_register", register)
|
||||
monkeypatch.setattr(a, "devkit_gameid", lambda pkg: "X" if registered else None)
|
||||
monkeypatch.setattr(a, "devkit_appid", lambda gid: registered.get("id"))
|
||||
got = a.cmd_launch({"package": "com.x.y"})
|
||||
assert tried == [111, 222] and got["via"] == "devkit" and got["steam"]["result"] == "started"
|
||||
assert got["first_try"]["code"] == 9
|
||||
got = a.cmd_launch({"package": "com.x.y"}) # next time the devkit entry is used right away
|
||||
assert tried[-1] == 222 and got["via"] == "devkit" and "first_try" not in got
|
||||
@@ -371,3 +371,41 @@ def test_converted_copies_are_removed_after_installing(tmp_path, monkeypatch):
|
||||
assert pipeline.remove_converted_copies("com.g") == 0
|
||||
library.set_setting("build.keep_copies", False)
|
||||
assert pipeline.remove_converted_copies("com.g") == 15 and not out.exists()
|
||||
|
||||
|
||||
def test_custom_art_replaces_one_kind_and_is_kept(tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
pkg = "com.custom.art"
|
||||
d = fetch.artwork_dir(pkg)
|
||||
(d / "portrait.jpg").write_bytes(png(600, 900))
|
||||
(d / "icon.png").write_bytes(png(128, 128))
|
||||
(d / "cover.jpg").write_bytes(png(600, 900)) # FramePort's generated cover (no store art)
|
||||
src = tmp_path / "mine.png"
|
||||
Image.new("RGB", (1000, 1500), (200, 10, 10)).save(src)
|
||||
out = sources.apply_custom(pkg, "portrait", src)
|
||||
assert out.name == "portrait.jpg" and Image.open(out).size == (1000, 1500) # no alpha → JPEG
|
||||
assert (d / "icon.png").exists() and not (d / "cover.jpg").exists() # only that kind is replaced
|
||||
assert (d / fetch.PICKED).read_text() == "custom" # automatic fetches skip it
|
||||
|
||||
logo = tmp_path / "logo.webp"
|
||||
Image.new("RGBA", (5000, 1000), (0, 0, 0, 0)).save(logo)
|
||||
out = sources.apply_custom(pkg, "logo", logo)
|
||||
assert out.name == "logo.png" and max(Image.open(out).size) == sources.CUSTOM_MAX_PX # scaled down, PNG
|
||||
|
||||
thumbs.thumb(d / "icon.png", 96)
|
||||
sources.remove_custom(pkg, "icon")
|
||||
assert not list(d.glob("icon.*")) and not list(d.glob("t_icon_*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make, why", [
|
||||
(lambda p: p.write_bytes(b"not an image"), "isn't an image"),
|
||||
(lambda p: __import__("PIL.Image").Image.new("RGB", (20, 20)).save(p, "PNG"), "too small"),
|
||||
])
|
||||
def test_custom_art_rejects_bad_files(tmp_path, make, why):
|
||||
bad = tmp_path / "bad.png"
|
||||
make(bad)
|
||||
with pytest.raises(sources.ArtError, match=why):
|
||||
sources.apply_custom("com.custom.bad", "portrait", bad)
|
||||
with pytest.raises(sources.ArtError):
|
||||
sources.apply_custom("com.custom.bad", "screenshot", bad)
|
||||
@@ -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