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:
spoopyghosty0 committed 2026-10-04 23:43:32 -04:00
1 parent d05dd27971
commit f37f7be9c7
5 files changed
+333 -8

No files matched your search

+1
View File
@@ -19,3 +19,4 @@ CLAUDE.local.md
# outputs of `frameport report` / `parity` / ruff
REPORT.md
.ruff_cache/
.claude/worktrees/
+221 -6
View File
@@ -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)
+17 -2
View File
@@ -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:
+42
View File
@@ -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")]) == []
+52
View File
@@ -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