Install queue waits for the Frame when it drops off the network (job back at the front, queue paused, reconnect every 10 s, continues where uploads stopped); wake lock on the Frame while jobs use it (agent v30 keep_awake: idle+sleep inhibitor, idle-only fallback, expires by itself); free-space check before queueing a batch

This commit is contained in:
spoopyghosty0 committed 2026-10-02 18:23:06 -04:00
1 parent 6e0bb8ee59
commit 2fecfbf6ea
8 files changed
+273 -11

No files matched your search

+24 -1
View File
@@ -33,7 +33,7 @@ import sys
import time
import zlib
AGENT_VERSION = 29
AGENT_VERSION = 30
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1821,6 +1821,29 @@ def cmd_finalize_pcvr(args):
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "proton": tool["name"]}
AWAKE_UNIT = "frameport-awake"
def cmd_keep_awake(args):
"""Keep the Frame from going idle/asleep while FramePort installs games (on=False ends it). An inhibitor lock held
by a `sleep` in its own user unit, so it outlives this SSH command and ends by itself after `minutes` if FramePort
disappears. Blocking sleep needs a local session (polkit inhibit-block-sleep: auth_admin_keep for others), so
when logind refuses it, idle alone is blocked (allowed for any user)."""
run(["systemctl", "--user", "stop", f"{AWAKE_UNIT}.service"])
run(["systemctl", "--user", "reset-failed", f"{AWAKE_UNIT}.service"])
if not args.get("on", True):
return {"awake": False}
seconds = int(min(max(float(args.get("minutes", 60)), 1), 240) * 60)
for what in ("idle:sleep", "idle"):
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={AWAKE_UNIT}", "systemd-inhibit",
f"--what={what}", "--who=FramePort", "--why=Installing games", "--mode=block", "sleep", str(seconds)])
time.sleep(0.7) # a refused lock ends the unit right away
if run(["systemctl", "--user", "is-active", f"{AWAKE_UNIT}.service"]).stdout.strip() == "active":
return {"awake": True, "what": what, "seconds": seconds}
run(["systemctl", "--user", "reset-failed", f"{AWAKE_UNIT}.service"])
return {"awake": False}
def cmd_set_settings(args):
pkg = check_pkg(args["package"])
dep = deployment(pkg) or {}
+8
View File
@@ -368,6 +368,7 @@
"Installing": "",
"Installs the game exactly as it is: no patches (your copy is already patched)": "",
"into {value}": "",
"It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort continues by itself, and uploads pick up where they stopped.": "",
"It has a known blocker on the Steam Frame.": "",
"it stopped at '{value}'": "",
"It turns on SSH, trusts this app, makes the Frame findable on your network and installs Lepton if needed. You only do this once.": "",
@@ -448,6 +449,7 @@
"No Unreal crash reporter": "",
"None found": "",
"Normal": "",
"Not enough space on the Frame": "",
"Not enough space on the Frame ({detail}). Remove games you don't play or use Free up space on the Steam Frame page.": "",
"Not installed": "",
"Not offered by Steam on this Frame yet": "",
@@ -504,6 +506,7 @@
"Patches every game of this kind gets (on by default). Customize lists them all.": "",
"Patches for what the Frame's runtime does differently from a Quest (graphics formats, missing OpenXR extensions, Lepton's launcher requirements).": "",
"Patches that can't matter for this game (wrong engine or API) are hidden. Show them to force one on anyway.": "",
"Paused: waiting for your Frame": "",
"PC VR": "",
"PC VR (Revive / Proton)": "",
"PC VR alternative: {pcvr_alternative}": "",
@@ -731,6 +734,7 @@
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
"These games can't run on the Steam Frame": "",
"These games check their Oculus license": "",
"These installs need about {need} on the Frame, and it has {free} free. Remove games you don't play (or use Free up space on the Steam Frame page), or install fewer at once.": "",
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app — it isn't installed on this PC, so they may quit right after starting. FramePort doesn't change how a game checks its license. Untick the ones you'd rather skip.": "",
"They use the Oculus Platform SDK, which comes with the Meta Horizon (Oculus) app. It doesn't exist on the Steam Frame, so there they crash right at startup (seen with Robo Recall, Lies Beneath and Lone Echo) — play them on this PC. Tick any you still want to try on the Frame.": "",
"They're Oculus games that need Revive to reach VR, and Revive can't run on the Frame. Play them on this PC instead (Install on this PC — SteamVR + Revive). Tick any you still want to put on the Frame to experiment (they'll likely run flat or crash).": "",
@@ -751,6 +755,7 @@
"Tools are up to date": "",
"Troubleshooting": "",
"Try again": "",
"Try now": "",
"Turn off only for games you play with your hands instead of controllers.": "",
"Turn this on if your copy is already patched: an APK is installed unchanged instead of being patched again. PC VR games are never modified on this PC; the Frame's copy only gets launch fixes.": "",
"Turned around": "",
@@ -819,6 +824,8 @@
"v{remote} (updates on the next action)": "",
"Waiting": "",
"Waiting ({n})": "",
"Waiting for the Frame": "",
"Waiting for your Frame": "",
"Waiting for your Frame… (code {code})": "",
"Welcome to FramePort": "",
"What FramePort will do": "",
@@ -844,6 +851,7 @@
"Your copy is used as it is (only the Frame's copy gets launch patches)": "",
"Your custom recipe": "",
"your Frame": "",
"Your Frame is back: continuing": "",
"Your headset, what it's ready for and what's installed": "",
"Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, VR API and store genres. Filter the library by either.": "",
"{count} files · {human}": "",
+2
View File
@@ -21,6 +21,8 @@ class FrameLeptonTarget(Target):
self.label = target.label
def connect(self) -> FrameLeptonTarget:
if self.frame.client is not None and not self.frame.alive():
self.frame.close() # the link died (Frame asleep, Wi-Fi gone): open a fresh one
if self.frame.client is None:
self.frame.connect()
return self
+97 -8
View File
@@ -188,6 +188,9 @@ class FramePortApp:
self._act_bar.value = cur.fraction
more = tr(" · {queued} more queued").format(queued=queued) if queued else ""
self._act_stage.value = (cur.stage or tr("Starting…")) + more
elif self.jobs.paused == "frame":
self._act_idle_text.value = tr("Paused: waiting for your Frame")
self._act_idle_text.color = T.WARN
else:
recent = next((j for j in self.jobs.recent(1)), None)
self._act_idle_text.value = tr("No activity") if not recent else \
@@ -325,6 +328,7 @@ class FramePortApp:
def _on_job(self, job: Job | None) -> None:
self._refresh_sidebar()
self.activity.refresh()
self._keep_frame_awake()
if job and job.state in ("done", "failed", "cancelled") and job.finished and not getattr(job, "_handled", 0):
job._handled = 1
if job.kind == "app-update" and job.state != "done":
@@ -367,8 +371,11 @@ class FramePortApp:
job._card_marked = 1 # "Working…" badge on the card
self.refresh_view()
def submit(self, title: str, run, package: str | None = None, kind: str = "task", open_panel: bool = False) -> Job:
job = self.jobs.submit(Job(title, run, package, kind))
def submit(self, title: str, run, package: str | None = None, kind: str = "task", open_panel: bool = False,
to: str = "frame") -> Job:
# jobs that use the Frame wait for it (queue paused) when it drops off the network, instead of failing
needs_frame = kind in ("install", "test", "uninstall", "tool-frame") and to == "frame"
job = self.jobs.submit(Job(title, run, package, kind, to=to, needs_frame=needs_frame))
if open_panel:
self.show_activity(True)
elif self.route[0] == "game":
@@ -560,6 +567,26 @@ class FramePortApp:
return None
# ---------------------------------------------------------------- queueing several installs
def upload_estimate(self, pkgs: list[str], to: str = "frame") -> int:
"""Bytes these installs add on the target: the APK, plus the game's data unless it's installed already
(updates only re-send what changed)."""
info = getattr(self, "frame_info", None) or {}
installed = {d.get("package") for d in info.get("installed", [])} if to == "frame" else set()
total = 0
for p in pkgs:
g = library.game(p) or {}
if g.get("kind") == "rift":
total += 0 if p in installed else int((g.get("analysis") or {}).get("extra", {}).get("data_bytes") or 0)
continue
apk = (g.get("build") or {}).get("apk") or g.get("apk")
try:
total += Path(apk).stat().st_size if apk else 0
except OSError:
pass
if p not in installed:
total += int(g.get("data_bytes") or 0)
return total
def install_many(self, pkgs: list[str], to: str = "frame", allow_blocked: bool = False,
then: Callable[[], None] | None = None) -> None:
"""Queue installs. Everything that needs a decision is asked first (which program starts each Rift game, one
@@ -674,6 +701,25 @@ class FramePortApp:
C.primary(tr("Install"), on_click=pick(ok))]))
def go(final: list[str]):
need, free = self.upload_estimate(final, to), (getattr(self, "frame_info", None) or {}).get("free_bytes")
if to == "frame" and free is not None and need > free - 2**30:
# warn before queueing: the installs would fail one by one once the Frame is full
pick = C.one_choice()
self.page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Not enough space on the Frame"), weight=ft.FontWeight.W_600),
bgcolor=T.SURFACE_2, shape=ft.RoundedRectangleBorder(radius=T.RADIUS), modal=True,
content=ft.Container(C.body(tr(
"These installs need about {need} on the Frame, and it has {free} free. Remove games you "
"don't play (or use Free up space on the Steam Frame page), or install fewer at once.")
.format(need=fmt_size(need), free=fmt_size(max(free, 0)))), width=T.px(480)),
on_dismiss=pick(closed),
actions=[C.ghost(tr("Cancel"), on_click=pick(cancel)),
C.secondary(tr("Install anyway"), on_click=pick(lambda e: (self.page.pop_dialog(),
queue(final))))]))
return
queue(final)
def queue(final: list[str]):
for p in final:
self._submit_install(p, to)
if len(final) > 1:
@@ -829,10 +875,7 @@ class FramePortApp:
.format(get=g.get('title'), where=where))
job_title = tr("Install {title} on {value}").format(title=self._title(pkg),
value='Frame' if to == 'frame' else 'this PC')
job = self.submit(job_title, run, pkg,
"install")
job.to = to
return job
return self.submit(job_title, run, pkg, "install", to=to)
def updatable(self) -> list[tuple[str, str]]:
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
@@ -900,7 +943,7 @@ class FramePortApp:
job.summary, job.to, job.log_path = summary, to, summary.get("log_path")
return tr("{title}: launch test {verdict} (furthest: {value})").format(
title=title, verdict=summary['verdict'], value=summary.get('milestone') or '—')
return self.submit(tr("Launch test: {title}").format(title=title), run, pkg, "test")
return self.submit(tr("Launch test: {title}").format(title=title), run, pkg, "test", to=to)
def update_steam_art(self, pkg: str) -> Job:
"""Send the game's current artwork to its Steam entry on the Frame (Steam restarts once)."""
@@ -1212,7 +1255,10 @@ class FramePortApp:
from ..frame.connection import saved_targets
while True:
time.sleep(POLL_SECONDS)
time.sleep(10 if self.jobs.paused else POLL_SECONDS)
if self.jobs.paused == "frame":
self.retry_frame(quiet=True)
continue
if self.jobs.current():
continue # the job is using the connection
try:
@@ -1223,6 +1269,49 @@ class FramePortApp:
except Exception: # noqa: BLE001
traceback.print_exc()
def retry_frame(self, quiet: bool = False) -> bool:
"""The queue waits for the Frame: reconnect, and continue the queue if it answers."""
target = self.target
if target is None:
return False
try:
target.connect()
info = target.describe()
except Exception as exc: # noqa: BLE001 - still away
if not quiet:
self.toast(explain(exc), error=True)
return False
self.frame_info, self.frame_state = info, "connected"
self._awake_at = 0.0 # the lock went away with the old connection's Frame session: take it again
self.jobs.resume()
self.toast(tr("Your Frame is back: continuing"))
self.refresh_view()
return True
def _keep_frame_awake(self) -> None:
"""Hold a wake lock on the Frame while jobs that use it run or wait (renewed every 30 min; it expires on its
own after an hour if FramePort goes away), release it when they're done."""
want = self.jobs.has_frame_work() and self.target is not None and self.frame_state == "connected" \
and not self.jobs.paused
now = time.time()
held = getattr(self, "_awake_at", 0.0)
if want == bool(held) and (not want or now - held < 1800) or getattr(self, "_awake_busy", False):
return
self._awake_busy = True
target = self.target
def work():
try:
r = target.frame.agent("keep_awake", on=want, minutes=60)
self._awake_at = time.time() if want and r.get("awake") else 0.0
applog.log.info("Frame wake lock: %s", r)
except Exception as exc: # noqa: BLE001 - only a convenience
applog.log.info("Frame wake lock failed: %s", exc)
self._awake_at = 0.0 if not want else time.time() # don't retry on every event
finally:
self._awake_busy = False
threading.Thread(target=work, daemon=True).start()
def connect(self, target, password=None, quiet=False):
from ..frame.connection import save_target
from ..targets.frame_lepton import FrameLeptonTarget
+43 -2
View File
@@ -12,7 +12,10 @@ from dataclasses import dataclass, field
from ..core import applog
from ..core.events import Cancelled, Event, Reporter
from ..errors import explain
from ..errors import explain, is_connection_error
from ..i18n import tr
MAX_FRAME_RETRIES = 5 # a job that keeps losing the Frame fails after this many tries
_ids = itertools.count(1)
@@ -42,6 +45,8 @@ class Job:
log_path: str | None = None # full launch log saved by a launch test
summary: dict | None = None # launch-test summary (install/test jobs)
to: str = "frame"
needs_frame: bool = False # uses the Frame: a lost connection pauses the queue instead of failing the job
retries: int = 0 # times this job went back to the queue after losing the Frame
@property
def active(self) -> bool:
@@ -87,6 +92,7 @@ class JobManager:
self._listeners: list[Callable[[Job | None], None]] = [on_change] if on_change else []
self._throttle = throttle
self._last = 0.0
self.paused: str | None = None # why the queue waits (e.g. "frame": the Frame dropped off the network)
self._worker = threading.Thread(target=self._loop, daemon=True, name="frameport-jobs")
self._worker.start()
@@ -103,6 +109,21 @@ class JobManager:
self._notify(job, force=True)
return job
def pause(self, reason: str) -> None:
with self._cv:
self.paused = reason
self._notify(None, force=True)
def resume(self) -> None:
with self._cv:
self.paused = None
self._cv.notify()
self._notify(None, force=True)
def has_frame_work(self) -> bool:
"""A job that uses the Frame is running or waiting (keep the Frame awake meanwhile)."""
return any(j.active and j.needs_frame for j in self.jobs)
def current(self) -> Job | None:
return next((j for j in self.jobs if j.state == "running"), None)
@@ -130,6 +151,8 @@ class JobManager:
with self._cv:
if job.state == "queued":
self._queue.remove(job)
if not self._queue:
self.paused = None # nothing left to wait for
job.state, job.finished = "cancelled", time.time()
job.version += 1
job.cancel()
@@ -144,6 +167,20 @@ class JobManager:
return False
# ------------------------------------------------------------------ internals
def _wait_for_frame(self, job: Job, exc: BaseException) -> None:
"""The Frame went away mid-job: put the job back at the front and pause until it's reachable again (the
app resumes the queue after reconnecting). Uploads continue where they stopped."""
applog.log.info("job %r: lost the Frame (%s: %s); queue paused", job.title, type(exc).__name__, exc)
with self._cv:
job.retries += 1
job.state, job.started, job.fraction, job.speed = "queued", None, None, ""
job.stage = tr("Waiting for the Frame")
job.log.append(f"lost the Frame ({type(exc).__name__}: {exc}); waiting to continue")
job.version += 1
self._queue.insert(0, job)
self.paused = "frame"
self._notify(job, force=True)
def _event(self, job: Job, ev: Event) -> None:
job._on_event(ev)
self._notify(job, force=ev.kind in ("stage", "check"))
@@ -162,7 +199,7 @@ class JobManager:
def _loop(self) -> None:
while True:
with self._cv:
while not self._queue:
while not self._queue or self.paused:
self._cv.wait()
job = self._queue.pop(0)
job.state, job.started = "running", time.time()
@@ -174,6 +211,10 @@ class JobManager:
except Cancelled:
job.state = "cancelled"
except Exception as exc: # noqa: BLE001
if job.needs_frame and is_connection_error(exc) and job.retries < MAX_FRAME_RETRIES \
and not job.reporter.cancelled.is_set():
self._wait_for_frame(job, exc)
continue
traceback.print_exc()
applog.log.exception("job %r failed", job.title)
job.state, job.error = "failed", explain(exc)
+11
View File
@@ -102,6 +102,8 @@ class ActivityPanel:
tiles.append(self._queue_header(queued))
tiles.append(cached[1])
self._tiles = {j.id: self._tiles[j.id] for j in jobs}
if self.app.jobs.paused == "frame":
pinned.insert(0, self._paused_notice())
self.pinned.controls = pinned
if not tiles and not pinned:
tiles = [ft.Container(C.body(tr("Nothing running. Installs, launch tests and downloads show up here."),
@@ -110,6 +112,15 @@ class ActivityPanel:
if update:
C.update(self.root)
def _paused_notice(self) -> ft.Control:
return C.callout(ft.Column([
C.body(tr("Waiting for your Frame"), T.TEXT, weight=ft.FontWeight.W_600),
C.body(tr("It can't be reached (asleep, turned off or out of Wi-Fi). Wake it or turn it on: FramePort "
"continues by itself, and uploads pick up where they stopped."), T.TEXT_2),
C.secondary(tr("Try now"), ft.Icons.REFRESH_ROUNDED,
lambda e: self.app.run_bg(lambda: self.app.retry_frame())),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START), "warn", ft.Icons.WIFI_OFF_ROUNDED)
def _queue_header(self, queued: list[Job]) -> ft.Control:
def cancel_all(e):
for j in list(queued):
+22
View File
@@ -680,3 +680,25 @@ def test_launcher_exports_multiline_env_values(monkeypatch, tmp_path):
out = subprocess.run(["bash", "-c", f'export {line}\nprintf %s "$LEPTON_GFXRECON_FP_PROPS"'],
capture_output=True, text=True, check=True).stdout
assert out == "0\nqemu.hw.mainkeys=1"
def test_keep_awake_falls_back_to_idle_when_sleep_is_refused(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
calls, started = [], []
def fake_run(cmd, **kw):
calls.append(cmd)
if cmd[0] == "systemd-run":
started.append(next(c for c in cmd if c.startswith("--what=")))
active = cmd[:3] == ["systemctl", "--user", "is-active"] and started and started[-1] == "--what=idle"
return SimpleNamespace(returncode=0, stdout="active\n" if active else "failed\n", stderr="")
monkeypatch.setattr(a, "run", fake_run)
monkeypatch.setattr(a.time, "sleep", lambda s: None)
r = a.cmd_keep_awake({"on": True, "minutes": 30})
assert r == {"awake": True, "what": "idle", "seconds": 1800} and started == ["--what=idle:sleep", "--what=idle"]
run_cmd = next(c for c in calls if c[0] == "systemd-run")
assert "systemd-inhibit" in run_cmd and run_cmd[-2:] == ["sleep", "1800"]
calls.clear()
assert a.cmd_keep_awake({"on": False}) == {"awake": False}
assert calls and all(c[0] == "systemctl" for c in calls) # only stops the unit
+66
View File
@@ -349,3 +349,69 @@ def test_activity_pins_the_running_job_above_a_long_queue():
panel.refresh(update=False)
assert len(panel.pinned.controls) == 1 # the running job, outside the scrolling list
assert panel.list.controls[0].controls[0].value == "Waiting (40)"
def test_queue_waits_for_the_frame_instead_of_failing_every_install():
"""A job that loses the Frame goes back to the front and the queue pauses; resume() continues it."""
import time
from paramiko.ssh_exception import NoValidConnectionsError
from frameport.ui.jobs import Job, JobManager
jm = JobManager(save_logs=False)
runs, online = [], {"up": False}
def install(name):
def run(job):
runs.append(name)
if not online["up"]:
raise NoValidConnectionsError({("10.0.0.9", 22): OSError("down")})
return "ok"
return run
a = jm.submit(Job("A", install("A"), "a", "install", needs_frame=True))
b = jm.submit(Job("B", install("B"), "b", "install", needs_frame=True))
for _ in range(200):
if jm.paused:
break
time.sleep(0.01)
assert jm.paused == "frame" and a.state == "queued" and b.state == "queued" and runs == ["A"]
assert jm.has_frame_work()
online["up"] = True
jm.resume()
assert jm.wait_idle(5) and a.state == b.state == "done" and runs == ["A", "A", "B"]
assert a.retries == 1 and not jm.has_frame_work()
pc = jm.submit(Job("PC", install("PC"), "p", "install")) # doesn't use the Frame: fails normally
online["up"] = False
jm.wait_idle(5)
assert pc.state in ("done", "failed") and not jm.paused
def test_free_space_is_checked_before_queueing(monkeypatch, tmp_path):
from types import SimpleNamespace
from frameport.core import library
from frameport.ui.app import FramePortApp
apk = tmp_path / "g.apk"
apk.write_bytes(b"x" * 1000)
games = {p: {"package": p, "title": p, "apk": str(apk), "data_bytes": 3 * 2**30, "analysis": {},
"recipe": {"patches": {}}} for p in ("com.a", "com.b")}
monkeypatch.setattr(library, "game", games.get)
dialogs, submitted = [], []
app = object.__new__(FramePortApp)
app.page = SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None)
app.jobs = SimpleNamespace(busy_with=lambda p: None)
app.library_view, app.toast, app._title = None, lambda *a, **k: None, lambda p: p
app._submit_install = lambda p, to: submitted.append(p)
app.frame_info = {"free_bytes": 5 * 2**30, "installed": [{"package": "com.b"}]} # com.b: update, no data
assert app.upload_estimate(["com.a", "com.b"]) == 3 * 2**30 + 2000
app.install_many(["com.a", "com.b"], "frame")
assert submitted == ["com.a", "com.b"] and not dialogs # fits (5 GiB free, 1 GiB spare)
app.frame_info["free_bytes"] = 3 * 2**30
submitted.clear()
app.install_many(["com.a", "com.b"], "frame")
assert not submitted and len(dialogs) == 1 # asks first
dialogs[0].actions[-1].on_click(None) # Install anyway
assert submitted == ["com.a", "com.b"] and not app._asking