mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 08:01:24 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf05ce2bbd | ||
|
|
a311498bca |
No files matched your search
@@ -1163,3 +1163,15 @@ the unresolved eye distortion, and testing the release bundles on real Windows/m
|
||||
(regenerates docs/PATCHES.md + site/src/data/patches.json, the site's id → anchor map; a test runs `--check`).
|
||||
- Known-good backups of every working APK and the signing keys: `PATCHED/_known-good-2026-09-28/` (don't delete).
|
||||
- Your development Frame: `frameport frame info` (remembered in `<user data>/frames.json`); SSH as `steamos@<frame>`.
|
||||
|
||||
|
||||
Steam library scheduling (#305, agent v92): `install.steam_library` = now/later/off; agent `library_mode` persists it.
|
||||
Automatic restarts also hold on a fresh local `frameport-app/gui.alive` (10 s; existing 12 h cap).
|
||||
Later journals changes and installs steam.service ExecStartPre/ExecStartPost hooks to reapply after Steam's exit
|
||||
writes and verify before clearing; preserve this ordering and avoid locking across systemctl's startup hooks.
|
||||
Off Play (also pending Later Play) uses detached systemd-run launch.sh, with no shortcut/devkit registration;
|
||||
Steam's per-game controller/overlay/Exit game integration is lost. Restart Steam now explicitly bypasses holds,
|
||||
asks before ending a running game, and closes local FramePort/Desktop Mode. Purge removes the startup hook.
|
||||
Hook hardening: both lines `-` + `/usr/bin/timeout -k 5 20`; ExecStartPost only `systemd-run --no-block`s the
|
||||
verify (unit frameport-library-verify) so Steam's start never waits; drop-in removed once mode != later and nothing
|
||||
pending (`drop_library_hooks_if_idle`). Steam = static user unit steam.service (Type=simple, Restart=always, 90 s).
|
||||
+282
-9
@@ -30,6 +30,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -44,7 +45,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 91
|
||||
AGENT_VERSION = 92
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -1111,6 +1112,216 @@ def remove_shortcut(vdf_path, exe):
|
||||
STATUS_FILE = os.path.join(HOME, ".local/share/frameport/shortcuts-status.json")
|
||||
|
||||
|
||||
LIBRARY_MODE_FILE = os.path.join(HOME, ".local/share/frameport/library-mode.json")
|
||||
PENDING_FILE = os.path.join(HOME, ".local/share/frameport/library-pending.json")
|
||||
RESTART_REQUEST_FILE = os.path.join(HOME, ".local/share/frameport/restart-request.json")
|
||||
|
||||
|
||||
def library_mode(args=None):
|
||||
mode = (args or {}).get("mode")
|
||||
if mode is None:
|
||||
try:
|
||||
mode = json.load(open(LIBRARY_MODE_FILE))["mode"]
|
||||
except (OSError, ValueError, KeyError):
|
||||
mode = "now"
|
||||
if mode not in ("now", "later", "off"):
|
||||
raise AgentError("Steam library mode must be now, later or off")
|
||||
return mode
|
||||
|
||||
|
||||
def cmd_library_mode(args):
|
||||
mode = library_mode(args)
|
||||
_write_json(LIBRARY_MODE_FILE, {"mode": mode})
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
if mode == "off":
|
||||
pending = pending_library()
|
||||
pending["packages"] = []
|
||||
_write_json(PENDING_FILE, pending)
|
||||
drop_library_hooks_if_idle()
|
||||
return {"mode": mode}
|
||||
|
||||
|
||||
def pending_library():
|
||||
try:
|
||||
pending = json.load(open(PENDING_FILE))
|
||||
except (OSError, ValueError):
|
||||
pending = {}
|
||||
if not isinstance(pending, dict):
|
||||
pending = {}
|
||||
pending["packages"] = [p for p in pending.get("packages") or [] if isinstance(p, str)]
|
||||
pending["remove"] = [r for r in pending.get("remove") or [] if isinstance(r, dict)]
|
||||
return pending
|
||||
|
||||
|
||||
def queue_library(packages, remove):
|
||||
# Serialize with the worker and Steam's startup hooks; persist before touching the live VDF.
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
return _queue_library(packages, remove)
|
||||
|
||||
|
||||
def _queue_library(packages, remove):
|
||||
"""Caller holds the shortcut lock."""
|
||||
pending = pending_library()
|
||||
pending["packages"] = sorted(set(pending["packages"]) | set(packages))
|
||||
pending["remove"].extend(remove)
|
||||
_write_json(PENDING_FILE, pending)
|
||||
result = {"state": "pending", **pending, "added": [], "errors": []}
|
||||
for user in library_users():
|
||||
update_library(user, packages, result)
|
||||
_write_json(STATUS_FILE, result)
|
||||
return result
|
||||
|
||||
|
||||
LIBRARY_HOOK_DIR = os.path.join(HOME, ".config/systemd/user/steam.service.d")
|
||||
LIBRARY_HOOK = os.path.join(LIBRARY_HOOK_DIR, "frameport-library.conf")
|
||||
TIMEOUT_BIN = "/usr/bin/timeout" # coreutils; present on SteamOS (checked on the Frame, 2026-10-16)
|
||||
SYSTEMD_RUN_BIN = "/usr/bin/systemd-run"
|
||||
LIBRARY_HOOK_SECONDS = 20 # steam.service's start timeout is 90 s: a stuck hook must never get near it
|
||||
LIBRARY_VERIFY_UNIT = "frameport-library-verify"
|
||||
|
||||
|
||||
def _unit_quote(value):
|
||||
"""One argument of a systemd Exec= line: quoted, with the specifier (%) and variable ($) characters escaped."""
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
|
||||
|
||||
|
||||
def library_hooks_text(python=None, agent=None, timeout_bin=None):
|
||||
"""steam.service drop-in for the `later` mode. Both hooks start with '-' (a failure never fails Steam's start)
|
||||
and run under timeout(1) (systemd's '-' ignores exit codes, not a hook that hangs until the unit's start
|
||||
timeout), so a missing interpreter/agent (exit 127/2) or a hang can't keep Steam from starting. The
|
||||
pre-start hook rewrites shortcuts.vdf before Steam reads it; the post-start check (it waits for Steam and may
|
||||
register devkit entries) runs as its own transient unit, so Steam's start never waits for it."""
|
||||
python = python or sys.executable
|
||||
agent = agent or os.path.abspath(__file__)
|
||||
timeout_bin = TIMEOUT_BIN if timeout_bin is None else timeout_bin
|
||||
wrap = f"{timeout_bin} -k 5 {LIBRARY_HOOK_SECONDS} " if timeout_bin else ""
|
||||
command = _unit_quote(python) + " " + _unit_quote(agent)
|
||||
return ("# Written by FramePort (Steam library: \"later\"); removed again when nothing waits for Steam's start.\n"
|
||||
"[Service]\n"
|
||||
f"ExecStartPre=-{wrap}{command} library_pending\n"
|
||||
f"ExecStartPost=-{wrap}{SYSTEMD_RUN_BIN} --user --collect --quiet --no-block "
|
||||
f"--unit={LIBRARY_VERIFY_UNIT} --property=RuntimeMaxSec=300 {command} library_pending_verify\n")
|
||||
|
||||
|
||||
def ensure_library_hooks():
|
||||
"""SteamOS runs Steam as the user unit steam.service (Type=simple, Restart=always, TimeoutStartSec 90 s).
|
||||
Apply AFTER its exit writes and BEFORE its next start; keep the journal until the post-start verification
|
||||
succeeds, including across reboots/crashes."""
|
||||
text = library_hooks_text(timeout_bin=TIMEOUT_BIN if os.access(TIMEOUT_BIN, os.X_OK) else "")
|
||||
try:
|
||||
if open(LIBRARY_HOOK).read() == text:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(LIBRARY_HOOK_DIR, exist_ok=True)
|
||||
with open(LIBRARY_HOOK + ".tmp", "w") as f:
|
||||
f.write(text)
|
||||
os.replace(LIBRARY_HOOK + ".tmp", LIBRARY_HOOK)
|
||||
if run(["systemctl", "--user", "daemon-reload"]).returncode:
|
||||
raise AgentError("Could not enable Steam library startup hooks")
|
||||
|
||||
|
||||
def remove_library_hooks():
|
||||
"""Remove the steam.service drop-in (mode switched away from `later` with nothing pending, purge).
|
||||
Returns whether there was one."""
|
||||
if not os.path.exists(LIBRARY_HOOK):
|
||||
return False
|
||||
try:
|
||||
os.remove(LIBRARY_HOOK)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
try:
|
||||
os.rmdir(LIBRARY_HOOK_DIR) # only when empty: the user's own drop-ins (extra_args.conf) stay
|
||||
except OSError:
|
||||
pass
|
||||
run(["systemctl", "--user", "daemon-reload"])
|
||||
return True
|
||||
|
||||
|
||||
def drop_library_hooks_if_idle():
|
||||
"""The drop-in only stays while the `later` mode is on or something still waits for Steam's next start."""
|
||||
pending = pending_library()
|
||||
if library_mode() != "later" and not pending["packages"] and not pending["remove"]:
|
||||
return remove_library_hooks()
|
||||
return False
|
||||
|
||||
|
||||
def cmd_library_pending(args):
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return {"worker_active": True} # that worker writes before start and verifies afterward
|
||||
pending = pending_library()
|
||||
result = {"added": [], "errors": []}
|
||||
if not pending["packages"] and not pending["remove"]:
|
||||
return result # every Steam start runs this: leave shortcuts.vdf alone when nothing waits
|
||||
# An uninstall queued after an install wins; never resurrect a deleted deployment.
|
||||
packages = [p for p in pending["packages"] if deployment(p)]
|
||||
for user in steam_users():
|
||||
remove_from_library(user, pending["remove"], result)
|
||||
for user in library_users():
|
||||
update_library(user, packages, result)
|
||||
pending["errors"] = result["errors"]
|
||||
_write_json(PENDING_FILE, pending)
|
||||
return result
|
||||
|
||||
|
||||
def cmd_library_pending_verify(args):
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return {"worker_active": True} # that worker writes before start and verifies afterward
|
||||
pending = pending_library()
|
||||
packages = [p for p in pending["packages"] if deployment(p)]
|
||||
result = {"state": "done", "added": [], "errors": list(pending.get("errors", []))}
|
||||
if not packages and not pending["remove"]:
|
||||
if pending["packages"]: # only uninstalled games were left
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
drop_library_hooks_if_idle()
|
||||
return result
|
||||
for pkg in shortcuts_lost(library_users(), packages):
|
||||
add_devkit_entry(pkg, result)
|
||||
if not result["errors"]:
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
drop_library_hooks_if_idle()
|
||||
_write_json(STATUS_FILE, result)
|
||||
return result
|
||||
|
||||
|
||||
def frameport_running():
|
||||
"""A local GUI heartbeat is refreshed every second; crashed/closed windows expire in ten seconds."""
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local/share"))
|
||||
try:
|
||||
age = time.time() - os.path.getmtime(os.path.join(data, "frameport-app/gui.alive"))
|
||||
return 0 <= age < 10
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def restart_hold():
|
||||
return "game" if game_running() else "desktop" if desktop_mode() else "frameport" if frameport_running() else None
|
||||
|
||||
|
||||
def cmd_restart_steam(args):
|
||||
if game_running() and not args.get("confirm"):
|
||||
return {"confirmation_required": True}
|
||||
status = cmd_shortcut_status({})
|
||||
if status.get("state") == "waiting" and time.time() - float(status.get("started") or 0) < 60:
|
||||
# Wake the worker already holding the lock; a second worker would wait behind the local GUI hold.
|
||||
_write_json(RESTART_REQUEST_FILE, {"requested": time.time()})
|
||||
return {"started": True, "waiting_worker": True}
|
||||
return cmd_shortcuts({"packages": pending_library()["packages"], "restart_now": True,
|
||||
"remove": pending_library()["remove"], "mode": "now"})
|
||||
|
||||
|
||||
def cmd_shortcuts(args):
|
||||
"""Add library entries (+ grid artwork) for installed games. Steam must be closed while shortcuts.vdf is
|
||||
rewritten, so the work runs in a detached systemd unit (terminals/SSH sessions started from Steam live in
|
||||
@@ -1118,18 +1329,27 @@ def cmd_shortcuts(args):
|
||||
packages = [check_pkg(p) for p in args.get("packages", [])]
|
||||
remove = [r for r in args.get("remove", [])
|
||||
if isinstance(r, dict) and str(r.get("exe", "")).startswith('"' + ANCHORS)]
|
||||
if not packages and not remove:
|
||||
mode = library_mode(args)
|
||||
if mode == "off" and not remove:
|
||||
return {"state": "done", "disabled": True, "added": [], "errors": []}
|
||||
if mode == "off":
|
||||
packages = []
|
||||
if mode == "later":
|
||||
ensure_library_hooks()
|
||||
return queue_library(packages, remove)
|
||||
if not packages and not remove and not args.get("restart_now"):
|
||||
return {"started": False}
|
||||
current = cmd_shortcut_status({})
|
||||
alive = time.time() - float(current.get("started") or 0) < 60 # a waiting worker rewrites its status every 10 s
|
||||
if current.get("state") == "waiting" and alive and set(packages) <= set(current.get("packages") or []) \
|
||||
and not remove:
|
||||
if (not args.get("restart_now") and current.get("state") == "waiting" and alive
|
||||
and set(packages) <= set(current.get("packages") or []) and not remove):
|
||||
return {"started": False, "waiting": True} # the update for these games already waits (Gaming Mode / game)
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump({"state": "running", "packages": packages, "started": time.time()}, f)
|
||||
unit = f"frameport-shortcuts-{int(time.time())}"
|
||||
payload = json.dumps({"packages": packages, "remove": remove, "restart": args.get("restart", True)})
|
||||
payload = json.dumps({"packages": packages, "remove": remove, "restart": args.get("restart", True),
|
||||
"restart_now": bool(args.get("restart_now"))})
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", "--setenv=HOME=" + HOME,
|
||||
sys.executable, os.path.abspath(__file__), "_shortcuts_worker", payload])
|
||||
return {"started": True, "unit": unit}
|
||||
@@ -1206,7 +1426,14 @@ def shortcuts_worker(payload):
|
||||
|
||||
os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True)
|
||||
with open(STATUS_FILE + ".lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
if json.loads(payload).get("restart_now"):
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
_write_json(RESTART_REQUEST_FILE, {"requested": time.time()})
|
||||
return # wake an existing hold, rather than queueing another restart behind it
|
||||
else:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
_shortcuts_worker(payload)
|
||||
|
||||
|
||||
@@ -1230,7 +1457,7 @@ def _shortcuts_worker(payload):
|
||||
copy_grid_art(pkg, appid)
|
||||
args["packages"] = [p for p in args["packages"] if p not in devkit]
|
||||
result["devkit"] = devkit
|
||||
if not args.get("remove") and not library_changes(users, args["packages"]):
|
||||
if not args.get("restart_now") and not args.get("remove") and not library_changes(users, args["packages"]):
|
||||
# nothing to change in shortcuts.vdf (e.g. a reinstall): only the artwork, no Steam restart
|
||||
for user in users:
|
||||
update_library(user, args["packages"], result, shortcuts=False)
|
||||
@@ -1240,8 +1467,25 @@ def _shortcuts_worker(payload):
|
||||
json.dump(result, f)
|
||||
return
|
||||
deadline = time.time() + 12 * 3600
|
||||
while time.time() < deadline: # restarting Steam would end the game being played, or Desktop Mode
|
||||
reason = "game" if game_running() else "desktop" if desktop_mode() else None
|
||||
while not args.get("restart_now") and time.time() < deadline:
|
||||
try:
|
||||
requested = json.load(open(RESTART_REQUEST_FILE))["requested"]
|
||||
if 0 <= time.time() - requested < 60:
|
||||
os.remove(RESTART_REQUEST_FILE)
|
||||
break
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
mode = library_mode()
|
||||
if mode == "later":
|
||||
ensure_library_hooks()
|
||||
_queue_library(args["packages"], args.get("remove", []))
|
||||
return
|
||||
if mode == "off" and not args.get("remove"):
|
||||
_write_json(STATUS_FILE, {"state": "done", "disabled": True, "added": [], "errors": []})
|
||||
return
|
||||
if mode == "off":
|
||||
args["packages"] = []
|
||||
reason = restart_hold()
|
||||
if not reason:
|
||||
break
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
@@ -1252,6 +1496,9 @@ def _shortcuts_worker(payload):
|
||||
service = stop_steam()
|
||||
except AgentError:
|
||||
raise AgentError("Steam did not close; library not modified") from None
|
||||
pending = pending_library()
|
||||
args["packages"] = sorted(set(args["packages"]) | {p for p in pending["packages"] if deployment(p)})
|
||||
args.setdefault("remove", []).extend(pending["remove"])
|
||||
for user in steam_users(): # uninstalled games leave every account's library
|
||||
remove_from_library(user, args.get("remove", []), result)
|
||||
for user in users:
|
||||
@@ -1272,6 +1519,13 @@ def _shortcuts_worker(payload):
|
||||
result["state"] = "failed"
|
||||
result["errors"].append(str(exc))
|
||||
run(["systemctl", "--user", "start", "steam.service"])
|
||||
if result["state"] == "done" and not result["errors"]:
|
||||
_write_json(PENDING_FILE, {"packages": [], "remove": []})
|
||||
drop_library_hooks_if_idle()
|
||||
try:
|
||||
os.remove(RESTART_REQUEST_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
result["finished"] = time.time()
|
||||
with open(STATUS_FILE, "w") as f:
|
||||
json.dump(result, f)
|
||||
@@ -1783,6 +2037,18 @@ def cmd_launch(args):
|
||||
check_base_present(dep["base"], dep.get("title") or pkg)
|
||||
if run(["pgrep", "-x", "steam"]).returncode != 0:
|
||||
raise AgentError("Steam isn't running on the Frame")
|
||||
mode = library_mode(args)
|
||||
if mode == "off" or (mode == "later" and pkg in pending_library()["packages"]):
|
||||
# Detached from SSH/FramePort. The launcher keeps Lepton/Proton's environment; SteamVR must be running.
|
||||
unit = f"frameport-play-{dep['appid']}"
|
||||
if game_is_running(dep) or run(["systemctl", "--user", "is-active", "--quiet", unit]).returncode == 0:
|
||||
raise AgentError("the game is already running") # (or its launcher is still starting it)
|
||||
run(["systemctl", "--user", "reset-failed", unit]) # a failed earlier start would block the unit name
|
||||
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}",
|
||||
os.path.join(ANCHORS, pkg, "launch.sh")])
|
||||
if p.returncode:
|
||||
raise AgentError("could not start the launcher: " + p.stderr[-300:])
|
||||
return {"package": pkg, "title": dep.get("title"), "via": "direct"}
|
||||
devkit = devkit_gameid(pkg)
|
||||
appid = devkit_appid(devkit) if devkit else None # this Frame needed the fallback entry before: use it
|
||||
if devkit and not appid:
|
||||
@@ -6556,6 +6822,11 @@ def purge_worker(payload):
|
||||
result["removed"].append(VR_OVERLAY_UNIT_PATH)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"overlay autostart service: {exc}")
|
||||
try:
|
||||
if remove_library_hooks():
|
||||
result["removed"].append(LIBRARY_HOOK)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"Steam library hook: {exc}")
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError as exc:
|
||||
@@ -7952,6 +8223,8 @@ def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in COMMANDS:
|
||||
print(json.dumps({"ok": False, "error": f"usage: frameport_agent.py <{'|'.join(sorted(COMMANDS))}>"}))
|
||||
return 2
|
||||
if sys.argv[1] == "library_pending" and hasattr(signal, "alarm"):
|
||||
signal.alarm(LIBRARY_HOOK_SECONDS + 5) # Steam's ExecStartPre: own deadline too, should timeout(1) be missing
|
||||
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
||||
try:
|
||||
args = json.loads(raw) if raw.strip() else {}
|
||||
|
||||
@@ -421,3 +421,33 @@ sighting, command lines checked once per process, render fds cached (rescanned e
|
||||
game processes without one), temperatures every 2 s, the CPU-cluster power rails every 5 s and the battery gauge every
|
||||
2-10 s, the stream measured ~10-13 ms CPU per 1 s tick on the device (idle clocks; idle or with 4XVR running) = about
|
||||
1 % of one core, ~0.15 % of the whole CPU. The I2C sensors (power monitors, battery gauge) are the slowest reads.
|
||||
|
||||
## Steam library scheduling
|
||||
|
||||
Agent v92 holds automatic shortcut restarts while the local app's
|
||||
`~/.local/share/frameport-app/gui.alive` heartbeat is under ten seconds old, as well as while a game or Desktop Mode
|
||||
is open. Closed/crashed GUIs expire; the existing twelve-hour hold limit still applies.
|
||||
|
||||
`library_mode` accepts `now` (default), `later`, and `off`; FramePort stores its preference as
|
||||
`install.steam_library`. Later writes shortcuts and journals packages/removals in
|
||||
`~/.local/share/frameport/library-pending.json`. Steam rewrites shortcuts.vdf on exit, so a user drop-in
|
||||
`~/.config/systemd/user/steam.service.d/frameport-library.conf` reapplies the journal in ExecStartPre, after the
|
||||
previous session has exited. ExecStartPost verifies the entries and uses the existing live devkit fallback if Steam
|
||||
removed them. The journal is cleared only after success, and survives agent/app exit and reboot. The hooks share the
|
||||
shortcut worker lock; an active worker performs its own write/verification instead. Both hook lines start with
|
||||
`-` and run under `/usr/bin/timeout -k 5 20` (present on SteamOS; `-` ignores exit codes, including 127 for a missing
|
||||
interpreter or agent, but not a hook that hangs until the unit's 90 s start timeout); ExecStartPre also has its own
|
||||
SIGALRM deadline. ExecStartPost only starts the verification as the transient unit `frameport-library-verify`
|
||||
(`--no-block`, RuntimeMaxSec 300), so Steam's start never waits for its Steam/devkit waits. The pre hook leaves
|
||||
shortcuts.vdf alone when nothing is pending. The drop-in is removed (and the user manager reloaded) as soon as the
|
||||
mode isn't `later` and nothing is pending, and by purge; an otherwise empty `steam.service.d` goes with it.
|
||||
SteamOS 0.3.0 facts (checked 2026-10-16): Steam is the static user unit `/usr/lib/systemd/user/steam.service`
|
||||
(Type=simple, Restart=always, TimeoutStartSec 90 s, its own `-` ExecStartPre lines; `extra_args.conf` drop-ins are
|
||||
Valve's documented way to extend it), systemd 257; no user drop-ins exist by default.
|
||||
This automatic application targets SteamOS's `steam.service`; if Steam is started outside that service, use
|
||||
FramePort's Restart Steam now action to apply the journal safely.
|
||||
|
||||
Off skips new shortcuts (existing entries remain). Play in Off, and Play for a pending Later entry, starts launch.sh
|
||||
in a detached user systemd unit, just as launch tests do, without adding a devkit entry. Steam/SteamVR must already
|
||||
be running. This loses Steam's per-game overlay/controller setup and Exit game integration; use FramePort Monitor
|
||||
to stop the game. This direct path has unit coverage; headset input/visual behaviour needs device validation.
|
||||
+18
-2
@@ -86,8 +86,24 @@ FramePort can run on the Frame itself, without a PC:
|
||||
2. In Desktop Mode, download `FramePort-linux-arm64.tar.gz`, unpack it (`tar xzf FramePort-linux-arm64.tar.gz`) and
|
||||
start `FramePort/FramePort`.
|
||||
|
||||
Games appear in the Steam library when you go back to Gaming Mode. Please report anything odd with **Report a
|
||||
problem…**.
|
||||
With the default **Steam library: Now**, games appear after you close FramePort and return to Gaming Mode.
|
||||
FramePort also waits while its window is open in Gaming Mode, so you can install several games in one session.
|
||||
Please report anything odd with **Report a problem…**.
|
||||
|
||||
Settings → Installing → **Steam library** controls future installs:
|
||||
|
||||
- **Now** (default): restart Steam after adding shortcuts, waiting while a game, Desktop Mode, or FramePort's local
|
||||
window is open (up to 12 hours). Unchanged shortcuts need no restart.
|
||||
- **Later**: save shortcuts without restarting Steam. Keep installing; changes are applied at the next Steam
|
||||
service start, including after a reboot. **Restart Steam now** applies them immediately. It closes Desktop Mode
|
||||
and FramePort running on the Frame, and asks again if a game is running.
|
||||
- **Off**: skip new Steam shortcuts; existing entries stay. FramePort's **Play** runs the installed launcher directly.
|
||||
Steam and SteamVR must already be running. Steam's per-game overlay, controller setup, and Exit game integration
|
||||
are unavailable; stop the game from FramePort's Monitor. Pending games in Later also use this direct Play path.
|
||||
|
||||
CLI: `frameport frame steam-library now|later|off` saves the setting and applies it to the connected Frame.
|
||||
`frameport frame restart-steam` applies pending entries now; `--yes` confirms ending a running game.
|
||||
These settings concern game shortcuts; installing a missing Steam runtime such as Proton can still require a restart.
|
||||
|
||||
## Installing games
|
||||
|
||||
|
||||
@@ -709,6 +709,31 @@ def frame_move(package: str = typer.Argument(..., help="an installed game"),
|
||||
typer.echo(f"moved to {st.get('base') or st.get('to')}")
|
||||
|
||||
|
||||
@frame_app.command("steam-library")
|
||||
def frame_steam_library(mode: str = typer.Argument(..., help="now, later or off"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Save Steam shortcut behaviour for future installs and Play, and apply it on the Frame."""
|
||||
from .core import library
|
||||
|
||||
if mode not in ("now", "later", "off"):
|
||||
raise typer.BadParameter("choose now, later or off")
|
||||
_target(frame).connect().frame.agent("library_mode", mode=mode)
|
||||
library.set_setting("install.steam_library", mode)
|
||||
typer.echo(mode)
|
||||
|
||||
|
||||
@frame_app.command("restart-steam")
|
||||
def frame_restart_steam(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
yes: bool = typer.Option(False, "--yes", help="confirm ending a running game")):
|
||||
"""Restart Steam now and apply pending library changes. Closes local FramePort/Desktop Mode too."""
|
||||
agent = _target(frame).connect().frame
|
||||
result = agent.agent("restart_steam", confirm=yes)
|
||||
if result.get("confirmation_required"):
|
||||
typer.confirm("A game is running. End it and restart Steam?", abort=True)
|
||||
result = agent.agent("restart_steam", confirm=True)
|
||||
typer.echo(json.dumps(result))
|
||||
|
||||
|
||||
@frame_app.command("info")
|
||||
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
|
||||
|
||||
@@ -817,7 +817,15 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
|
||||
import time
|
||||
|
||||
reporter.stage("Add to Steam library")
|
||||
frame.agent("shortcuts", packages=packages, restart=True)
|
||||
from ..core import library
|
||||
|
||||
mode = library.setting("install.steam_library", "now")
|
||||
frame.agent("library_mode", mode=mode)
|
||||
submitted = frame.agent("shortcuts", packages=packages, restart=True, mode=mode)
|
||||
if submitted.get("disabled") or submitted.get("state") == "pending":
|
||||
reporter.check("Steam library", None, "disabled; use FramePort Play" if mode == "off" else
|
||||
"queued for the next Steam start; you can keep installing")
|
||||
return submitted
|
||||
end = time.time() + wait
|
||||
status = {}
|
||||
while time.time() < end:
|
||||
@@ -830,6 +838,8 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
|
||||
break
|
||||
if status.get("state") == "waiting": # Steam restarts later: after the game, or back in Gaming Mode
|
||||
reporter.check("Steam library", None,
|
||||
"added after FramePort on the Frame closes (Steam has to restart for it)"
|
||||
if status.get("reason") == "frameport" else
|
||||
"added when you're back in Gaming Mode (Steam has to restart for it)"
|
||||
if status.get("reason") == "desktop" else
|
||||
"added when the game that's running now is closed (Steam has to restart for it)")
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"920 × 430": "",
|
||||
"96 Hz": "",
|
||||
"A folder with Quest, Android or PC VR games.": "",
|
||||
"A game is running": "",
|
||||
"A game is running on the Frame": "",
|
||||
"A game is running on the Frame. Close it in the headset first, then try again.": "",
|
||||
"a known blocker": "",
|
||||
@@ -276,6 +277,7 @@
|
||||
"Connect by address…": "",
|
||||
"Connect Frames in Developer Mode automatically": "",
|
||||
"Connect the Frame's USB-C port to this PC with a data cable (not a charge-only one).": "",
|
||||
"Connect to the Frame first.": "",
|
||||
"Connect to your Steam Frame to browse its videos, downloads and each game's storage.": "",
|
||||
"Connect to your Steam Frame to see its screenshots here.": "",
|
||||
"Connect to your Steam Frame to stream its view and sound to this PC.": "",
|
||||
@@ -804,6 +806,7 @@
|
||||
"Last launch test: {get} · got as far as: {value}": "",
|
||||
"Last session · ended {ago}": "",
|
||||
"late": "",
|
||||
"Later — next Steam start": "",
|
||||
"Launch log": "",
|
||||
"Launch log · {title}": "",
|
||||
"Launch options (SteamVR or OpenXR)": "",
|
||||
@@ -1000,6 +1003,9 @@
|
||||
"Nothing to patch: it runs unchanged.": "",
|
||||
"Nothing to see here.": "",
|
||||
"nothing yet": "",
|
||||
"Now (default)": "",
|
||||
"Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.": "",
|
||||
"Now waits while a game, Desktop Mode, or local FramePort is open. Later lets you keep installing without restarting Steam. Off uses FramePort Play without Steam per-game integration.": "",
|
||||
"Now {scale:.0%}.": "",
|
||||
"Now {scale:.0%}. Changes apply the next time FramePort starts.": "",
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
@@ -1011,6 +1017,7 @@
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or gray (for example I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
"Off": "",
|
||||
"Off — launch from FramePort": "",
|
||||
"Off: these links open whatever app is set up for them.": "",
|
||||
"off: {names}": "",
|
||||
"Offline": "",
|
||||
@@ -1270,10 +1277,13 @@
|
||||
"Reset {title}'s recipe?": "",
|
||||
"Restart": "",
|
||||
"Restart now": "",
|
||||
"Restart Steam now": "",
|
||||
"Restart Steam now?": "",
|
||||
"Restart the Frame": "",
|
||||
"Restart the Frame?": "",
|
||||
"Restart the Frame…": "",
|
||||
"Restarting": "",
|
||||
"Restarting Steam ends the running game. Continue?": "",
|
||||
"Resume": "",
|
||||
"Resuming — copied files are skipped": "",
|
||||
"Retry refused picture formats": "",
|
||||
@@ -1461,6 +1471,8 @@
|
||||
"Steam Frame patches": "",
|
||||
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
|
||||
"Steam game": "",
|
||||
"Steam is restarting. FramePort on the Frame will close.": "",
|
||||
"Steam library": "",
|
||||
"Steam on the Frame downloads Proton (about 1 GiB). Steam restarts once, which closes a running game.": "",
|
||||
"Steam on the Frame isn't responding. Restart the Frame and try again.": "",
|
||||
"SteamOS · Developer Mode": "",
|
||||
@@ -1601,6 +1613,7 @@
|
||||
"They run on the Frame too.": "",
|
||||
"They're deleted on the Frame for good. Steam's own list may show them until Steam restarts.": "",
|
||||
"They're part of SteamOS. Ending them may restart Steam and stop the running game.": "",
|
||||
"This applies pending library changes and closes Desktop Mode and FramePort on the Frame.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder contains an installer without a launchable Linux program. FramePort runs programs, not installers. Use the project's own install instructions.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
|
||||
@@ -71,7 +71,10 @@ class FrameLeptonTarget(Target):
|
||||
return self.connect().frame.agent("collect_diag", timeout=180, package=package)
|
||||
|
||||
def launch(self, package):
|
||||
return self.connect().frame.agent("launch", package=package, timeout=60)
|
||||
from ..core import library
|
||||
|
||||
return self.connect().frame.agent("launch", package=package, timeout=60,
|
||||
mode=library.setting("install.steam_library", "now"))
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
from ..core import library
|
||||
|
||||
@@ -19,6 +19,8 @@ class _Translated(dict):
|
||||
|
||||
|
||||
HELP: dict[str, str] = _Translated({
|
||||
"steam_library": "Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. "
|
||||
"Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.",
|
||||
# ---- game status / library
|
||||
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" were tested on a Frame; "
|
||||
"\"Untested\" is a suggestion nobody has confirmed yet. \"Can't run\" means a known blocker.",
|
||||
|
||||
@@ -96,6 +96,35 @@ class SettingsView:
|
||||
from ...core import library
|
||||
from ...install.installer import upload_threads
|
||||
|
||||
def steam_changed(e):
|
||||
mode = e.control.value
|
||||
library.set_setting("install.steam_library", mode)
|
||||
if self.app.frame_state == "connected":
|
||||
self.app.run_bg(lambda: self.app.target.connect().frame.agent("library_mode", mode=mode))
|
||||
|
||||
def restart(e):
|
||||
app = self.app
|
||||
if app.frame_state != "connected":
|
||||
app.toast(tr("Connect to the Frame first."))
|
||||
return
|
||||
def work(confirm=False):
|
||||
result = app.target.connect().frame.agent("restart_steam", confirm=confirm)
|
||||
if result.get("confirmation_required"):
|
||||
C.confirm(app.page, tr("A game is running"),
|
||||
tr("Restarting Steam ends the running game. Continue?"), tr("Restart Steam now"),
|
||||
lambda: app.run_bg(lambda: work(True)), danger=True)
|
||||
else:
|
||||
app.toast(tr("Steam is restarting. FramePort on the Frame will close."))
|
||||
C.confirm(app.page, tr("Restart Steam now?"),
|
||||
tr("This applies pending library changes and closes Desktop Mode and FramePort on the Frame."),
|
||||
tr("Restart Steam now"), lambda: app.run_bg(work))
|
||||
|
||||
steam = ft.Row([C.dropdown(label=tr("Steam library"),
|
||||
value=library.setting("install.steam_library", "now"), width=T.px(360),
|
||||
options=[ft.DropdownOption("now", tr("Now (default)")),
|
||||
ft.DropdownOption("later", tr("Later — next Steam start")),
|
||||
ft.DropdownOption("off", tr("Off — launch from FramePort"))],
|
||||
on_select=steam_changed), C.help_icon("steam_library")], spacing=T.S1)
|
||||
def changed(e):
|
||||
library.set_setting("install.launch_test", bool(e.control.value))
|
||||
def keep_changed(e):
|
||||
@@ -127,7 +156,11 @@ class SettingsView:
|
||||
parallel = C.dropdown(label=tr("Parallel uploads"), value=str(upload_threads()), width=T.px(240),
|
||||
options=[ft.DropdownOption(str(n), str(n)) for n in range(1, 9)],
|
||||
on_select=threads_changed)
|
||||
return ft.Column([launch, keep, video, parallel,
|
||||
return ft.Column([steam, C.meta(tr("Now waits while a game, Desktop Mode, or local FramePort is open. "
|
||||
"Later lets you keep installing without restarting Steam. "
|
||||
"Off uses FramePort Play without Steam per-game integration.")),
|
||||
C.ghost(tr("Restart Steam now"), ft.Icons.RESTART_ALT, restart),
|
||||
launch, keep, video, parallel,
|
||||
C.meta(tr("Large files upload together using OpenSSH when available. "
|
||||
"Default: 3. Lower this on a busy network.")),
|
||||
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
|
||||
|
||||
@@ -2081,3 +2081,229 @@ def test_launcher_starts_lepton_again_once_when_the_game_restarts_itself(monkeyp
|
||||
(tmp_path / "starts").unlink()
|
||||
subprocess.run([str(launcher)], timeout=30, env={**env, "FRAMEPORT_NO_RESTART": "1"}, start_new_session=True)
|
||||
assert (tmp_path / "starts").read_text().count("started") == 1
|
||||
|
||||
|
||||
def test_local_frameport_hold_expires(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
||||
monkeypatch.setattr(a, "game_running", lambda: False)
|
||||
monkeypatch.setattr(a, "desktop_mode", lambda: False)
|
||||
assert a.restart_hold() is None
|
||||
heartbeat = tmp_path / ".local/share/frameport-app/gui.alive"
|
||||
heartbeat.parent.mkdir(parents=True)
|
||||
heartbeat.touch()
|
||||
assert a.restart_hold() == "frameport"
|
||||
os.utime(heartbeat, (a.time.time() - 11, a.time.time() - 11))
|
||||
assert a.restart_hold() is None
|
||||
monkeypatch.setattr(a, "game_running", lambda: True)
|
||||
assert a.restart_hold() == "game"
|
||||
|
||||
|
||||
def test_library_later_survives_steam_exit_and_next_start(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
pkg = "com.x.y"
|
||||
anchor = Path(a.ANCHORS) / pkg
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"title": "X", "appid": 123, "base": str(anchor)}))
|
||||
cfg = Path(a.STEAM) / "userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "run", lambda *args, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""))
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: pytest.fail("later stopped Steam"))
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: pytest.fail("later started Steam"))
|
||||
a.cmd_library_mode({"mode": "later"})
|
||||
assert a.cmd_shortcuts({"packages": [pkg]})["state"] == "pending"
|
||||
hook = tmp_path / ".config/systemd/user/steam.service.d/frameport-library.conf"
|
||||
assert "ExecStartPre=" in hook.read_text() and "ExecStartPost=" in hook.read_text()
|
||||
vdf = cfg / "shortcuts.vdf"
|
||||
assert pkg in a.pending_library()["packages"]
|
||||
vdf.write_bytes(a.vdf_encode({"shortcuts": {}})) # Steam saved its old in-memory copy at exit
|
||||
a.cmd_library_pending({})
|
||||
assert any(e["appname"] == "X" for e in a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
||||
monkeypatch.setattr(a.time, "sleep", lambda _: None)
|
||||
a.cmd_library_pending_verify({})
|
||||
assert not a.pending_library()["packages"]
|
||||
|
||||
|
||||
def test_pending_retained_when_live_repair_fails(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X"})
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: ["com.x.y"])
|
||||
monkeypatch.setattr(a, "devkit_register", lambda _: (_ for _ in ()).throw(a.AgentError("test")))
|
||||
assert a.cmd_library_pending_verify({})["errors"]
|
||||
assert a.pending_library()["packages"] == ["com.x.y"]
|
||||
|
||||
|
||||
def test_off_skips_shortcuts_and_play_uses_direct_launcher(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a.cmd_library_mode({"mode": "off"})
|
||||
assert a.library_mode() == "off"
|
||||
assert a.cmd_shortcuts({"packages": ["com.x.y"]})["disabled"]
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X", "appid": 123})
|
||||
monkeypatch.setattr(a, "game_is_running", lambda _: False)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd)
|
||||
or SimpleNamespace(returncode=3 if "is-active" in cmd else 0, stderr=""))
|
||||
monkeypatch.setattr(a, "devkit_register", lambda _: pytest.fail("off registered a shortcut"))
|
||||
result = a.cmd_launch({"package": "com.x.y"})
|
||||
assert result["via"] == "direct"
|
||||
assert calls[-1][0] == "systemd-run" and calls[-1][-1].endswith("com.x.y/launch.sh")
|
||||
assert not any("rungameid" in str(c) for c in calls)
|
||||
|
||||
|
||||
def test_manual_restart_requires_game_confirmation(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "game_running", lambda: True)
|
||||
assert a.cmd_restart_steam({}) == {"confirmation_required": True}
|
||||
monkeypatch.setattr(a, "cmd_shortcuts", lambda args: args)
|
||||
assert a.cmd_restart_steam({"confirm": True})["restart_now"]
|
||||
|
||||
|
||||
def test_startup_hook_does_not_deadlock_active_worker(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
Path(a.STATUS_FILE).parent.mkdir(parents=True)
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
assert a.cmd_library_pending({})["worker_active"]
|
||||
assert a.cmd_library_pending_verify({})["worker_active"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hold", [None, "frameport"])
|
||||
def test_shortcut_worker_holds_local_gui_until_closed(monkeypatch, tmp_path, hold):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
clock = [100.0]
|
||||
events = []
|
||||
monkeypatch.setattr(a.time, "time", lambda: clock[0])
|
||||
def sleep(seconds):
|
||||
status = a.cmd_shortcut_status({})
|
||||
events.append(status.get("reason"))
|
||||
clock[0] += seconds
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: None)
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
monkeypatch.setattr(a, "refresh_desktop_entries", lambda: None)
|
||||
monkeypatch.setattr(a, "library_users", lambda: [])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: [])
|
||||
monkeypatch.setattr(a, "library_changes", lambda *args: True)
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: hold)
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: events.append("stop") or True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: events.append("start"))
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
a.shortcuts_worker(json.dumps({"packages": [], "remove": []}))
|
||||
assert events == (["frameport"] if hold else []) + ["stop", "start"]
|
||||
assert a.cmd_shortcut_status({})["state"] == "done"
|
||||
|
||||
|
||||
def test_pending_later_play_never_registers_shortcut(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"appid": 123, "title": "X"})
|
||||
monkeypatch.setattr(a, "game_is_running", lambda _: False)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(returncode=3 if "is-active" in cmd else 0,
|
||||
stderr=""))
|
||||
monkeypatch.setattr(a, "devkit_register", lambda _: pytest.fail("pending Play added a devkit shortcut"))
|
||||
assert a.cmd_launch({"package": "com.x.y", "mode": "later"})["via"] == "direct"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["restart", "later", "off", "cap"])
|
||||
def test_waiting_worker_can_be_released_without_deadlock(monkeypatch, tmp_path, action):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
clock = [100.0]
|
||||
stopped = []
|
||||
monkeypatch.setattr(a.time, "time", lambda: clock[0])
|
||||
monkeypatch.setattr(a, "refresh_desktop_entries", lambda: None)
|
||||
monkeypatch.setattr(a, "library_users", lambda: [])
|
||||
monkeypatch.setattr(a, "steam_users", lambda: [])
|
||||
monkeypatch.setattr(a, "library_changes", lambda *args: True)
|
||||
monkeypatch.setattr(a, "restart_hold", lambda: "frameport")
|
||||
monkeypatch.setattr(a, "game_running", lambda: False)
|
||||
monkeypatch.setattr(a, "ensure_library_hooks", lambda: None)
|
||||
monkeypatch.setattr(a, "stop_steam", lambda: stopped.append(True) or True)
|
||||
monkeypatch.setattr(a, "start_steam", lambda _: None)
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
def sleep(seconds):
|
||||
if action == "restart":
|
||||
assert a.cmd_restart_steam({})["waiting_worker"]
|
||||
elif action in ("later", "off"):
|
||||
a._write_json(a.LIBRARY_MODE_FILE, {"mode": action})
|
||||
clock[0] += 12 * 3600 if action == "cap" else seconds
|
||||
monkeypatch.setattr(a.time, "sleep", sleep)
|
||||
a.shortcuts_worker(json.dumps({"packages": [], "remove": []}))
|
||||
assert bool(stopped) == (action in ("restart", "cap"))
|
||||
status = a.cmd_shortcut_status({})
|
||||
assert status["state"] == ("pending" if action == "later" else "done")
|
||||
|
||||
|
||||
def test_manual_restart_worker_wakes_existing_lock_holder(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
Path(a.STATUS_FILE).parent.mkdir(parents=True)
|
||||
with open(a.STATUS_FILE + ".lock", "w") as lock:
|
||||
a.fcntl.flock(lock, a.fcntl.LOCK_EX)
|
||||
a.shortcuts_worker(json.dumps({"restart_now": True, "packages": []}))
|
||||
assert json.load(open(a.RESTART_REQUEST_FILE))["requested"] > 0
|
||||
|
||||
|
||||
def test_library_hook_is_bounded_and_never_fails_steam(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
text = a.library_hooks_text(python="/usr/bin/python3", agent="/home/u/100% $HOME/frameport_agent.py",
|
||||
timeout_bin="/usr/bin/timeout")
|
||||
lines = [line for line in text.splitlines() if line.startswith("Exec")]
|
||||
assert len(lines) == 2
|
||||
pre, post = lines
|
||||
# '-' first (a non-zero exit, also 127 for a missing interpreter, never fails Steam), then timeout(1)
|
||||
assert pre.startswith("ExecStartPre=-/usr/bin/timeout -k 5 20 ") and pre.endswith(" library_pending")
|
||||
assert post.startswith("ExecStartPost=-/usr/bin/timeout -k 5 20 /usr/bin/systemd-run --user ")
|
||||
assert "--no-block" in post and "RuntimeMaxSec=" in post and post.endswith(" library_pending_verify")
|
||||
assert '"/home/u/100%% $$HOME/frameport_agent.py"' in pre # systemd specifiers/variables escaped
|
||||
assert "[Service]" in text
|
||||
assert "timeout" not in a.library_hooks_text(timeout_bin="") # internal alarm then
|
||||
|
||||
|
||||
def test_library_hook_written_once_and_removed_when_idle(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd) or SimpleNamespace(returncode=0))
|
||||
hook = tmp_path / ".config/systemd/user/steam.service.d/frameport-library.conf"
|
||||
a.cmd_library_mode({"mode": "later"})
|
||||
a.ensure_library_hooks()
|
||||
a.ensure_library_hooks()
|
||||
assert hook.exists()
|
||||
assert calls.count(["systemctl", "--user", "daemon-reload"]) == 1 # unchanged drop-in: no reload
|
||||
# back to "now" with a game still waiting for Steam's start: the hook stays until it is applied
|
||||
a._write_json(a.PENDING_FILE, {"packages": ["com.x.y"], "remove": []})
|
||||
a.cmd_library_mode({"mode": "now"})
|
||||
assert hook.exists()
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X"})
|
||||
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
||||
monkeypatch.setattr(a, "shortcuts_lost", lambda *args: [])
|
||||
a.cmd_library_pending_verify({})
|
||||
assert not hook.exists() and not hook.parent.exists()
|
||||
assert calls.count(["systemctl", "--user", "daemon-reload"]) == 2
|
||||
# nothing pending: switching away from "later" removes it at once, the user's own drop-ins stay
|
||||
a.cmd_library_mode({"mode": "later"})
|
||||
a.ensure_library_hooks()
|
||||
(hook.parent / "extra_args.conf").write_text("[Service]\n")
|
||||
a.cmd_library_mode({"mode": "off"})
|
||||
assert not hook.exists() and (hook.parent / "extra_args.conf").exists()
|
||||
|
||||
|
||||
def test_library_pre_hook_leaves_vdf_alone_when_nothing_waits(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "library_users", lambda: pytest.fail("touched the library"))
|
||||
monkeypatch.setattr(a, "steam_users", lambda: pytest.fail("touched the library"))
|
||||
assert a.cmd_library_pending({}) == {"added": [], "errors": []}
|
||||
Path(a.PENDING_FILE).write_text("[]") # damaged journal: treated as empty, never a crash in Steam's start
|
||||
assert a.cmd_library_pending({}) == {"added": [], "errors": []}
|
||||
|
||||
|
||||
def test_off_play_refuses_while_launcher_unit_starts(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
a.cmd_library_mode({"mode": "off"})
|
||||
monkeypatch.setattr(a, "deployment", lambda _: {"title": "X", "appid": 123})
|
||||
monkeypatch.setattr(a, "game_is_running", lambda _: False)
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: calls.append(cmd) or SimpleNamespace(returncode=0, stderr=""))
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_launch({"package": "com.x.y"})
|
||||
assert not any(c[0] == "systemd-run" for c in calls)
|
||||
@@ -19,3 +19,41 @@ def test_errors_are_one_line_on_stderr(monkeypatch, capsys):
|
||||
monkeypatch.setenv("FRAMEPORT_DEBUG", "1")
|
||||
with pytest.raises(NoValidConnectionsError):
|
||||
cli.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["now", "later", "off"])
|
||||
def test_steam_library_setting_cli(monkeypatch, mode):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
from frameport.core import library
|
||||
|
||||
calls = []
|
||||
frame = SimpleNamespace(agent=lambda command, **kw: calls.append((command, kw)) or {})
|
||||
monkeypatch.setattr(cli, "_target", lambda _: SimpleNamespace(connect=lambda: SimpleNamespace(frame=frame)))
|
||||
result = CliRunner().invoke(cli.app, ["frame", "steam-library", mode])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert library.setting("install.steam_library") == mode
|
||||
assert calls == [("library_mode", {"mode": mode})]
|
||||
|
||||
|
||||
def test_restart_steam_cli_confirmation(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
calls = []
|
||||
def agent(command, **kw):
|
||||
calls.append(kw)
|
||||
return {} if kw.get("confirm") else {"confirmation_required": True}
|
||||
frame = SimpleNamespace(agent=agent)
|
||||
monkeypatch.setattr(cli, "_target", lambda _: SimpleNamespace(connect=lambda: SimpleNamespace(frame=frame)))
|
||||
result = CliRunner().invoke(cli.app, ["frame", "restart-steam"], input="n\n")
|
||||
assert result.exit_code != 0 and calls == [{"confirm": False}]
|
||||
calls.clear()
|
||||
result = CliRunner().invoke(cli.app, ["frame", "restart-steam"], input="y\n")
|
||||
assert result.exit_code == 0 and calls == [{"confirm": False}, {"confirm": True}]
|
||||
Reference in new issue
Block a user