mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 10:00:36 +02:00
Agent: install arm64 Linux apps (GitHub #31; agent v45): prepare/finalize_linux (AppImages extracted once, missing shared libraries reported via ldd of the app's programs), launcher with the exit watchdog, Steam tag "Linux app on Frame", launch test, stop, uninstall. Verified on the device with an AppImage (Venera: libwebkit2gtk missing, reported) and a tar build (FramePort arm64: runs from Steam, Exit game, uninstall). The app no longer replaces a newer agent on the Frame (an older FramePort kept downgrading it)
This commit is contained in:
1 parent
d05dd27971
commit
f37f7be9c7
5 files changed
+333
-8
No files matched your search
@@ -19,3 +19,4 @@ CLAUDE.local.md
|
||||
# outputs of `frameport report` / `parity` / ruff
|
||||
REPORT.md
|
||||
.ruff_cache/
|
||||
.claude/worktrees/
|
||||
+221
-6
@@ -35,7 +35,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 44
|
||||
AGENT_VERSION = 45
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -992,7 +992,9 @@ def shortcut_args(pkg):
|
||||
dep = json.load(open(os.path.join(anchor, "deployment.json")))
|
||||
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
|
||||
flat = dep.get("vr") is False
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if dep.get("kind") == "pcvr" else "Quest on Frame"
|
||||
kind = dep.get("kind")
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if kind == "pcvr" else \
|
||||
"Linux app on Frame" if kind == "linux" else "Quest on Frame"
|
||||
return f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag, dep.get("tags") or [], not flat
|
||||
|
||||
|
||||
@@ -1043,7 +1045,7 @@ def game_running():
|
||||
names = run(["podman", "ps", "--format", "{{.Names}}"]).stdout.split()
|
||||
if any(n.startswith("lepton-steamlaunch-") for n in names):
|
||||
return True
|
||||
return any(pcvr_pids(d["base"]) for d in cmd_list_installed({})["games"] if d.get("kind") == "pcvr")
|
||||
return any(pcvr_pids(d["base"]) for d in cmd_list_installed({})["games"] if d.get("kind") in ("pcvr", "linux"))
|
||||
|
||||
|
||||
def shortcuts_worker(payload):
|
||||
@@ -1175,7 +1177,12 @@ def cmd_list_installed(args):
|
||||
continue
|
||||
dep.setdefault("kind", "quest")
|
||||
dep["anchor"] = os.path.dirname(dep_path) # its artwork/ feeds the Steam grid (shortcuts)
|
||||
if dep["kind"] == "pcvr":
|
||||
if dep["kind"] == "linux":
|
||||
exe = os.path.join(dep["base"], "app", dep.get("exe", ""))
|
||||
dep["apk_present"] = os.path.isfile(exe)
|
||||
dep["apk_size"] = sum((dep.get("files") or {}).get("app", {}).values())
|
||||
dep.pop("files", None)
|
||||
elif dep["kind"] == "pcvr":
|
||||
exe = os.path.join(dep["base"], "game", dep.get("exe", ""))
|
||||
dep["apk_present"] = os.path.isfile(exe)
|
||||
dep["apk_size"] = sum((dep.get("files") or {}).get("game", {}).values())
|
||||
@@ -2365,6 +2372,172 @@ def cmd_finalize_pcvr(args):
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "proton": tool["name"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Linux apps (arm64)
|
||||
# Native arm64 Linux programs (AppImages, zip/tar builds) installed from FramePort (GitHub #31): files in
|
||||
# <base>/app, AppImages extracted once (no FUSE in Steam's launch environment needed, faster start), a launcher with
|
||||
# the same exit watchdog as the Quest launcher, and a Steam shortcut (VR flag for OpenXR apps).
|
||||
LINUX_LAUNCH_SH = r"""#!/usr/bin/env bash
|
||||
# Steam Frame launcher for {title} ({pkg}), a Linux app. Generated by FramePort.
|
||||
set -uo pipefail
|
||||
cd {run_dir_q} || {{ echo "App files missing at {run_dir_q}" >&2; exit 1; }}
|
||||
export XDG_RUNTIME_DIR="${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}"
|
||||
# Steam passes the display session (gamescope's X/Wayland); headless launches (launch tests) take it from Steam
|
||||
if [[ -z "${{DISPLAY:-}}" ]]; then
|
||||
steam_pid=$(pgrep -x steam | head -n1 || true)
|
||||
if [[ -n "$steam_pid" && -r "/proc/$steam_pid/environ" ]]; then
|
||||
while IFS= read -r -d '' kv; do
|
||||
case "$kv" in DISPLAY=*|WAYLAND_DISPLAY=*|GAMESCOPE_WAYLAND_DISPLAY=*|XAUTHORITY=*|XDG_SESSION_TYPE=*)
|
||||
export "$kv";; esac
|
||||
done < "/proc/$steam_pid/environ"
|
||||
fi
|
||||
fi
|
||||
{extra_env}parent=$PPID
|
||||
{exe_q} "$@" >{log_q} 2>&1 &
|
||||
child=$!
|
||||
trap 'kill -TERM $child 2>/dev/null' INT TERM
|
||||
( while sleep 2 && kill -0 $child 2>/dev/null; do
|
||||
if [[ $parent -gt 1 ]] && ! kill -0 $parent 2>/dev/null; then kill -TERM $child; fi; done ) &
|
||||
wait $child
|
||||
"""
|
||||
|
||||
|
||||
def linux_run_target(base, exe_rel, appimage):
|
||||
"""(working directory, program) for a Linux app: an extracted AppImage runs its AppRun."""
|
||||
if appimage: # absolute paths: the agent finds the app's processes by its folder in their command lines
|
||||
root = os.path.join(base, "app", "squashfs-root")
|
||||
return root, os.path.join(root, "AppRun")
|
||||
path = os.path.join(base, "app", exe_rel)
|
||||
return os.path.dirname(path), path
|
||||
|
||||
|
||||
def missing_libraries(root, programs):
|
||||
"""Shared libraries the app's main programs need (ldd follows their dependencies) that neither the Frame nor the
|
||||
app itself has, with the app's own library folders on LD_LIBRARY_PATH as AppImages' AppRun sets it: e.g.
|
||||
libwebkit2gtk-4.1 for Venera. Only the programs that start, not every bundled module (FramePort's own build
|
||||
ships optional Python modules needing libjvm/libtcl that never load)."""
|
||||
libdirs = set()
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
if any(".so" in n for n in files):
|
||||
libdirs.add(dirpath)
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=":".join(sorted(libdirs)))
|
||||
missing = set()
|
||||
for path in programs:
|
||||
p = subprocess.run(["ldd", path], capture_output=True, text=True, env=env, timeout=30)
|
||||
for line in p.stdout.splitlines():
|
||||
if "=> not found" in line:
|
||||
missing.add(line.split("=>")[0].strip())
|
||||
return sorted(missing)
|
||||
|
||||
|
||||
def appimage_programs(root):
|
||||
"""The ELF programs an extracted AppImage starts: top-level and usr/bin executables (not libraries)."""
|
||||
out = []
|
||||
for d in (root, os.path.join(root, "usr", "bin")):
|
||||
try:
|
||||
names = sorted(os.listdir(d))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
path = os.path.join(d, name)
|
||||
if ".so" in name or not os.path.isfile(path) or not os.access(path, os.X_OK):
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if f.read(4) == b"\x7fELF":
|
||||
out.append(path)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def cmd_prepare_linux(args):
|
||||
"""Where to upload a Linux app, and what the Frame already has (unchanged files aren't re-sent)."""
|
||||
pkg = check_pkg(args["package"])
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
dep = deployment(pkg)
|
||||
base = dep["base"] if dep else anchor
|
||||
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', args["title"])
|
||||
if dep and pcvr_pids(base):
|
||||
raise AgentError(f"{args['title']} is running on the Frame. Close it first.")
|
||||
incoming = os.path.join(base, "incoming")
|
||||
os.makedirs(os.path.join(incoming, "app"), exist_ok=True)
|
||||
existing = {"app": {**tree_manifest(os.path.join(base, "app")), **tree_manifest(os.path.join(incoming, "app"))}}
|
||||
existing["app"] = {k: v for k, v in existing["app"].items() if not k.startswith("squashfs-root/")}
|
||||
st = os.statvfs(base if os.path.exists(base) else HOME)
|
||||
return {"package": pkg, "base": base, "anchor": anchor, "appid": appid, "incoming": incoming,
|
||||
"installed": bool(dep), "existing": existing, "free_bytes": st.f_bavail * st.f_frsize}
|
||||
|
||||
|
||||
def cmd_finalize_linux(args):
|
||||
"""Move the uploaded app into place (files the new version lacks are deleted), extract an AppImage, check its
|
||||
libraries, write the launcher and deployment.json."""
|
||||
pkg = check_pkg(args["package"])
|
||||
title = args["title"]
|
||||
prep = cmd_prepare_linux({"package": pkg, "title": title})
|
||||
base, anchor, appid, incoming = prep["base"], prep["anchor"], prep["appid"], prep["incoming"]
|
||||
exe_rel = os.path.normpath(args["exe"])
|
||||
if exe_rel.startswith("..") or os.path.isabs(exe_rel):
|
||||
raise AgentError(f"bad program path {args['exe']!r}")
|
||||
app = os.path.join(base, "app")
|
||||
want = (args.get("manifests") or {}).get("app")
|
||||
old = ((deployment(pkg) or {}).get("files") or {}).get("app") or {}
|
||||
moved = 0
|
||||
for rel in tree_manifest(os.path.join(incoming, "app")):
|
||||
dst = os.path.join(app, rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
os.replace(os.path.join(incoming, "app", rel), dst)
|
||||
moved += 1
|
||||
if want is not None:
|
||||
for rel in set(old) - set(want):
|
||||
p = os.path.normpath(os.path.join(app, rel))
|
||||
if p.startswith(app + os.sep) and os.path.isfile(p):
|
||||
os.remove(p)
|
||||
have = tree_manifest(app)
|
||||
bad = [k for k, v in want.items() if have.get(k) != v]
|
||||
if bad:
|
||||
raise AgentError(f"{len(bad)} app file(s) missing or incomplete, e.g. {bad[0]}")
|
||||
exe = os.path.join(app, exe_rel)
|
||||
if not os.path.isfile(exe):
|
||||
raise AgentError(f"program {exe_rel} missing after upload")
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
os.chmod(exe, os.stat(exe).st_mode | 0o111)
|
||||
appimage = bool(args.get("appimage"))
|
||||
if appimage:
|
||||
shutil.rmtree(os.path.join(app, "squashfs-root"), ignore_errors=True)
|
||||
p = subprocess.run([exe, "--appimage-extract"], cwd=app, capture_output=True, text=True, timeout=600)
|
||||
if not os.path.isfile(os.path.join(app, "squashfs-root", "AppRun")):
|
||||
raise AgentError(f"couldn't unpack the AppImage: {(p.stderr or p.stdout).strip()[-300:]}")
|
||||
run_dir, program = linux_run_target(base, exe_rel, appimage)
|
||||
for name in args.get("executables") or []: # other programs the app starts (archives may lose the x bit)
|
||||
q = os.path.normpath(os.path.join(app, name))
|
||||
if q.startswith(app + os.sep) and os.path.isfile(q):
|
||||
os.chmod(q, os.stat(q).st_mode | 0o111)
|
||||
missing = missing_libraries(app, appimage_programs(run_dir) if appimage else [exe])
|
||||
os.makedirs(anchor, exist_ok=True)
|
||||
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (args.get("env") or {}).items()
|
||||
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
|
||||
text = LINUX_LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, run_dir_q=shlex.quote(run_dir),
|
||||
exe_q=shlex.quote(program), log_q=shlex.quote(os.path.join(base, "launch.log")),
|
||||
extra_env=extra)
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
with open(path + ".tmp", "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(path + ".tmp", 0o755)
|
||||
os.replace(path + ".tmp", path)
|
||||
art_in = os.path.join(base, "incoming-artwork")
|
||||
if os.path.isdir(art_in):
|
||||
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
|
||||
shutil.move(art_in, os.path.join(anchor, "artwork"))
|
||||
vr = bool(args.get("openxr"))
|
||||
dep = {"package": pkg, "kind": "linux", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
|
||||
"appimage": appimage, "vr": vr, "tags": args.get("tags") or [], "sha256": args.get("exe_sha256"),
|
||||
"missing_libraries": missing, "recipe": args.get("recipe"), "installed_by": "frameport",
|
||||
"files": {"app": want or {}}, "agent_version": AGENT_VERSION, "time": time.time()}
|
||||
with open(os.path.join(anchor, "deployment.json"), "w") as f:
|
||||
json.dump(dep, f, indent=2)
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_files": moved, "missing_libraries": missing}
|
||||
|
||||
|
||||
AWAKE_UNIT = "frameport-awake"
|
||||
|
||||
|
||||
@@ -2423,10 +2596,12 @@ def cmd_uninstall(args):
|
||||
return {"removed": False}
|
||||
base = dep["base"]
|
||||
pcvr = dep.get("kind") == "pcvr"
|
||||
if (pcvr_pids(base) if pcvr else container_running(dep["appid"])):
|
||||
linux = dep.get("kind") == "linux"
|
||||
if (pcvr_pids(base) if pcvr or linux else container_running(dep["appid"])):
|
||||
raise AgentError("the game is running")
|
||||
keep_data = args.get("keep_data", True)
|
||||
names = ("game", "revive", "xrlayer", "shadercache", "incoming", "incoming-artwork") if pcvr else \
|
||||
("app", "incoming", "incoming-artwork", "launch.log") if linux else \
|
||||
("lepton-app", "lepton-shaders", "incoming", "previous-game.apk")
|
||||
for name in names:
|
||||
p = os.path.join(base, name)
|
||||
@@ -2462,6 +2637,9 @@ def cmd_stop(args):
|
||||
run(["systemctl", "--user", "stop", f"frameport-test-{dep['appid']}"])
|
||||
if dep.get("kind") == "pcvr":
|
||||
stop_pcvr(dep)
|
||||
elif dep.get("kind") == "linux":
|
||||
for pid in pcvr_pids(dep["base"]):
|
||||
run(["kill", "-TERM", pid])
|
||||
else:
|
||||
run(["podman", "kill", f"lepton-steamlaunch-{dep['appid']}"])
|
||||
return {"stopped": bool(dep)}
|
||||
@@ -2521,6 +2699,8 @@ def cmd_launch_test(args):
|
||||
appid = dep["appid"]
|
||||
if dep.get("kind") == "pcvr":
|
||||
return launch_test_pcvr(dep, anchor, log, seconds)
|
||||
if dep.get("kind") == "linux":
|
||||
return launch_test_linux(dep, anchor, log, seconds)
|
||||
if container_running(appid):
|
||||
raise AgentError("the game is already running")
|
||||
ensure_host_fixes()
|
||||
@@ -2559,6 +2739,40 @@ def cmd_launch_test(args):
|
||||
"kernel_keys_before": keys, "kernel_keys_after": key_usage()}
|
||||
|
||||
|
||||
def launch_test_linux(dep, anchor, log, seconds):
|
||||
"""Start a Linux app headless (the launcher borrows Steam's display) and see whether it stays up."""
|
||||
base, appid = dep["base"], dep["appid"]
|
||||
if pcvr_pids(os.path.join(base, "app")):
|
||||
raise AgentError("the app is already running")
|
||||
unit = f"frameport-test-{appid}"
|
||||
run(["systemctl", "--user", "reset-failed", unit])
|
||||
start = time.time()
|
||||
p = run(["systemd-run", "--user", "--quiet", f"--unit={unit}", "--property=RemainAfterExit=no",
|
||||
os.path.join(anchor, "launch.sh")])
|
||||
if p.returncode:
|
||||
raise AgentError("could not start the launcher: " + p.stderr[-300:])
|
||||
state, seen = "RUNNING", False
|
||||
while time.time() - start < seconds:
|
||||
time.sleep(2)
|
||||
seen = seen or bool(pcvr_pids(os.path.join(base, "app")))
|
||||
if run(["systemctl", "--user", "is-active", "--quiet", unit]).returncode:
|
||||
state = "EXITED" if seen else "NEVER_STARTED"
|
||||
break
|
||||
elapsed = round(time.time() - start)
|
||||
run(["systemctl", "--user", "stop", unit])
|
||||
for pid in pcvr_pids(os.path.join(base, "app")):
|
||||
run(["kill", "-TERM", pid])
|
||||
combined = os.path.join(base, "launch-test.log")
|
||||
text = open(log, errors="replace").read()[-400000:] if os.path.exists(log) else ""
|
||||
missing = dep.get("missing_libraries") or []
|
||||
with open(combined, "w") as f:
|
||||
if missing:
|
||||
f.write("FramePort: libraries missing on the Frame: " + ", ".join(missing) + "\n")
|
||||
f.write(text)
|
||||
return {"state": state, "elapsed": elapsed, "log": combined, "log_size": os.path.getsize(combined),
|
||||
"kind": "linux", "game_process": seen, "missing_libraries": missing}
|
||||
|
||||
|
||||
def launch_test_pcvr(dep, anchor, log, seconds):
|
||||
base, appid = dep["base"], dep["appid"]
|
||||
if pcvr_pids(base):
|
||||
@@ -2784,7 +2998,8 @@ def cmd_purge(args):
|
||||
Proton self-test prefix, timefix layer). Runs detached (Steam is closed while shortcuts.vdf changes); poll
|
||||
purge_status. Proton/Lepton stay installed (they're Steam apps) and the podman keyring fix stays (harmless)."""
|
||||
games = cmd_list_installed({})["games"]
|
||||
if any((pcvr_pids(d["base"]) if d.get("kind") == "pcvr" else container_running(d["appid"])) for d in games):
|
||||
if any((pcvr_pids(d["base"]) if d.get("kind") in ("pcvr", "linux") else container_running(d["appid"]))
|
||||
for d in games):
|
||||
raise AgentError("a FramePort game is running on the Frame; close it first")
|
||||
status = os.path.join(HOME, ".cache/frameport-purge.json")
|
||||
os.makedirs(os.path.dirname(status), exist_ok=True)
|
||||
|
||||
@@ -28,6 +28,12 @@ log = logging.getLogger(__name__)
|
||||
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
|
||||
|
||||
|
||||
def agent_version_of(text: str) -> int:
|
||||
"""AGENT_VERSION from an agent's source (0 if missing)."""
|
||||
m = re.search(r"^AGENT_VERSION\s*=\s*(\d+)", text, re.M)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
class FrameNotPaired(ConnectionError):
|
||||
"""SSH works but none of FramePort's ways in was accepted: the Frame hasn't run the first-time setup (or the
|
||||
password was wrong). The message keeps "authentication failed" for older callers."""
|
||||
@@ -512,8 +518,17 @@ class Frame:
|
||||
remote = posixpath.join(remote_dir, "frameport_agent.py")
|
||||
if self._agent_digest == digest:
|
||||
return remote
|
||||
code, out, _ = self.run(f"sha256sum {sh_quote(remote)} 2>/dev/null | cut -c1-16")
|
||||
if out.strip() != digest:
|
||||
code, out, _ = self.run(f"sha256sum {sh_quote(remote)} 2>/dev/null | cut -c1-16; "
|
||||
f"grep -m1 '^AGENT_VERSION' {sh_quote(remote)} 2>/dev/null")
|
||||
lines = out.split("\n")
|
||||
remote_version = agent_version_of(lines[1] if len(lines) > 1 else "")
|
||||
if lines[0].strip() != digest and remote_version > agent_version_of(text.decode("utf-8", "replace")):
|
||||
# a newer FramePort (another PC, or FramePort on the Frame) installed a newer agent: keep it. Agents
|
||||
# stay compatible with older apps; replacing it broke the newer one's launchers (2026-10-04: an older
|
||||
# app put agent 43 back over 45 every few seconds)
|
||||
self._agent_digest = digest
|
||||
return remote
|
||||
if lines[0].strip() != digest:
|
||||
self.run(f"mkdir -p {sh_quote(remote_dir)}")
|
||||
tmp = f"{remote}.{os.getpid()}.{threading.get_ident()}.tmp" # two threads never share a temp file
|
||||
with self.sftp.open(tmp, "wb") as f:
|
||||
|
||||
@@ -1161,3 +1161,45 @@ def test_upgrade_launchers_adds_dashboard_helper(monkeypatch, tmp_path):
|
||||
assert a.upgrade_launchers() == ["com.x.y"]
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert "_dashboard_worker" in text and text.index("_dashboard_worker") < text.index('wait "$child"')
|
||||
|
||||
|
||||
def test_linux_app_finalize_launcher_and_libraries(monkeypatch, tmp_path):
|
||||
"""GitHub #31: an arm64 Linux app (here: a copy of a host ELF) goes to <base>/app with a launcher; the library
|
||||
check runs on the program, uninstall removes it."""
|
||||
import shutil
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
prep = a.cmd_prepare_linux({"package": "linux.true", "title": "True"})
|
||||
app_in = os.path.join(prep["incoming"], "app", "bin")
|
||||
os.makedirs(app_in)
|
||||
shutil.copy("/bin/true", os.path.join(app_in, "true"))
|
||||
size = os.path.getsize(os.path.join(app_in, "true"))
|
||||
res = a.cmd_finalize_linux({"package": "linux.true", "title": "True", "exe": "bin/true",
|
||||
"manifests": {"app": {"bin/true": size}}, "openxr": False})
|
||||
assert res["missing_libraries"] == [] and res["moved_files"] == 1
|
||||
dep = a.deployment("linux.true")
|
||||
assert dep["kind"] == "linux" and dep["vr"] is False
|
||||
text = open(os.path.join(prep["anchor"], "launch.sh")).read()
|
||||
assert os.path.join(prep["base"], "app", "bin", "true") in text and "kill -0 $parent" in text
|
||||
assert a.shortcut_args("linux.true")[4] == "Linux app on Frame" and a.shortcut_args("linux.true")[6] is False
|
||||
listed = [g for g in a.cmd_list_installed({})["games"] if g["package"] == "linux.true"]
|
||||
assert listed and listed[0]["apk_present"]
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
assert a.cmd_uninstall({"package": "linux.true", "keep_data": False})["removed"]
|
||||
assert not os.path.exists(os.path.join(prep["base"], "app"))
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_finalize_linux({"package": "linux.true", "title": "True", "exe": "../etc/passwd"})
|
||||
|
||||
|
||||
def test_appimage_programs_and_missing_libraries(monkeypatch, tmp_path):
|
||||
import shutil
|
||||
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
root = tmp_path / "squashfs-root"
|
||||
(root / "usr/bin").mkdir(parents=True)
|
||||
shutil.copy("/bin/true", root / "usr/bin/tool")
|
||||
(root / "AppRun").write_text("#!/bin/sh\n")
|
||||
(root / "AppRun").chmod(0o755)
|
||||
(root / "libfoo.so").write_bytes(b"\x7fELF")
|
||||
assert a.appimage_programs(str(root)) == [str(root / "usr/bin/tool")] # scripts and libraries skipped
|
||||
assert a.missing_libraries(str(root), [str(root / "usr/bin/tool")]) == []
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Frame.ensure_agent: an older FramePort must not replace a newer agent on the Frame (2026-10-04: an app with agent
|
||||
43 put it back over 45 every few seconds, so the newer app's launcher helpers failed)."""
|
||||
from frameport.frame import connection
|
||||
|
||||
|
||||
def fake_frame(remote_text, monkeypatch, tmp_path, local_version=45):
|
||||
local = tmp_path / "frameport_agent.py"
|
||||
local.write_text(f"#!/usr/bin/env python3\nAGENT_VERSION = {local_version}\n")
|
||||
monkeypatch.setattr(connection, "agent_file", lambda: local)
|
||||
uploads = []
|
||||
|
||||
class SFTP:
|
||||
def open(self, path, mode):
|
||||
class W:
|
||||
def write(self, data):
|
||||
uploads.append(data)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *e):
|
||||
return False
|
||||
return W()
|
||||
|
||||
def run(cmd, **k):
|
||||
if cmd.startswith("sha256sum"):
|
||||
return 0, "0000000000000000\n" + (f"AGENT_VERSION = {remote_text}\n" if remote_text else ""), ""
|
||||
return 0, "", ""
|
||||
class Fake(connection.Frame):
|
||||
sftp = SFTP()
|
||||
|
||||
f = Fake.__new__(Fake)
|
||||
f.__dict__.update(home="/home/steamos", _agent_digest=None, run=run)
|
||||
return f, uploads
|
||||
|
||||
|
||||
def test_newer_agent_on_the_frame_is_kept(monkeypatch, tmp_path):
|
||||
f, uploads = fake_frame("46", monkeypatch, tmp_path)
|
||||
f.ensure_agent()
|
||||
assert uploads == []
|
||||
|
||||
|
||||
def test_older_or_missing_agent_is_replaced(monkeypatch, tmp_path):
|
||||
for remote in ("43", ""):
|
||||
f, uploads = fake_frame(remote, monkeypatch, tmp_path)
|
||||
f.ensure_agent()
|
||||
assert len(uploads) == 1
|
||||
|
||||
|
||||
def test_agent_version_of():
|
||||
assert connection.agent_version_of("x\nAGENT_VERSION = 45\n") == 45
|
||||
assert connection.agent_version_of("nothing") == 0
|
||||
Reference in new issue
Block a user