mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 10:00:36 +02:00
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:
1 parent
6e0bb8ee59
commit
2fecfbf6ea
8 files changed
+273
-11
No files matched your search
@@ -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 {}
|
||||
|
||||
@@ -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}": "",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user