Compare commits

...
Author SHA1 Message Date
spoopyghosty0 cf05ce2bbd Steam library hooks: bounded, detached verify, removed when idle (#305)
Both steam.service drop-in lines keep '-' and run under timeout(1) (-k 5 20);
library_pending also has its own SIGALRM deadline. ExecStartPost only starts
the verification as the transient unit frameport-library-verify (--no-block,
RuntimeMaxSec 300): its Steam and devkit waits no longer hold steam.service in
'activating'. The pre hook leaves shortcuts.vdf alone when nothing is pending,
a damaged journal reads as empty, '$' is escaped in Exec lines, the drop-in is
only rewritten (and the manager reloaded) when it changes, and it is removed
once the mode isn't 'later' and nothing is pending (also on purge). Off-mode
Play resets a failed play unit and refuses while its launcher still starts.
2026-10-16 00:27:54 -04:00
spoopyghosty0 a311498bca Add configurable Steam library scheduling 2026-10-16 00:21:04 -04:00
12 changed files with 695 additions and 14 deletions

No files matched your search

+12
View File
@@ -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
View File
@@ -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 {}
+30
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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)."""
+11 -1
View File
@@ -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)")
+13
View File
@@ -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.": "",
+4 -1
View File
@@ -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
+2
View File
@@ -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.",
+34 -1
View File
@@ -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,
+226
View File
@@ -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)
+38
View File
@@ -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}]