mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-20 01:01:34 +02:00
Settings -> Data picks the PC backup folder (e.g. a cloud-sync folder; old folders stay listed) and turns on automatic snapshots (off by default): Quest launchers snapshot the saves when the game closes, under the launch lock, keeping the newest three changed ones on the Frame, and FramePort imports them as automatic backups when it connects. Agent v101. Checked on the Frame with BlazeRush (snapshot per close, one import per snapshot).
2364 lines
127 KiB
Python
2364 lines
127 KiB
Python
"""The Frame-side agent (stdlib only) — pieces that don't need a Frame."""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
|
|
|
|
|
|
def load_agent(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def test_vdf_roundtrip_and_upsert(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
exe = '"/home/steamos/Applications/quest-frame/com.x.y/launch.sh"'
|
|
appid = a.upsert_shortcut(str(vdf), exe, "My Game", "/start", "/icon.png")
|
|
assert appid == a.shortcut_appid(exe, "My Game") and appid & 0x80000000
|
|
root = a.vdf_decode(vdf.read_bytes())
|
|
entry = root["shortcuts"]["0"]
|
|
assert entry["appname"] == "My Game" and entry["Exe"] == exe and entry["OpenVR"] == 1
|
|
# upsert keeps the id and doesn't duplicate
|
|
assert a.upsert_shortcut(str(vdf), exe, "Renamed", "/start") == appid
|
|
assert len(a.vdf_decode(vdf.read_bytes())["shortcuts"]) == 1
|
|
assert a.vdf_encode(a.vdf_decode(vdf.read_bytes())) == vdf.read_bytes()
|
|
assert a.remove_shortcut(str(vdf), exe)
|
|
assert a.vdf_decode(vdf.read_bytes())["shortcuts"] == {}
|
|
|
|
|
|
@pytest.mark.parametrize("root_key", ["Shortcuts", "SHORTCUTS", "shortcuts"])
|
|
def test_shortcuts_keep_steams_key_spelling_and_existing_games(monkeypatch, tmp_path, root_key):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
exe = '"/game/launch.sh"'
|
|
other = {"AppID": 123, "AppName": "Other", "EXE": '"/other"', "Tags": {"0": "Favorite"}}
|
|
original = {root_key: {"0": {"AppID": 456, "AppName": "Old", "EXE": exe, "ICON": "/old.png",
|
|
"Tags": {"0": "Favorite"}, "LastPlayTime": 99}, "1": other}}
|
|
data = a.vdf_encode(original)
|
|
vdf.write_bytes(data)
|
|
assert a.vdf_encode(a.vdf_decode(data)) == data
|
|
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"], write=False) == (456, True)
|
|
assert vdf.read_bytes() == data
|
|
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"]) == 456
|
|
root = a.vdf_decode(vdf.read_bytes())
|
|
assert list(root) == [root_key]
|
|
entry = root["shortcuts"]["0"]
|
|
assert entry["appname"] == "New" and "AppName" in list(entry) and "appname" not in list(entry)
|
|
assert entry["appid"] == 456 and entry["icon"] == "/old.png" and entry["LastPlayTime"] == 99
|
|
assert list(entry["tags"].values()) == ["Favorite", "Quest on Frame", "Action"]
|
|
assert root["shortcuts"]["1"] == other
|
|
assert a.upsert_shortcut(str(vdf), exe, "New", "/game", tags=["Action"], write=False) == (456, False)
|
|
a.upsert_shortcut(str(vdf), '"/third"', "Third", "/")
|
|
assert len(a.vdf_decode(vdf.read_bytes())["shortcuts"]) == 3
|
|
assert a.prune_shortcuts(str(vdf), "New", '"/replacement"', "Quest on Frame") == [456]
|
|
assert a.remove_shortcut(str(vdf), '"/other"')
|
|
root = a.vdf_decode(vdf.read_bytes())
|
|
assert list(root) == [root_key] and len(root["shortcuts"]) == 1
|
|
assert root["shortcuts"]["0"]["appname"] == "Third"
|
|
|
|
|
|
def test_capitalized_shortcuts_are_found_by_library_and_devkit_checks(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
cfg = Path(a.STEAM) / "userdata/42/config"
|
|
cfg.mkdir(parents=True)
|
|
exe = '"/game/launch.sh"'
|
|
vdf = cfg / "shortcuts.vdf"
|
|
vdf.write_bytes(a.vdf_encode({"Shortcuts": {"0": {"APPID": 0xF0000001, "EXE": exe,
|
|
"APPNAME": "Game", "DEVKITGAMEID": "fp_game"}}}))
|
|
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
|
monkeypatch.setattr(a, "steam_users", lambda: ["42"])
|
|
monkeypatch.setattr(a, "shortcut_args", lambda pkg: (exe,))
|
|
monkeypatch.setattr(a.time, "sleep", lambda seconds: None)
|
|
assert a.shortcut_appid_for(exe) == 0xF0000001
|
|
assert a.devkit_appid("fp_game") == 0xF0000001
|
|
assert a.shortcut_names("42") == {str(0xF0000001): "Game"}
|
|
assert a.shortcuts_lost(["42"], ["com.x.y"], wait=0) == []
|
|
|
|
|
|
@pytest.mark.parametrize("names", [("appname", "AppName"), ("AppName", "appname"), ("appname", "appname")])
|
|
def test_duplicate_shortcut_keys_are_merged_and_upserted(monkeypatch, tmp_path, names):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
exe = '"/game/launch.sh"'
|
|
entry = {"appid": 123, "name_slot": "Old", "Exe": exe, "duplicate_slot": "Latest", "LastPlayTime": 99}
|
|
data = a.vdf_encode({"Shortcuts": {"0": entry}})
|
|
data = data.replace(b"name_slot\0", names[0].encode() + b"\0")
|
|
data = data.replace(b"duplicate_slot\0", names[1].encode() + b"\0")
|
|
vdf.write_bytes(data)
|
|
spelling = "AppName" if "AppName" in names else "appname"
|
|
root = a.vdf_decode(data)
|
|
merged = root["shortcuts"]["0"]
|
|
assert list(merged) == ["appid", spelling, "Exe", "LastPlayTime"]
|
|
assert merged["appname"] == "Latest"
|
|
clean = a.vdf_encode(root)
|
|
assert clean == a.vdf_encode({"Shortcuts": {"0": {
|
|
"appid": 123, spelling: "Latest", "Exe": exe, "LastPlayTime": 99}}})
|
|
assert a.vdf_encode(a.vdf_decode(clean)) == clean
|
|
assert a.upsert_shortcut(str(vdf), exe, "Updated", "/game") == 123
|
|
written = vdf.read_bytes()
|
|
assert written.count(spelling.encode() + b"\0") == 1
|
|
other_spelling = "appname" if spelling == "AppName" else "AppName"
|
|
assert other_spelling.encode() + b"\0" not in written
|
|
updated = a.vdf_decode(written)["shortcuts"]["0"]
|
|
assert updated["appname"] == "Updated" and updated["LastPlayTime"] == 99
|
|
|
|
|
|
def test_duplicate_vdf_nodes_keep_last_value(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
data = a.vdf_encode({"Shortcuts": {"0": {"appid": 1}}, "shortcuts": {"0": {"appid": 2}}})
|
|
root = a.vdf_decode(data)
|
|
assert list(root) == ["Shortcuts"]
|
|
assert root["shortcuts"]["0"]["appid"] == 2
|
|
|
|
|
|
def test_launcher_template(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "anchor"
|
|
anchor.mkdir()
|
|
a.write_launcher(str(anchor), "/data/game dir/pkg", "com.x.y", "Game's Title", 123, "/lepton/lepton",
|
|
{"VK_INSTANCE_LAYERS": "", "bad-name": "x"})
|
|
text = (anchor / "launch.sh").read_text()
|
|
assert "export SteamAppId=123" in text and a.launcher_base(text, "com.x.y") == "/data/game dir/pkg"
|
|
assert "export VK_INSTANCE_LAYERS=''" in text and "bad-name" not in text
|
|
assert subprocess.run(["bash", "-n", str(anchor / "launch.sh")]).returncode == 0
|
|
|
|
|
|
def test_launcher_repairs_save_folder_permissions(monkeypatch, tmp_path):
|
|
"""SUPERHOT creates its cloud save folder with mode 1700 and quits when it can't write there (Lepton's app
|
|
writes through the folder's group): the launcher gives every storage folder owner and group write permission."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "anchor"
|
|
anchor.mkdir()
|
|
a.write_launcher(str(anchor), str(tmp_path / "game"), "com.x.y", "T", 1, "/lepton/lepton", {})
|
|
fix = next(line for line in (anchor / "launch.sh").read_text().splitlines() if line.startswith("fix_perms()"))
|
|
saves = tmp_path / "game" / "lepton-data" / "external" / "Android" / "data" / "com.x.y" / "files" / "cloud"
|
|
saves.mkdir(parents=True)
|
|
os.chmod(saves, 0o1700)
|
|
if os.stat(saves).st_mode & 0o777 != 0o700:
|
|
pytest.skip("filesystem doesn't keep modes")
|
|
subprocess.run(["bash", "-c", f"app_dir={tmp_path / 'game'}\n{fix}\nfix_perms"], check=True)
|
|
assert os.stat(saves).st_mode & 0o770 == 0o770
|
|
|
|
|
|
def test_launcher_prepares_cloud_storage_before_startup(monkeypatch, tmp_path):
|
|
"""GitHub #239: the startup routine fails before the 2 s watchdog can repair a new cloud folder."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "anchor"
|
|
anchor.mkdir()
|
|
a.write_launcher(str(anchor), "/games/com.x.y", "com.x.y", "T", 1, "/lepton/lepton", {})
|
|
text = (anchor / "launch.sh").read_text()
|
|
line = a.cloud_dirs_line("com.x.y")
|
|
assert text.count(line) == 1
|
|
assert text.index(line) < text.index("\nfix_perms\n") < text.index("\nsetsid ")
|
|
|
|
|
|
def test_upgrade_launchers_prepares_cloud_storage_once(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchors = tmp_path / "anchors"
|
|
monkeypatch.setattr(a, "ANCHORS", str(anchors))
|
|
anchor = anchors / "com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
a.write_launcher(str(anchor), str(anchor), "com.x.y", "T", 1, "/lepton/lepton", {})
|
|
line = a.cloud_dirs_line("com.x.y")
|
|
path = anchor / "launch.sh"
|
|
old = path.read_text().replace(line + "\n", "")
|
|
path.write_text(old)
|
|
saves = anchor / "lepton-data/external/Android/data/com.x.y/files/cloud/data/slot.dat"
|
|
saves.parent.mkdir(parents=True)
|
|
saves.write_bytes(b"progress")
|
|
assert a.upgrade_launchers() == ["com.x.y"]
|
|
text = path.read_text()
|
|
assert text.count(line) == 1 and text.index(line) < text.index("\nfix_perms\n")
|
|
assert a.upgrade_launchers() == []
|
|
assert saves.read_bytes() == b"progress"
|
|
|
|
|
|
def test_cli_protocol(tmp_path):
|
|
p = subprocess.run([sys.executable, str(AGENT), "nope"], capture_output=True, text=True)
|
|
assert p.returncode == 2 and json.loads(p.stdout)["ok"] is False
|
|
p = subprocess.run([sys.executable, str(AGENT), "set_settings"], input=json.dumps({"package": "bad name"}),
|
|
capture_output=True, text=True, env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin"})
|
|
assert json.loads(p.stdout) == {"ok": False, "error": "bad package name 'bad name'"}
|
|
|
|
|
|
def test_host_fix_keyring(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
assert a.ensure_host_fixes() == ["podman keyring=false"]
|
|
text = (tmp_path / ".config/containers/containers.conf").read_text()
|
|
assert "[containers]" in text and "keyring = false" in text
|
|
assert a.ensure_host_fixes() == [] # idempotent
|
|
# an existing [containers] section gets the key inserted, not a second section
|
|
conf = tmp_path / ".config/containers/containers.conf"
|
|
conf.write_text("[engine]\nfoo = 1\n[containers]\nlog_size_max = 10\n")
|
|
assert a.ensure_host_fixes() == ["podman keyring=false"]
|
|
assert conf.read_text().count("[containers]") == 1 and "keyring = false" in conf.read_text()
|
|
|
|
|
|
def test_cleanup_refuses_outside_paths(monkeypatch, tmp_path):
|
|
import pytest
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
old = tmp_path / "PATCHED"
|
|
(old / "x").mkdir(parents=True)
|
|
(old / "x" / "f.apk").write_bytes(b"1234")
|
|
r = a.cmd_cleanup({"paths": ["~/PATCHED"]})
|
|
assert r["freed_bytes"] == 4 and not old.exists()
|
|
for bad in ("/etc", "~/Applications/quest-frame", "~/Applications/quest-frame/x", "~/..", "~/.ssh", "~/.steam",
|
|
"~/.local/share", "~"):
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_cleanup({"paths": [bad]})
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------ PC VR under Proton
|
|
def fake_steam_tools(a, tmp_path, experimental=False):
|
|
"""A Steam library with an ARM64 Proton (needing a runtime) installed, like the Frame's; experimental=True also
|
|
installs Proton Experimental (chosen per game; the default is the stable one)."""
|
|
apps = tmp_path / ".local/share/Steam/steamapps"
|
|
if experimental:
|
|
(apps / "common/Proton - Experimental (ARM64)").mkdir(parents=True)
|
|
(apps / "common/Proton - Experimental (ARM64)/toolmanifest.vdf").write_text(
|
|
'"manifest"\n{\n "commandline" "/proton %verb%"\n "require_tool_appid" "4185400"\n}\n')
|
|
(apps / "appmanifest_4427310.acf").write_text(
|
|
'"AppState"\n{\n\t"appid"\t\t"4427310"\n\t"name"\t\t"Proton Experimental (ARM64)"\n'
|
|
'\t"StateFlags"\t\t"4"\n\t"installdir"\t\t"Proton - Experimental (ARM64)"\n}\n')
|
|
(apps / "common/Proton 11.0 (ARM64)").mkdir(parents=True)
|
|
(apps / "common/SteamLinuxRuntime_4-arm64").mkdir(parents=True)
|
|
(apps / "common/Proton 11.0 (ARM64)/toolmanifest.vdf").write_text(
|
|
'"manifest"\n{\n "version" "2"\n "commandline" "/proton %verb%"\n "require_tool_appid" "4185400"\n}\n')
|
|
(apps / "common/SteamLinuxRuntime_4-arm64/toolmanifest.vdf").write_text(
|
|
'"manifest"\n{\n "commandline" "/_v2-entry-point --verb=%verb% --"\n}\n')
|
|
for appid, name, d in ((4628740, "Proton 11.0 (ARM64)", "Proton 11.0 (ARM64)"),
|
|
(4185400, "Steam Linux Runtime 4.0 - Arm64", "SteamLinuxRuntime_4-arm64")):
|
|
(apps / f"appmanifest_{appid}.acf").write_text(
|
|
f'"AppState"\n{{\n\t"appid"\t\t"{appid}"\n\t"name"\t\t"{name}"\n\t"StateFlags"\t\t"4"\n'
|
|
f'\t"installdir"\t\t"{d}"\n}}\n')
|
|
a.arm64_compat_tools = lambda: {
|
|
"proton_11-arm64": {"appid": 4628740, "display_name": "Proton 11.0-2 (ARM64)", "from_oslist": "windows",
|
|
"require_tool_appid": 4185400, "aliases": "proton-stable-arm64"},
|
|
"proton-experimental-arm64": {"appid": 4427310, "display_name": "Proton Experimental (ARM64)",
|
|
"from_oslist": "windows", "aliases": "proton-experimental"},
|
|
"steamlinuxruntime_steamrt4-arm64": {"appid": 4185400, "from_oslist": "linux"},
|
|
}
|
|
return apps
|
|
|
|
|
|
def test_proton_status_and_command(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
apps = fake_steam_tools(a, tmp_path)
|
|
st = a.cmd_proton_status({})
|
|
assert st["ready"]["name"] == "proton_11-arm64" and st["suggested"]["name"] == "proton_11-arm64" # stable
|
|
st = a.cmd_proton_status({"tool": "proton-stable-arm64"})
|
|
assert st["ready"]["name"] == "proton_11-arm64"
|
|
assert [t["name"] for t in st["tools"]] == ["proton_11-arm64", "proton-experimental-arm64"] # stable first
|
|
cmd = a.compat_command(st["ready"]["dir"])
|
|
assert cmd == [str(apps / "common/SteamLinuxRuntime_4-arm64/_v2-entry-point"), "--verb=waitforexitandrun", "--",
|
|
str(apps / "common/Proton 11.0 (ARM64)/proton"), "waitforexitandrun"]
|
|
assert a.pick_proton(st["tools"], "proton-experimental")["installed"] is False
|
|
|
|
|
|
def test_install_proton_request_mode(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
fake_steam_tools(a, tmp_path)
|
|
calls = []
|
|
monkeypatch.setattr(a.subprocess, "Popen", lambda args, **k: calls.append(args))
|
|
r = a.cmd_install_proton({"tool": "proton-experimental-arm64"})
|
|
assert r["requested"] == [4427310] and calls == [["steam", "-ifrunning", "steam://install/4427310"]]
|
|
assert a.cmd_install_proton({"tool": "proton-stable-arm64"})["installed"] is True
|
|
assert a.cmd_install_proton({})["installed"] is True # the default: the stable one, already installed
|
|
|
|
|
|
def test_stub_manifest(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
lib = tmp_path / "lib"
|
|
lib.mkdir()
|
|
assert a.write_stub_manifest(4427310, "Proton Experimental (ARM64)", "Proton Experimental (ARM64)", str(lib))
|
|
text = (lib / "appmanifest_4427310.acf").read_text()
|
|
assert '"StateFlags"\t\t"1026"' in text and '"installdir"\t\t"Proton Experimental (ARM64)"' in text
|
|
assert not a.write_stub_manifest(4427310, "x", "x", str(lib)) # never overwrites a real manifest
|
|
|
|
|
|
def test_pcvr_install_flow(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
fake_steam_tools(a, tmp_path, experimental=True)
|
|
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
|
prep = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
|
|
inc = Path(prep["incoming"])
|
|
(inc / "game/Space Game_Data").mkdir(parents=True)
|
|
(inc / "game/Space Game.exe").write_bytes(b"MZexe")
|
|
(inc / "game/Space Game_Data/level0").write_bytes(b"1234")
|
|
(inc / "revive").mkdir(exist_ok=True)
|
|
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
|
|
(inc / "xrlayer").mkdir(exist_ok=True)
|
|
layer_json = ('{"api_layer": {"name": "XR_APILAYER_FRAMEPORT_timefix", '
|
|
'"library_path": "./libxr_frameport_timefix.so"}}')
|
|
(inc / "xrlayer/XR_APILAYER_FRAMEPORT_timefix.json").write_text(layer_json)
|
|
(inc / "xrlayer/libxr_frameport_timefix.so").write_bytes(b"ELF")
|
|
manifests = {"game": {"Space Game.exe": 5, "Space Game_Data/level0": 4}, "revive": {"ReviveInjector.exe": 2},
|
|
"xrlayer": {"XR_APILAYER_FRAMEPORT_timefix.json": len(layer_json), "libxr_frameport_timefix.so": 3}}
|
|
r = a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
|
|
"manifests": manifests, "env": {"PROTON_LOG": "1", "bad key": "x"}, "xr_layer": True})
|
|
assert r["ok"] and r["proton"] == "proton_11-arm64" # the default: stable, also with Experimental installed
|
|
launch = Path(prep["anchor"]) / "launch.sh"
|
|
text = launch.read_text()
|
|
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
|
|
assert "SteamGameId=" in text and "export PROTON_LOG=1" in text and "bad key" not in text
|
|
assert "ReviveInjector.exe /openxr 'Z:" in text and "Space Game.exe'" in text
|
|
assert 'XR_ENABLE_API_LAYERS="XR_APILAYER_FRAMEPORT_timefix' in text and 'XR_API_LAYER_PATH="$base/xrlayer' in text
|
|
env = subprocess.run(["bash", "-c", text.split("export XDG_RUNTIME_DIR")[0].replace("set -euo pipefail", "set -eu")
|
|
.split("[[ -d")[0] + 'base=/b\n' + text.split("export PROTON_LOG_DIR=\"$base\"\n")[1]
|
|
.split("export XDG_RUNTIME_DIR")[0] + 'echo "$XR_API_LAYER_PATH|$XR_ENABLE_API_LAYERS"'],
|
|
capture_output=True, text=True)
|
|
assert env.stdout.strip() == "/b/xrlayer|XR_APILAYER_FRAMEPORT_timefix", env.stderr
|
|
# the layer is registered as an explicit layer in the user's XDG data dir (Proton's container drops
|
|
# XR_API_LAYER_PATH), pointing at a shared absolute copy
|
|
reg = json.loads((tmp_path / ".local/share/openxr/1/api_layers/explicit.d/XR_APILAYER_FRAMEPORT_timefix.json")
|
|
.read_text())
|
|
lib = Path(reg["api_layer"]["library_path"])
|
|
assert lib.is_absolute() and lib.read_bytes() == b"ELF"
|
|
dep = a.deployment("rift.space_game")
|
|
assert dep["kind"] == "pcvr" and dep["files"]["game"] == manifests["game"]
|
|
listed = a.cmd_list_installed({})["games"]
|
|
assert listed[0]["kind"] == "pcvr" and listed[0]["apk_present"] and "files" not in listed[0]
|
|
# an update that drops a file removes it, and a new prepare sees what's there
|
|
prep2 = a.cmd_prepare_pcvr({"package": "rift.space_game", "title": "Space Game"})
|
|
assert prep2["existing"]["game"] == manifests["game"] and prep2["appid"] == prep["appid"]
|
|
a.cmd_finalize_pcvr({"package": "rift.space_game", "title": "Space Game", "exe": "Space Game.exe",
|
|
"manifests": {"game": {"Space Game.exe": 5}, "revive": manifests["revive"]}})
|
|
assert not (Path(prep["base"]) / "game/Space Game_Data/level0").exists()
|
|
# uninstall keeps the Proton prefix (saves)
|
|
(Path(prep["base"]) / "compatdata/pfx").mkdir(parents=True)
|
|
assert a.cmd_uninstall({"package": "rift.space_game", "keep_data": True})["removed"]
|
|
assert (Path(prep["base"]) / "compatdata/pfx").is_dir() and not (Path(prep["base"]) / "game").exists()
|
|
|
|
|
|
def test_pcvr_finalize_rejects_bad_exe(monkeypatch, tmp_path):
|
|
import pytest
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
fake_steam_tools(a, tmp_path)
|
|
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_finalize_pcvr({"package": "rift.x", "title": "X", "exe": "../../etc/passwd", "manifests": {}})
|
|
|
|
|
|
def test_shortcut_tag_and_launch_options(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
a.upsert_shortcut(str(vdf), '"C:\\Revive\\ReviveInjector.exe"', "Game", '"D:\\G\\"', "", "Rift via Revive",
|
|
'/openxr "D:\\G\\Game.exe"')
|
|
e = a.vdf_decode(vdf.read_bytes())["shortcuts"]["0"]
|
|
assert e["tags"] == {"0": "Rift via Revive"} and e["LaunchOptions"] == '/openxr "D:\\G\\Game.exe"'
|
|
|
|
|
|
def test_appinfo_parser(monkeypatch, tmp_path):
|
|
"""appinfo.vdf v29: header, one app (binary KV with string-table keys), string table."""
|
|
import struct
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
keys = ["appinfo", "appid", "common", "name", "extended", "compat_tools", "proton-x-arm64", "from_oslist"]
|
|
k = {n: i for i, n in enumerate(keys)}
|
|
|
|
def s(key, val):
|
|
return b"\x01" + struct.pack("<I", k[key]) + val.encode() + b"\0"
|
|
|
|
def m(key, body):
|
|
return b"\x00" + struct.pack("<I", k[key]) + body + b"\x08"
|
|
kv = m("appinfo", b"\x02" + struct.pack("<Ii", k["appid"], 7) + m("common", s("name", "Compat List")) +
|
|
m("extended", m("compat_tools", m("proton-x-arm64", s("from_oslist", "windows") +
|
|
b"\x02" + struct.pack("<Ii", k["appid"], 99))))) + b"\x08"
|
|
app = struct.pack("<I", 7) + struct.pack("<I", 60 + len(kv)) + b"\0" * 60 + kv
|
|
body = app + struct.pack("<I", 0)
|
|
str_off = 16 + len(body)
|
|
table = struct.pack("<I", len(keys)) + b"".join(n.encode() + b"\0" for n in keys)
|
|
data = struct.pack("<II", 0x07564429, 1) + struct.pack("<q", str_off) + body + table
|
|
f = tmp_path / "appinfo.vdf"
|
|
f.write_bytes(data)
|
|
out = a.appinfo_entries(path=str(f))
|
|
assert out[7]["common"]["name"] == "Compat List"
|
|
assert out[7]["extended"]["compat_tools"]["proton-x-arm64"] == {"from_oslist": "windows", "appid": 99}
|
|
|
|
|
|
def test_run_tree_kills_the_whole_group(monkeypatch, tmp_path):
|
|
"""Wine leaves children holding the output open; a timeout must still return and kill them."""
|
|
import time
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
start = time.time()
|
|
out, code = a.run_tree(["bash", "-c", "echo started; (sleep 60 &) ; sleep 60"], dict(a.os.environ), str(tmp_path),
|
|
str(tmp_path / "log"), 2)
|
|
assert code is None and "started" in out and time.time() - start < 20
|
|
out, code = a.run_tree(["bash", "-c", "echo ok; exit 3"], dict(a.os.environ), str(tmp_path), str(tmp_path / "l2"),
|
|
10)
|
|
assert (out.strip(), code) == ("ok", 3)
|
|
|
|
|
|
def test_purge_removes_frameport_and_keeps_saves(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
fake_steam_tools(a, tmp_path)
|
|
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
|
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
|
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
|
users = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
(users / "grid").mkdir(parents=True)
|
|
# a Quest-style install (base = anchor, saves in lepton-data) and a PC VR one (saves in compatdata)
|
|
q = Path(a.ANCHORS) / "com.x.y"
|
|
(q / "lepton-app").mkdir(parents=True)
|
|
(q / "lepton-data" / "external").mkdir(parents=True)
|
|
(q / "launch.sh").write_text("#!/bin/sh")
|
|
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
|
|
a.upsert_shortcut(str(users / "shortcuts.vdf"), f'"{q}/launch.sh"', "Q", str(q))
|
|
(users / "grid" / "7p.png").write_bytes(b"x")
|
|
agent_home = Path(a.AGENT_HOME)
|
|
(agent_home / "agent").mkdir(parents=True)
|
|
a.purge_worker(json.dumps({"keep_saves": True, "status": str(tmp_path / "st.json")}))
|
|
st = json.loads((tmp_path / "st.json").read_text())
|
|
assert st["state"] == "done", st
|
|
assert (q / "lepton-data").is_dir() and not (q / "lepton-app").exists() and not (q / "launch.sh").exists()
|
|
assert a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"] == {}
|
|
assert not (users / "grid" / "7p.png").exists() and not agent_home.exists()
|
|
# without keeping saves everything goes
|
|
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
|
|
assert not Path(a.ANCHORS).exists()
|
|
|
|
|
|
def test_shortcut_tags_merge(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
exe = '"/x/launch.sh"'
|
|
a.upsert_shortcut(str(vdf), exe, "G", "/x", tags=["Meta Quest", "Action"])
|
|
root = a.vdf_decode(vdf.read_bytes())
|
|
root["shortcuts"]["0"]["tags"]["9"] = "My collection" # a tag the user set in Steam
|
|
vdf.write_bytes(a.vdf_encode(root))
|
|
a.upsert_shortcut(str(vdf), exe, "G", "/x", tags=["Meta Quest", "Action", "Indie"])
|
|
tags = list(a.vdf_decode(vdf.read_bytes())["shortcuts"]["0"]["tags"].values())
|
|
assert tags == ["Quest on Frame", "Meta Quest", "Action", "My collection", "Indie"]
|
|
|
|
|
|
def test_pcvr_launcher_oculus_hmd_helper(monkeypatch, tmp_path):
|
|
"""pcvr.oculus_unreal: launch.sh runs Revive's injector (or the game) through fp_oculushmd.exe."""
|
|
import pytest
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
fake_steam_tools(a, tmp_path, experimental=True)
|
|
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
|
|
|
def install(pkg, helper=True, revive=True, oculus_hmd=True):
|
|
prep = a.cmd_prepare_pcvr({"package": pkg, "title": "UE Game"})
|
|
inc = Path(prep["incoming"])
|
|
(inc / "game").mkdir(parents=True, exist_ok=True)
|
|
(inc / "game/UEGame.exe").write_bytes(b"MZexe")
|
|
manifests = {"game": {"UEGame.exe": 5}}
|
|
if revive:
|
|
(inc / "revive").mkdir(exist_ok=True)
|
|
(inc / "revive/ReviveInjector.exe").write_bytes(b"MZ")
|
|
manifests["revive"] = {"ReviveInjector.exe": 2}
|
|
if helper:
|
|
(inc / "helpers").mkdir(exist_ok=True)
|
|
(inc / "helpers/fp_oculushmd.exe").write_bytes(b"MZhelp")
|
|
manifests["helpers"] = {"fp_oculushmd.exe": 6}
|
|
a.cmd_finalize_pcvr({"package": pkg, "title": "UE Game", "exe": "UEGame.exe", "manifests": manifests,
|
|
"revive": revive, "oculus_hmd": oculus_hmd, "game_args": ["-nocrashreports"]})
|
|
launch = Path(prep["anchor"]) / "launch.sh"
|
|
assert subprocess.run(["bash", "-n", str(launch)]).returncode == 0
|
|
return launch.read_text().splitlines()[-1], prep["base"]
|
|
|
|
cmd, base = install("rift.ue_game")
|
|
helper = f"{base}/helpers/fp_oculushmd.exe"
|
|
assert helper in cmd and cmd.index(helper) < cmd.index("ReviveInjector.exe")
|
|
# everything after the helper is a Windows command line: the injector by its Z: path, then the game + args
|
|
assert f"'Z:{base}/revive/ReviveInjector.exe'".replace("/", "\\") in cmd
|
|
assert (cmd.index("ReviveInjector.exe") < cmd.index("/openxr") < cmd.index("UEGame.exe")
|
|
< cmd.index("-nocrashreports"))
|
|
assert a.deployment("rift.ue_game")["oculus_hmd"] is True
|
|
cmd, base = install("rift.ue_direct", revive=False)
|
|
assert f"{base}/helpers/fp_oculushmd.exe" in cmd and "ReviveInjector" not in cmd and "'Z:" in cmd
|
|
cmd, base = install("rift.ue_plain", helper=False, oculus_hmd=False)
|
|
assert "fp_oculushmd" not in cmd and f"{base}/revive/ReviveInjector.exe /openxr" in cmd
|
|
with pytest.raises(a.AgentError, match="fp_oculushmd.exe missing"):
|
|
install("rift.ue_nohelper", helper=False)
|
|
|
|
|
|
def test_list_files(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "Applications/quest-frame/rift.g"
|
|
(base / "game/Bin").mkdir(parents=True)
|
|
(base / "game/Bin/Game.exe").write_bytes(b"MZ12")
|
|
(base / "game/Bin/CrashReportClient.exe.disabled").write_bytes(b"MZ")
|
|
(base / "launch.sh").write_text("#!/bin/sh\n")
|
|
(base / "deployment.json").write_text(json.dumps({
|
|
"package": "rift.g", "kind": "pcvr", "base": str(base),
|
|
"files": {"game": {"Bin/Game.exe": 4, "Bin/Data.pak": 10, "Bin/CrashReportClient.exe": 2}}}))
|
|
r = a.cmd_list_files({"package": "rift.g"})
|
|
assert len(r["roots"]) == 1 and r["roots"][0]["name"] == "Install folder" # the anchor is the same folder
|
|
files = dict(map(tuple, r["roots"][0]["files"]))
|
|
assert files["game/Bin/Game.exe"] == 4 and "launch.sh" in files
|
|
assert r["missing"] == [["game/Bin/Data.pak", 10, None]] # the renamed crash reporter isn't "missing"
|
|
assert not r["truncated"]
|
|
|
|
|
|
def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
deployment = {"package": "com.x.y", "appid": 2546384938, "base": str(anchor)}
|
|
(anchor / "deployment.json").write_text(json.dumps(deployment))
|
|
calls = []
|
|
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
|
|
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
cfg.mkdir(parents=True)
|
|
with pytest.raises(a.AgentError, match="not in the Frame's Steam library"):
|
|
a.cmd_launch({"package": "com.x.y"})
|
|
exe = f'"{anchor}/launch.sh"'
|
|
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, "Old title", str(anchor)) # keeps its first id
|
|
r = a.cmd_launch({"package": "com.x.y"})
|
|
assert r["gameid"] == (a.shortcut_appid(exe, "Old title") << 32) | 0x02000000
|
|
assert calls[-1][-1] == f"steam://rungameid/{r['gameid']}" and calls[-1][0] == "systemd-run"
|
|
|
|
|
|
def test_library_users_prefers_the_signed_in_account(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
steam = tmp_path / ".local/share/Steam"
|
|
with pytest.raises(a.AgentError, match="signed-in"):
|
|
a.library_users()
|
|
for u in ("12345", "999"):
|
|
(steam / "userdata" / u / "config").mkdir(parents=True)
|
|
assert a.library_users() == ["12345", "999"] # unknown: every account (used to fail with 2 accounts)
|
|
(steam / "config").mkdir()
|
|
(steam / "config/loginusers.vdf").write_text(
|
|
'"users"\n{\n\t"76561197960278073"\n\t{\n\t\t"MostRecent"\t\t"1"\n\t}\n'
|
|
'\t"76561197960266727"\n\t{\n\t\t"MostRecent"\t\t"0"\n\t}\n}\n')
|
|
assert a.library_users() == ["12345", "999"] # every account, the signed-in one first
|
|
|
|
|
|
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
(anchor / "lepton-app").mkdir(parents=True)
|
|
(anchor / "lepton-app/game.apk").write_bytes(b"PK")
|
|
(anchor / "lepton-data/saves").mkdir(parents=True)
|
|
(anchor / "lepton-data/saves/slot1").write_text("progress")
|
|
(anchor / "launch.sh").write_text("#!/bin/sh\n")
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 1}))
|
|
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
|
assert a.cmd_uninstall({"package": "com.x.y"})["removed"]
|
|
assert not a.cmd_list_installed({})["games"] # no longer reported as installed
|
|
assert (anchor / "lepton-data/saves/slot1").read_text() == "progress" # saves kept
|
|
|
|
|
|
|
|
def test_uninstall_quest_deleting_data_removes_saves_and_mods(monkeypatch, tmp_path):
|
|
"""GitHub #130: the Uninstall dialog's "Also delete its saves…" box (keep_data False) removes lepton-data too."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
(anchor / "lepton-app").mkdir(parents=True)
|
|
(anchor / "lepton-data/external/ModData/songs").mkdir(parents=True)
|
|
(anchor / "lepton-data/external/ModData/songs/old.zip").write_bytes(b"x")
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 1}))
|
|
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
|
assert a.cmd_uninstall({"package": "com.x.y", "keep_data": False})["removed"]
|
|
assert not (anchor / "lepton-data").exists()
|
|
|
|
def test_prune_shortcuts_on_relaunch_change(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = str(tmp_path / "shortcuts.vdf")
|
|
old = a.upsert_shortcut(vdf, '"C:\\Revive\\ReviveInjector.exe"', "Vader", "/d", tag="Rift via Revive",
|
|
tags=["Rift via Revive"])
|
|
a.upsert_shortcut(vdf, '"C:\\Other.exe"', "Other game", "/d", tag="Rift via Revive", tags=["Rift via Revive"])
|
|
removed = a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', "Rift via Revive") # new direct launch
|
|
assert removed == [old]
|
|
root = a.vdf_decode(open(vdf, "rb").read())
|
|
names = {v.get("appname") for v in root["shortcuts"].values() if isinstance(v, dict)}
|
|
assert names == {"Other game"} # the stale Vader entry is gone, the unrelated one stays
|
|
# re-adding Vader directly, then pruning again, is a no-op for the kept entry
|
|
a.upsert_shortcut(vdf, '"C:\\game\\WKND.exe"', "Vader", "/d", tag="Rift via Revive", tags=["Rift via Revive"])
|
|
assert a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', "Rift via Revive") == []
|
|
|
|
|
|
def test_libovr_redirect_symlink(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "b"
|
|
(base / "game/Bin/Win64").mkdir(parents=True)
|
|
(base / "game/Bin/Win64/Game.exe").write_bytes(b"MZ")
|
|
(base / "revive").mkdir()
|
|
(base / "revive/LibReviveXR64.dll").write_bytes(b"REVIVE")
|
|
# an Unreal-style OVRPlugin.dll in its own dir -> the redirect must land there too
|
|
(base / "game/Engine/Plug/OVRPlugin/Win64").mkdir(parents=True)
|
|
(base / "game/Engine/Plug/OVRPlugin/Win64/OVRPlugin.dll").write_bytes(b"MZ")
|
|
exe_rel = "Bin/Win64/Game.exe"
|
|
link = base / "game/Bin/Win64/LibOVRRT64_1.dll"
|
|
plugin_link = base / "game/Engine/Plug/OVRPlugin/Win64/LibOVRRT64_1.dll"
|
|
a.set_libovr_redirect(str(base), exe_rel, enabled=True)
|
|
assert link.is_symlink() and link.read_bytes() == b"REVIVE" # redirect to Revive's runtime (exe dir)
|
|
assert plugin_link.is_symlink() and plugin_link.read_bytes() == b"REVIVE" # and next to OVRPlugin.dll
|
|
assert not (base / "game/Bin/Win64/LibOVRRT32_1.dll").exists() # no 32-bit Revive dll -> not created
|
|
# disabling removes our symlink
|
|
a.set_libovr_redirect(str(base), exe_rel, enabled=False)
|
|
assert not link.exists()
|
|
# never clobber a real game-shipped LibOVRRT
|
|
real = base / "game/Bin/Win64/LibOVRRT64_1.dll"
|
|
real.write_bytes(b"REAL")
|
|
a.set_libovr_redirect(str(base), exe_rel, enabled=True)
|
|
assert not real.is_symlink() and real.read_bytes() == b"REAL"
|
|
|
|
|
|
def test_libovr_redirect_to_bundled_revive(monkeypatch, tmp_path):
|
|
"""A repack's own LibRevive64.dll (no FramePort Revive) becomes the LibOVRRT the game loads on the Frame."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "b"
|
|
(base / "game/G").mkdir(parents=True)
|
|
(base / "game/G/Game.exe").write_bytes(b"MZ")
|
|
(base / "game/G/LibRevive64.dll").write_bytes(b"BUNDLED")
|
|
(base / "revive").mkdir()
|
|
(base / "revive/LibReviveXR64.dll").write_bytes(b"REVIVE")
|
|
link = base / "game/G/LibOVRRT64_1.dll"
|
|
a.set_libovr_redirect(str(base), "G/Game.exe", enabled=True)
|
|
assert link.read_bytes() == b"REVIVE"
|
|
a.set_libovr_redirect(str(base), "G/Game.exe", enabled=True, bundled=True) # switches target
|
|
assert link.is_symlink() and link.read_bytes() == b"BUNDLED"
|
|
a.set_libovr_redirect(str(base), "G/Game.exe", enabled=False, bundled=True)
|
|
assert not link.exists() and (base / "game/G/LibRevive64.dll").read_bytes() == b"BUNDLED"
|
|
|
|
|
|
def test_proton_launcher_game_args(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a, "compat_command", lambda d: ["/proton", "waitforexitandrun"])
|
|
anchor = tmp_path / "anchor"
|
|
anchor.mkdir()
|
|
a.write_proton_launcher(str(anchor), "/b", "rift.x", "X", 1, {"dir": "/p", "name": "proton"}, "G/X.exe", False,
|
|
{"WINEDLLOVERRIDES": "xinput1_3=n,b"}, game_args=["-vrmode", "OpenVR", "-hmd=OpenXR",
|
|
"$(rm -rf /)"])
|
|
text = (anchor / "launch.sh").read_text()
|
|
assert "/b/game/G/X.exe -vrmode OpenVR -hmd=OpenXR >>" in text and "rm -rf" not in text
|
|
assert "export WINEDLLOVERRIDES=xinput1_3=n,b" in text
|
|
|
|
|
|
PNG_1PX = bytes.fromhex("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63f8"
|
|
"cfc0f01f0005000201a5d6f1c80000000049454e44ae426082")
|
|
|
|
|
|
def _fake_render_model(root, name, grip_origin=(0.0, 0.0, 0.0), grip_rot=(0, 0, 0)):
|
|
d = root / "resources/rendermodels" / name
|
|
d.mkdir(parents=True)
|
|
(d / "diffuse.png").write_bytes(PNG_1PX)
|
|
(d / "body.mtl").write_text("newmtl skin\nmap_Kd diffuse.png\n")
|
|
# a quad (fan-triangulated) with v/vt/vn corners, plus a negative-index face
|
|
(d / "body.obj").write_text("mtllib body.mtl\nusemtl skin\n"
|
|
"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nvn 0 0 1\n"
|
|
"f 1/1/1 2/2/1 3/3/1 4/4/1\nf -4/-4/-1 -3/-3/-1 -1/-1/-1\n")
|
|
(d / "status.obj").write_text("v 5 5 5\nv 6 5 5\nv 5 6 5\nf 1 2 3\n")
|
|
(d / f"{name}.json").write_text(json.dumps({"components": {
|
|
"body": {"filename": "body.obj"}, "status": {"filename": "status.obj"},
|
|
"openxr_grip": {"component_local": {"origin": list(grip_origin), "rotate_xyz": list(grip_rot)}}}}))
|
|
return d
|
|
|
|
|
|
def _read_glb(data):
|
|
import struct as st
|
|
magic, version, total = st.unpack_from("<III", data)
|
|
assert magic == 0x46546C67 and version == 2 and total == len(data)
|
|
jlen, jtype = st.unpack_from("<I4s", data, 12)
|
|
assert jtype == b"JSON" and jlen % 4 == 0
|
|
gltf = json.loads(data[20:20 + jlen])
|
|
blen, btype = st.unpack_from("<I4s", data, 20 + jlen)
|
|
assert btype == b"BIN\x00" and blen == gltf["buffers"][0]["byteLength"]
|
|
return gltf, data[28 + jlen:28 + jlen + blen]
|
|
|
|
|
|
def test_controller_models_pick_and_convert(monkeypatch, tmp_path):
|
|
import struct as st
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
root = tmp_path / "steamvr"
|
|
_fake_render_model(root, "valve_frame_controller_left", grip_origin=(1.0, 0.0, 0.0))
|
|
_fake_render_model(root, "valve_frame_controller_right", grip_rot=(0, 0, 90))
|
|
_fake_render_model(root, "vr_controller_vive_1_5")
|
|
dirs = a.render_model_dirs([str(root)])
|
|
assert set(dirs) == {"valve_frame_controller_left", "valve_frame_controller_right", "vr_controller_vive_1_5"}
|
|
picked = a.pick_controller_models(dirs)
|
|
assert picked == {"left": dirs["valve_frame_controller_left"], "right": dirs["valve_frame_controller_right"]}
|
|
assert a.pick_controller_models({"frame_left": "/x"}) == {} # needs both sides
|
|
assert a.model_side("controller_r") == "right" and a.model_side("hmd") is None
|
|
|
|
gltf, blob = _read_glb(a.controller_glb(picked["left"]))
|
|
prim = gltf["meshes"][0]["primitives"]
|
|
assert len(prim) == 1 # status component skipped, one texture
|
|
acc = gltf["accessors"]
|
|
pos = acc[prim[0]["attributes"]["POSITION"]]
|
|
assert pos["count"] == 4 and acc[prim[0]["indices"]]["count"] == 9 # 4 unique corners, 3 triangles
|
|
# grip origin (1, 0, 0) -> vertices shifted by -1 on x
|
|
assert pos["min"] == [-1.0, 0.0, 0.0] and pos["max"] == [0.0, 1.0, 0.0]
|
|
assert gltf["images"][0]["mimeType"] == "image/png"
|
|
view = gltf["bufferViews"][gltf["images"][0]["bufferView"]]
|
|
assert blob[view["byteOffset"]:view["byteOffset"] + view["byteLength"]] == PNG_1PX
|
|
uv_view = gltf["bufferViews"][acc[prim[0]["attributes"]["TEXCOORD_0"]]["bufferView"]]
|
|
uvs = st.unpack_from("<8f", blob, uv_view["byteOffset"])
|
|
assert uvs[:2] == (0.0, 1.0) # OBJ (0, 0) -> glTF (0, 1)
|
|
|
|
# 90° about z: raw +x becomes grip -y (R^T applied)
|
|
gltf, blob = _read_glb(a.controller_glb(picked["right"]))
|
|
pos = gltf["accessors"][gltf["meshes"][0]["primitives"][0]["attributes"]["POSITION"]]
|
|
x = st.unpack_from("<6f", blob, gltf["bufferViews"][pos["bufferView"]]["byteOffset"])
|
|
assert abs(x[3]) < 1e-6 and abs(x[4] + 1.0) < 1e-6 # vertex (1, 0, 0)
|
|
|
|
|
|
def test_controller_models_install(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
root = tmp_path / "steamvr"
|
|
_fake_render_model(root, "frame_controller_left")
|
|
_fake_render_model(root, "frame_controller_right")
|
|
monkeypatch.setattr(a, "steamvr_roots", lambda: [str(root)])
|
|
files = tmp_path / "files"
|
|
result = a.install_controller_models(str(files), True)
|
|
assert result["ok"] and set(result["sources"]) == {"left", "right"}
|
|
left = files / "framebridge/controller_left.glb"
|
|
assert left.read_bytes()[:4] == b"glTF" and (files / "framebridge/controller_right.glb").exists()
|
|
assert len(list((tmp_path / ".local/share/frameport/controller-models").glob("*.glb"))) == 2 # cached
|
|
assert a.install_controller_models(str(files), False) is None and not left.exists()
|
|
monkeypatch.setattr(a, "steamvr_roots", lambda: [])
|
|
missing = a.install_controller_models(str(files), True)
|
|
assert missing["ok"] is False and "no Steam Frame controller render models" in missing["error"]
|
|
assert a.cmd_controller_models({})["picked"] == {}
|
|
|
|
|
|
def test_storage_targets_follow_lepton(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
lepton = tmp_path / "Lepton"
|
|
(lepton / "liblepton").mkdir(parents=True)
|
|
(lepton / "lepton").write_text("#!/bin/sh\n")
|
|
(lepton / "liblepton/mounting.sh").write_text(
|
|
'ln -s "${HOME}/Videos" "${TARGET_PATH}/Movies"\n'
|
|
'ln -s "${HOME}/Downloads" "${TARGET_PATH}/Download"\n')
|
|
monkeypatch.setattr(a, "lepton_path", lambda: (str(lepton / "lepton"), None))
|
|
base = tmp_path / "Applications/quest-frame/com.x.y"
|
|
base.mkdir(parents=True)
|
|
(base / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(base)}))
|
|
t = {x["id"]: x for x in a.cmd_storage_targets({"package": "com.x.y"})["targets"]}
|
|
assert t["videos"]["android"] == "/sdcard/Movies" and t["videos"]["shared"]
|
|
assert t["downloads"]["android"] == "/sdcard/Download" and "documents" not in t # read from Lepton, not assumed
|
|
assert (tmp_path / "Videos").is_dir() # Lepton only links folders that exist
|
|
assert t["app"]["path"].endswith("lepton-data/external") and t["app"]["android"] == "/sdcard"
|
|
assert t["app-files"]["android"] == "/sdcard/Android/data/com.x.y/files"
|
|
# without Lepton's script: the known defaults
|
|
monkeypatch.setattr(a, "lepton_path", lambda: (None, None))
|
|
ids = {x["id"] for x in a.cmd_storage_targets({})["targets"]}
|
|
assert ids == {"documents", "downloads", "videos"}
|
|
|
|
|
|
def test_link_media_into_app_own_folder(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a, "lepton_path", lambda: (None, None)) # default shared folders
|
|
base = tmp_path / "Applications/quest-frame/cn.player"
|
|
ext = base / "lepton-data/external"
|
|
for d in ("4XPlayer", "Android", "DCIM", "Music", ".hidden"):
|
|
(ext / d).mkdir(parents=True)
|
|
(tmp_path / "Videos").mkdir()
|
|
(ext / "Movies").symlink_to(tmp_path / "Videos") # Lepton's link to the shared folder: not the app's own
|
|
(base / "deployment.json").write_text(json.dumps({"package": "cn.player", "base": str(base)}))
|
|
assert a.app_media_dirs(str(ext)) == ["4XPlayer"]
|
|
t = {x["id"]: x for x in a.cmd_storage_targets({"package": "cn.player"})["targets"]}
|
|
assert t["app-media"]["android"] == "/sdcard/4XPlayer"
|
|
video = tmp_path / "Videos/a_360.mp4"
|
|
video.write_bytes(b"x" * 10)
|
|
r = a.cmd_link_media({"package": "cn.player", "files": [str(video), str(tmp_path / "Videos/gone.mp4")]})
|
|
assert r["folder"] == "4XPlayer" and r["linked"] == ["a_360.mp4"] and r["missing"]
|
|
assert (ext / "4XPlayer/a_360.mp4").stat().st_ino == video.stat().st_ino # a hard link, not a copy
|
|
assert a.cmd_link_media({"package": "cn.player", "files": [str(video)]})["existing"] == ["a_360.mp4"]
|
|
with pytest.raises(a.AgentError): # only files from the shared folders
|
|
a.cmd_link_media({"package": "cn.player", "files": [str(base / "deployment.json")]})
|
|
|
|
|
|
def test_grid_files_match_exact_appids(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
grid = tmp_path / "grid"
|
|
grid.mkdir()
|
|
for n in ("123p.jpg", "123.png", "123_hero.jpg", "123_logo.png", "1234p.jpg", "12345_hero.jpg", "notes.txt"):
|
|
(grid / n).write_text("x")
|
|
assert sorted(p.rsplit("/", 1)[1] for p in a.grid_files(str(grid), 123)) == \
|
|
["123.png", "123_hero.jpg", "123_logo.png", "123p.jpg"]
|
|
|
|
|
|
def test_vdf_backups_are_capped(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
vdf.write_bytes(b"x")
|
|
for i in range(9):
|
|
(tmp_path / f"shortcuts.vdf.backup-2026010{i}-000000").write_bytes(b"old")
|
|
a.backup_vdf(str(vdf))
|
|
assert len(list(tmp_path.glob("shortcuts.vdf.backup-*"))) == a.VDF_BACKUPS
|
|
|
|
|
|
def test_finalize_checks_the_data_before_replacing_the_game(monkeypatch, tmp_path):
|
|
"""A failed data check must leave the installed APK and data as they were."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "Applications/quest-frame/com.x.y"
|
|
app = base / "lepton-app"
|
|
(app / "obb").mkdir(parents=True)
|
|
(app / "game.apk").write_bytes(b"OLD")
|
|
inc = base / "incoming"
|
|
(inc / "obb").mkdir(parents=True)
|
|
(inc / "game.apk").write_bytes(b"NEW")
|
|
monkeypatch.setattr(a, "cmd_prepare", lambda args: {"base": str(base), "anchor": str(base), "appid": 1,
|
|
"incoming": str(inc), "lepton": "/lepton"})
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_finalize({"package": "com.x.y", "title": "X", "obb_manifest": {"main.obb": 10}})
|
|
assert (app / "game.apk").read_bytes() == b"OLD" and (inc / "game.apk").exists()
|
|
|
|
|
|
def test_uninstall_removes_the_shortcut_with_steam_closed(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 7}))
|
|
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
|
monkeypatch.setattr(a, "steam_users", lambda: ["1"])
|
|
started = []
|
|
monkeypatch.setattr(a, "run", lambda cmd, *k, **kw: started.append(cmd) or SimpleNamespace(returncode=0, stdout=""))
|
|
r = a.cmd_uninstall({"package": "com.x.y", "remove_shortcut": True})
|
|
assert r["shortcut_removed"] and started and started[0][0] == "systemd-run" # the detached worker, not a live edit
|
|
payload = json.loads(started[0][-1])
|
|
assert payload["remove"] == [{"exe": f'"{anchor}/launch.sh"', "appid": 7}]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["later", "off"])
|
|
def test_uninstall_with_library_mode_later_or_off(monkeypatch, tmp_path, mode):
|
|
"""#305: in "later" the removal is queued and in "off" nothing starts; uninstall must still finish (found on
|
|
the device: KeyError 'started' after the removal was already queued, the game stayed installed)."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "base": str(anchor), "appid": 7}))
|
|
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
|
monkeypatch.setattr(a, "steam_users", lambda: ["1"])
|
|
monkeypatch.setattr(a, "library_mode", lambda args=None: mode)
|
|
monkeypatch.setattr(a, "ensure_library_hooks", lambda: None)
|
|
monkeypatch.setattr(a, "run", lambda cmd, *k, **kw: SimpleNamespace(returncode=0, stdout=""))
|
|
r = a.cmd_uninstall({"package": "com.x.y", "remove_shortcut": True})
|
|
# "off" still removes an existing shortcut right away (only adding is off); "later" queues it
|
|
assert r["removed"] and r["shortcut_removed"] == (mode == "off")
|
|
|
|
|
|
def test_prune_also_matches_the_earlier_tag_name(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = str(tmp_path / "shortcuts.vdf")
|
|
old = a.upsert_shortcut(vdf, '"C:\\Revive\\ReviveInjector.exe"', "Vader", "/d", tag="Rift via Revive")
|
|
assert a.prune_shortcuts(vdf, "Vader", '"C:\\game\\WKND.exe"', ("FramePort PC VR", "Rift via Revive")) == [old]
|
|
|
|
|
|
def test_flatscreen_marker_for_android_apps_without_vr(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
a.set_flatscreen(str(tmp_path), True)
|
|
assert (tmp_path / "lepton-show-flatscreen").exists()
|
|
a.set_flatscreen(str(tmp_path), True) # idempotent
|
|
a.set_flatscreen(str(tmp_path), False)
|
|
assert not (tmp_path / "lepton-show-flatscreen").exists()
|
|
a.set_flatscreen(str(tmp_path), False)
|
|
|
|
|
|
|
|
def test_launcher_exports_multiline_env_values(monkeypatch, tmp_path):
|
|
"""device.hide_navbar's value has a newline (a second Android property line): it must survive launch.sh."""
|
|
import subprocess
|
|
|
|
from frameport.patches.settings import HideNavBar
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
a.write_launcher(str(tmp_path), "/b", "p", "T", 1, "/l", HideNavBar.ENV)
|
|
script = (tmp_path / "launch.sh").read_text()
|
|
line = next(x for x in script.split("export ") if x.startswith("LEPTON_GFXRECON_FP_PROPS="))
|
|
line = line[:line.index("\n", line.index("mainkeys"))]
|
|
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
|
|
|
|
|
|
def test_purge_without_saves_removes_container_workdirs_and_stale_shortcuts(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
fake_steam_tools(a, tmp_path)
|
|
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
|
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
|
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
|
users = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
(users / "grid").mkdir(parents=True)
|
|
q = Path(a.ANCHORS) / "com.x.y"
|
|
work = q / "lepton-data" / "baked" / "data_workdir" / "work" # overlayfs leaves these with mode 000
|
|
(work / "inner").mkdir(parents=True)
|
|
(work / "inner" / "f").write_text("x")
|
|
(q / "launch.sh").write_text("#!/bin/sh")
|
|
(q / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 7, "base": str(q), "title": "Q"}))
|
|
os.chmod(work / "inner", 0)
|
|
os.chmod(work, 0)
|
|
vdf = str(users / "shortcuts.vdf")
|
|
a.upsert_shortcut(vdf, f'"{q}/launch.sh"', "Q", str(q))
|
|
stale_exe = f'"{a.ANCHORS}/com.gone/launch.sh"' # no install record any more
|
|
stale = a.upsert_shortcut(vdf, stale_exe, "Gone", "/x")
|
|
a.upsert_shortcut(vdf, '"/usr/bin/other"', "Not ours", "/x")
|
|
(users / "grid" / f"{stale}p.jpg").write_bytes(b"x")
|
|
a.purge_worker(json.dumps({"keep_saves": False, "status": str(tmp_path / "st.json")}))
|
|
st = json.loads((tmp_path / "st.json").read_text())
|
|
assert st["state"] == "done" and not st["errors"], st
|
|
assert not Path(a.ANCHORS).exists()
|
|
names = [s["appname"] for s in a.vdf_decode((users / "shortcuts.vdf").read_bytes())["shortcuts"].values()]
|
|
assert names == ["Not ours"] and not (users / "grid" / f"{stale}p.jpg").exists()
|
|
|
|
|
|
def test_battery_state(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
ps = tmp_path / "power_supply"
|
|
|
|
def supply(name, **files):
|
|
(ps / name).mkdir(parents=True)
|
|
for k, v in files.items():
|
|
(ps / name / k).write_text(v + "\n")
|
|
monkeypatch.setattr(a, "POWER_SUPPLY", str(ps))
|
|
assert a.battery_state() is None # no power_supply folder (or no battery): nothing to show
|
|
supply("battery", type="Battery", capacity="42", status="Discharging")
|
|
supply("usb", type="USB", online="0")
|
|
assert a.battery_state() == {"percent": 42, "status": "Discharging", "plugged": False, "draining": True}
|
|
(ps / "usb" / "online").write_text("1\n") # cable in, battery not charging yet ("Not charging"): still plugged
|
|
assert a.battery_state()["plugged"] is True
|
|
assert a.cmd_battery({})["battery"]["percent"] == 42
|
|
|
|
|
|
def test_virtual_keyboard_events_text_and_release(monkeypatch, tmp_path):
|
|
import io
|
|
import struct as st
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
written = []
|
|
kb = a.VirtualKeyboard(fd="fake", write=lambda fd, data: written.append(st.unpack("llHHi", data)[2:]))
|
|
kb.key(30, 1) # 'a' down: EV_KEY then SYN_REPORT
|
|
assert written == [(1, 30, 1), (0, 0, 0)] and kb.held == {30}
|
|
written.clear()
|
|
assert kb.type_text("A!\N{SNOWMAN}") == "\N{SNOWMAN}" # US layout; what it can't type is reported
|
|
keys = [(c, v) for t, c, v in written if t == 1]
|
|
assert keys == [(42, 1), (30, 1), (30, 0), (42, 0), (42, 1), (2, 1), (2, 0), (42, 0)]
|
|
kb.key(57, 1) # space held down when the PC goes away
|
|
written.clear()
|
|
kb.close() # released before the device goes away
|
|
assert (1, 57, 0) in written and kb.held == set()
|
|
# session protocol: ready line, keys and text from JSON lines, keyboard closed at EOF
|
|
out = io.StringIO()
|
|
kb2 = a.VirtualKeyboard(fd="fake", write=lambda fd, data: None)
|
|
closed = []
|
|
kb2.close = lambda: closed.append(1)
|
|
lines = io.StringIO('{"k": 28, "v": 1}\n{"k": 28, "v": 0}\nnot json\n{"text": "hi"}\n')
|
|
assert a.keyboard_session(lines, out, keyboard=kb2) == 0
|
|
replies = [json.loads(x) for x in out.getvalue().splitlines()]
|
|
assert replies[0] == {"ready": True} and replies[1] == {"typed": True, "skipped": ""} and closed
|
|
|
|
|
|
def test_keyboard_session_reports_a_refused_device(monkeypatch, tmp_path):
|
|
import io
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
|
|
def refuse(*args, **kw):
|
|
raise PermissionError(13, "Permission denied", "/dev/uinput")
|
|
monkeypatch.setattr(a, "VirtualKeyboard", refuse)
|
|
out = io.StringIO()
|
|
assert a.keyboard_session(io.StringIO(""), out) == 1
|
|
assert json.loads(out.getvalue())["ready"] is False
|
|
|
|
|
|
def test_boot_state_finds_unclean_previous_boot_and_last_launch(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "game"
|
|
base.mkdir()
|
|
(base / "launch.log").write_text("x")
|
|
os.utime(base / "launch.log", (1000, 1000))
|
|
monkeypatch.setattr(a, "cmd_list_installed", lambda args: {"games": [{"package": "com.x", "title": "X",
|
|
"base": str(base)}]})
|
|
real_open = open
|
|
|
|
def fake_open(path, *args, **kw):
|
|
if path == "/proc/sys/kernel/random/boot_id":
|
|
from io import StringIO
|
|
return StringIO("abc\n")
|
|
if path == "/proc/stat":
|
|
from io import StringIO
|
|
return StringIO("cpu 1 2 3\nbtime 2000\n")
|
|
return real_open(path, *args, **kw)
|
|
monkeypatch.setattr(a, "open", fake_open, raising=False)
|
|
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="kernel: msm_dpu ... fault\n"))
|
|
st = a.boot_state()
|
|
assert st == {"boot_id": "abc", "boot_time": 2000, "prev_clean": False,
|
|
"last_launch": {"package": "com.x", "title": "X", "time": 1000.0}}
|
|
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="Reached target System Power Off\n"))
|
|
assert a.boot_state()["prev_clean"] is False # cached for this boot: worked out once
|
|
|
|
|
|
def test_flat_windows_game_launcher_and_shortcut(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a, "compat_command", lambda d: ["/proton", "waitforexitandrun"])
|
|
anchor, base = tmp_path / "anchor", tmp_path / "base"
|
|
anchor.mkdir()
|
|
tool = {"dir": "/tools/proton", "name": "Proton 11"}
|
|
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {}, vr=False)
|
|
flat = (anchor / "launch.sh").read_text()
|
|
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {})
|
|
vr = (anchor / "launch.sh").read_text()
|
|
assert "SteamGameId" not in flat and "export SteamGameId=123" in vr # Proton sets up VR only with it
|
|
assert "STEAM_COMPAT_APP_ID=123" in flat
|
|
vdf = str(tmp_path / "shortcuts.vdf")
|
|
a.upsert_shortcut(vdf, '"/x/launch.sh"', "Game", "/x", tag="Windows game on Frame", openvr=False)
|
|
entry = a.vdf_decode(open(vdf, "rb").read())["shortcuts"]["0"]
|
|
assert entry["OpenVR"] == 0 and entry["tags"]["0"] == "Windows game on Frame"
|
|
|
|
|
|
def test_reinstall_with_unchanged_shortcut_does_not_restart_steam(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
(anchor / "artwork").mkdir(parents=True)
|
|
(anchor / "artwork/portrait.jpg").write_bytes(b"art")
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
|
|
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
cfg.mkdir(parents=True)
|
|
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
|
|
assert a.library_changes(["42"], ["com.x.y"]) # not in the library yet
|
|
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
|
|
assert not a.library_changes(["42"], ["com.x.y"]) # a reinstall: nothing to change
|
|
stops = []
|
|
monkeypatch.setattr(a, "stop_steam", lambda: stops.append(1) or True)
|
|
monkeypatch.setattr(a, "start_steam", lambda s: stops.append(2))
|
|
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
|
|
status = json.load(open(a.STATUS_FILE))
|
|
assert not stops and status["state"] == "done" and status["unchanged"]
|
|
assert status["added"][0]["package"] == "com.x.y"
|
|
assert (cfg / "grid").is_dir() and any(p.name.endswith("p.jpg") for p in (cfg / "grid").iterdir())
|
|
|
|
|
|
|
|
def test_devkit_games_get_art_without_a_steam_restart(monkeypatch, tmp_path):
|
|
# GitHub #41: on Frames whose Steam drops FramePort's shortcuts.vdf entries, every install/art update restarted
|
|
# Steam, which also dropped the live devkit entry: the next Play then restarted Steam once more
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
(anchor / "artwork").mkdir(parents=True)
|
|
(anchor / "artwork/portrait.jpg").write_bytes(b"art")
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
|
|
(tmp_path / ".local/share/Steam/userdata/42/config").mkdir(parents=True)
|
|
monkeypatch.setattr(a, "devkit_gameid", lambda pkg: "X")
|
|
monkeypatch.setattr(a, "devkit_appid", lambda gid: 777)
|
|
art = []
|
|
monkeypatch.setattr(a, "copy_grid_art", lambda pkg, appid, users=None: art.append((pkg, appid)))
|
|
stops = []
|
|
monkeypatch.setattr(a, "stop_steam", lambda: stops.append(1) or True)
|
|
monkeypatch.setattr(a, "start_steam", lambda s: stops.append(2))
|
|
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
|
|
status = json.load(open(a.STATUS_FILE))
|
|
assert not stops and art == [("com.x.y", 777)] and status["devkit"] == ["com.x.y"] and status["state"] == "done"
|
|
|
|
|
|
def test_launch_re_adds_a_forgotten_devkit_entry_live(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 111, "title": "X",
|
|
"base": str(anchor)}))
|
|
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=0, stdout=""))
|
|
monkeypatch.setattr(a, "shortcut_appid_for", lambda exe: None) # Steam has no shortcut either
|
|
monkeypatch.setattr(a, "devkit_gameid", lambda pkg: "X")
|
|
monkeypatch.setattr(a, "devkit_appid", lambda gid: None) # Steam restarted and forgot the devkit entry
|
|
monkeypatch.setattr(a, "devkit_register", lambda pkg: 333)
|
|
monkeypatch.setattr(a, "steam_launch", lambda appid, wait=10: {"result": "started", "appid": appid})
|
|
got = a.cmd_launch({"package": "com.x.y"}) # no NOT_IN_LIBRARY (whose repair restarts Steam)
|
|
assert got["via"] == "devkit" and got["steam"]["appid"] == 333
|
|
|
|
def test_library_falls_back_to_a_devkit_entry_when_steam_drops_the_shortcut(monkeypatch, tmp_path):
|
|
# this Frame's Steam drops every shortcuts.vdf entry on restart (GitHub #41): "Add to Steam library" failed every
|
|
# time with "Steam removed the new library entry again"; the devkit entry is the one Steam keeps
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
|
|
(tmp_path / ".local/share/Steam/userdata/42/config").mkdir(parents=True)
|
|
monkeypatch.setattr(a, "library_users", lambda: ["42"])
|
|
monkeypatch.setattr(a, "game_running", lambda: False)
|
|
monkeypatch.setattr(a, "desktop_mode", lambda: False)
|
|
monkeypatch.setattr(a, "stop_steam", lambda: True)
|
|
monkeypatch.setattr(a, "start_steam", lambda s: None)
|
|
monkeypatch.setattr(a, "shortcuts_lost", lambda users, pkgs, wait=25: list(pkgs))
|
|
registered = []
|
|
monkeypatch.setattr(a, "devkit_register", lambda pkg: registered.append(pkg) or 333)
|
|
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
|
|
status = json.load(open(a.STATUS_FILE))
|
|
assert registered == ["com.x.y"] and status["state"] == "done" and not status["errors"]
|
|
assert status["added"] == [{"package": "com.x.y", "appid": 333, "expected": 1, "via": "devkit"}]
|
|
|
|
monkeypatch.setattr(a, "devkit_register", lambda pkg: (_ for _ in ()).throw(a.AgentError("no answer")))
|
|
(tmp_path / ".local/share/Steam/userdata/42/config/shortcuts.vdf").unlink() # Steam dropped it, as above
|
|
a.shortcuts_worker(json.dumps({"packages": ["com.x.y"], "remove": []}))
|
|
status = json.load(open(a.STATUS_FILE))
|
|
assert not status["added"] and "Steam removed the new library entry" in status["errors"][0]
|
|
assert "no answer" in status["errors"][0]
|
|
|
|
|
|
def test_shortcuts_lost_after_steam_restart(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
|
|
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
cfg.mkdir(parents=True)
|
|
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=0, stdout=""))
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
assert a.shortcuts_lost(["42"], ["com.x.y"]) == ["com.x.y"] # Steam put its old shortcuts.vdf back
|
|
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
|
|
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
|
|
assert a.shortcuts_lost(["42"], ["com.x.y"]) == []
|
|
|
|
|
|
def test_steam_launch_result_reads_steams_log(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
log = tmp_path / "console_log.txt"
|
|
old = "[2026-10-04 12:00:00] SteamUI: WARNING: LaunchGameAction: launch error 9 3554078061 LaunchApp AppError_18\n"
|
|
log.write_text(old)
|
|
start = log.stat().st_size # lines from before this launch don't count
|
|
assert a.steam_launch_result(str(log), start, 3554078061, wait=0)["result"] == "silent"
|
|
with open(log, "a") as f: # GitHub #21/#30: Steam's "Game configuration unavailable"
|
|
f.write("[2026-10-04 12:09:52] SteamUI: WARNING: LaunchGameAction: launch error 9 3554078061 LaunchApp "
|
|
"AppError_18 \n")
|
|
got = a.steam_launch_result(str(log), start, 3554078061, wait=0)
|
|
assert got["result"] == "error" and got["code"] == 9 and got["detail"] == "LaunchApp AppError_18"
|
|
log.write_text(old + "[2026-10-04 19:12:16] GameAction [AppID 2369265159, ActionID 4] : LaunchApp changed task "
|
|
"to CreatingProcess with \"\"\n")
|
|
assert a.steam_launch_result(str(log), start, 2369265159, wait=0)["result"] == "started"
|
|
|
|
|
|
def test_steam_library_report(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 1, "title": "X", "base": "b"}))
|
|
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
cfg.mkdir(parents=True)
|
|
exe, title, start, icon, tag, tags, openvr = a.shortcut_args("com.x.y")
|
|
appid = a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, title, start, icon, tag, tags=tags, openvr=openvr)
|
|
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), '"/usr/bin/other"', "Other", "/", "")
|
|
logs = tmp_path / ".local/share/Steam/logs"
|
|
logs.mkdir()
|
|
(logs / "console_log.txt").write_text(f"noise\n[x] GameAction [AppID {appid}, ActionID 1] : LaunchApp changed "
|
|
"task to Completed\n path_shortcut: \"/a.desktop\"\n")
|
|
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=1, stdout=""))
|
|
rep, console = a.steam_library_report()
|
|
acc = rep["accounts"][0]
|
|
assert acc["shortcuts"] == 2 and acc["exe_dupes"] == 0 and acc["shortcuts_vdf_written"]
|
|
assert acc["frameport"] == [{"package": "com.x.y", "appid": appid, "title": "X", "start_dir": start,
|
|
"openvr": acc["frameport"][0]["openvr"], "options": acc["frameport"][0]["options"]}]
|
|
assert f"[AppID {appid}" in console and "noise" not in console and "path_shortcut" not in console
|
|
|
|
|
|
def test_steam_launch_result_app_error(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
log = tmp_path / "console_log.txt"
|
|
log.write_text('[x] GameAction [AppID 3554078061, ActionID 3] : LaunchApp changed task to RequestingLicense\n'
|
|
'[x] GameAction [AppID 3554078061, ActionID 3] : LaunchApp failed with AppError_9 with ""\n'
|
|
'[x] GameAction [AppID 1234, ActionID 1] : LaunchApp failed with AppError_18 with ""\n')
|
|
got = a.steam_launch_result(str(log), 0, 3554078061, wait=0) # GitHub #21: Steam didn't know the shortcut
|
|
assert got["result"] == "error" and got["code"] == 9
|
|
assert a.steam_launch_result(str(log), 0, 35540780, wait=0)["result"] == "silent" # no prefix matches
|
|
|
|
|
|
def _fake_steam(a, monkeypatch, tmp_path, appid=2772269798):
|
|
"""Steam's devkit side: answers create/delete requests and writes/removes the shortcut like Steam does."""
|
|
sent = []
|
|
vdf = tmp_path / ".local/share/Steam/userdata/42/config/shortcuts.vdf"
|
|
vdf.parent.mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".steam").mkdir(exist_ok=True)
|
|
(tmp_path / ".steam/steam.token").write_text("tok")
|
|
pipe = tmp_path / ".steam/steam.pipe"
|
|
pipe.write_text("")
|
|
|
|
real_open = open
|
|
|
|
def fake_open(path, mode="r", *args, **kw):
|
|
if str(path) == str(pipe) and "w" in mode:
|
|
class P:
|
|
def write(self, data):
|
|
line = data.decode().strip()
|
|
sent.append(line)
|
|
from urllib.parse import parse_qs, urlparse
|
|
url = urlparse(line.split(" ", 1)[1])
|
|
q = parse_qs(url.query)
|
|
cmd = url.path.rsplit("/", 1)[-1]
|
|
if cmd == "create-shortcut":
|
|
gid = q["gameid"][0]
|
|
a.upsert_shortcut(str(vdf), f'"{a.DEVKIT_GAMES}/{gid}/launch.sh"', f"Devkit Game: {gid}",
|
|
f"{a.DEVKIT_GAMES}/{gid}", "")
|
|
root = a.vdf_decode(vdf.read_bytes())
|
|
for v in root["shortcuts"].values():
|
|
if v["Exe"].endswith(f'/{gid}/launch.sh"'):
|
|
v.update(appid=appid, Devkit=1, DevkitGameID=gid)
|
|
vdf.write_bytes(a.vdf_encode(root))
|
|
elif cmd == "delete-shortcut":
|
|
a.remove_shortcut(str(vdf), f'"{a.DEVKIT_GAMES}/{q["gameid"][0]}/launch.sh"')
|
|
real_open(q["response"][0], "w").write("ok")
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *e):
|
|
return False
|
|
return P()
|
|
return real_open(path, mode, *args, **kw)
|
|
monkeypatch.setattr(a, "open", fake_open, raising=False)
|
|
monkeypatch.setattr("builtins.open", fake_open)
|
|
return sent, vdf
|
|
|
|
|
|
def test_devkit_fallback_register_and_unregister(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.camouflaj.manta"
|
|
(anchor / "artwork").mkdir(parents=True)
|
|
(anchor / "launch.sh").write_text("#!/bin/sh\n")
|
|
(anchor / "artwork/portrait.jpg").write_bytes(b"jpg")
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.camouflaj.manta", "appid": 3554078061,
|
|
"title": "Batman: Arkham Shadow", "base": str(anchor)}))
|
|
sent, vdf = _fake_steam(a, monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
appid = a.devkit_register("com.camouflaj.manta")
|
|
assert appid == 2772269798 and a.devkit_gameid("com.camouflaj.manta") == "Batman_Arkham_Shadow"
|
|
assert "create-shortcut" in sent[0] and "gameid=Batman_Arkham_Shadow" in sent[0] # no spaces in the id
|
|
dk = tmp_path / "devkit-game"
|
|
assert json.loads((dk / "Batman_Arkham_Shadow-argv.json").read_text()) == ["launch.sh"]
|
|
assert os.readlink(dk / "Batman_Arkham_Shadow/launch.sh") == str(anchor / "launch.sh")
|
|
grid = vdf.parent / "grid"
|
|
assert (grid / "2772269798p.jpg").exists() # its art under Steam's new appid
|
|
assert a.devkit_register("com.camouflaj.manta") == appid # registering again reuses the same id
|
|
assert a.devkit_unregister("com.camouflaj.manta")
|
|
assert not (dk / "Batman_Arkham_Shadow").exists() and not list(grid.iterdir())
|
|
assert a.devkit_appid("Batman_Arkham_Shadow") is None and not a.devkit_unregister("com.camouflaj.manta")
|
|
# Steam rejects ids that don't start with a letter (e.g. "4XVR_Video_Player")
|
|
dep = json.loads((anchor / "deployment.json").read_text())
|
|
(anchor / "deployment.json").write_text(json.dumps({**dep, "title": "4XVR Video-Player"}))
|
|
a.devkit_register("com.camouflaj.manta")
|
|
assert a.devkit_gameid("com.camouflaj.manta") == "Game_4XVR_Video_Player"
|
|
|
|
|
|
def test_launch_falls_back_to_devkit_entry(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.y", "appid": 111, "title": "X",
|
|
"base": str(anchor)}))
|
|
monkeypatch.setattr(a, "run", lambda cmd, **k: SimpleNamespace(returncode=0, stdout=""))
|
|
monkeypatch.setattr(a, "shortcut_appid_for", lambda exe: 111)
|
|
tried, registered = [], {}
|
|
|
|
def launch(appid, wait=10):
|
|
tried.append(appid)
|
|
return {"result": "error", "code": 9} if appid == 111 else {"result": "started"}
|
|
|
|
def register(pkg):
|
|
registered["id"] = 222
|
|
return 222
|
|
monkeypatch.setattr(a, "steam_launch", launch)
|
|
monkeypatch.setattr(a, "devkit_register", register)
|
|
monkeypatch.setattr(a, "devkit_gameid", lambda pkg: "X" if registered else None)
|
|
monkeypatch.setattr(a, "devkit_appid", lambda gid: registered.get("id"))
|
|
got = a.cmd_launch({"package": "com.x.y"})
|
|
assert tried == [111, 222] and got["via"] == "devkit" and got["steam"]["result"] == "started"
|
|
assert got["first_try"]["code"] == 9
|
|
got = a.cmd_launch({"package": "com.x.y"}) # next time the devkit entry is used right away
|
|
assert tried[-1] == 222 and got["via"] == "devkit" and "first_try" not in got
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="bash launcher")
|
|
def test_launcher_ends_game_when_its_parent_is_gone(monkeypatch, tmp_path):
|
|
"""GitHub #36: Steam stopping only its reaper left launch.sh + Lepton running; the watchdog stops them."""
|
|
import signal
|
|
import time
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base, anchor = tmp_path / "base", tmp_path / "anchor"
|
|
(base / "lepton-app").mkdir(parents=True)
|
|
anchor.mkdir()
|
|
marker = tmp_path / "lepton-pid"
|
|
lepton = tmp_path / "lepton"
|
|
lepton.write_text(f"#!/bin/bash\necho $$ > {marker}\nexec sleep 300\n")
|
|
lepton.chmod(0o755)
|
|
a.write_launcher(str(anchor), str(base), "com.x.y", "X", 123, str(lepton), {})
|
|
# the reaper stand-in: starts the launcher and waits; then it alone is killed
|
|
parent = subprocess.Popen(["bash", "-c", f"bash {anchor / 'launch.sh'} & wait"], start_new_session=True)
|
|
for _ in range(50):
|
|
if marker.exists() and marker.read_text().strip():
|
|
break
|
|
time.sleep(0.1)
|
|
game = int(marker.read_text())
|
|
os.kill(parent.pid, signal.SIGKILL)
|
|
parent.wait()
|
|
for _ in range(80): # the watchdog checks every 2 s
|
|
try:
|
|
os.kill(game, 0)
|
|
except ProcessLookupError:
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
os.killpg(game, signal.SIGKILL)
|
|
pytest.fail("the game kept running after its parent was gone")
|
|
|
|
|
|
def test_upgrade_launchers_adds_watchdog(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
old = f"#!/bin/bash\nfix_perms\n{a.OLD_WATCHDOG}\nexport SteamAppId=1\n"
|
|
(anchor / "launch.sh").write_text(old)
|
|
(anchor / "launch.sh").chmod(0o755)
|
|
assert a.upgrade_launchers() == ["com.x.y"]
|
|
text = (anchor / "launch.sh").read_text()
|
|
assert "parent=$PPID" in text and a.OLD_WATCHDOG not in text and os.access(anchor / "launch.sh", os.X_OK)
|
|
assert a.upgrade_launchers() == [] # once only
|
|
|
|
|
|
def test_dashboard_worker_hides_dashboard_after_first_frames(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Lepton starting\n")
|
|
calls, visible = [], [True, True, False, True]
|
|
|
|
def js(expr, timeout=5):
|
|
calls.append(expr)
|
|
if expr.endswith("IsDashboardVisible()"):
|
|
if len(calls) == 1: # the first frames arrive while it waits
|
|
pass
|
|
return visible.pop(0) if visible else False
|
|
return None
|
|
monkeypatch.setattr(a, "steam_js", js)
|
|
clock = iter(range(0, 10000))
|
|
monkeypatch.setattr(a.time, "time", lambda: next(clock))
|
|
log.write_text("Lepton starting\nI FrameBridge: pacing: 72.0 fps\n")
|
|
a.dashboard_worker(str(log), os.getpid(), wait_start=10, window=8, max_hides=3)
|
|
assert calls.count("SteamClient.OpenVR.VROverlay.HideDashboard()") == 3 # at most max_hides times
|
|
|
|
|
|
def test_dashboard_worker_starts_at_the_first_submitted_frame(monkeypatch, tmp_path):
|
|
"""Steam's "Resume game" menu opens at the game's first frame (ITR2: 0.3 s after FrameBridge's "new layer:"); the
|
|
first "pacing:" summary comes ~8 s later, after the player had pressed Resume. The log grows while it waits."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Lepton starting\nI FrameBridge: xrCreateSwapchain 2016x1728\nI FrameBridge: new la")
|
|
pieces = ["yer: type=35 swapchain=0x0\n"] # the marker split across two reads
|
|
|
|
def sleep(s):
|
|
if pieces:
|
|
with open(log, "a") as f:
|
|
f.write(pieces.pop(0))
|
|
monkeypatch.setattr(a.time, "sleep", sleep)
|
|
clock = iter(range(0, 10000))
|
|
monkeypatch.setattr(a.time, "time", lambda: next(clock))
|
|
calls = []
|
|
monkeypatch.setattr(a, "steam_js", lambda expr, timeout=5: calls.append(expr) or expr.endswith("Visible()"))
|
|
a.dashboard_worker(str(log), os.getpid(), wait_start=10, window=4)
|
|
assert "SteamClient.OpenVR.VROverlay.HideDashboard()" in calls
|
|
|
|
|
|
def test_dashboard_worker_leaves_a_dashboard_the_player_opened(monkeypatch, tmp_path):
|
|
"""ITR2 (agent 52): the player's controller button opened the dashboard and the worker closed it 60 ms later; the
|
|
game had paused for it and stayed paused. After a toggle_dashboard_action the worker must stop for good."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log, ui = tmp_path / "launch.log", tmp_path / "vrwebhelper_systemui.txt"
|
|
log.write_text("I FrameBridge: new layer: type=35\n")
|
|
ui.write_text("| [Dashboard] [ToggleDashboard] toggle_dashboard_action (an earlier session)\n")
|
|
visible = iter([True, False, False, True, True])
|
|
calls = []
|
|
|
|
def js(expr, timeout=5):
|
|
calls.append(expr)
|
|
return next(visible, True) if expr.endswith("Visible()") else None
|
|
monkeypatch.setattr(a, "steam_js", js)
|
|
steps = iter(range(10000))
|
|
|
|
def sleep(s):
|
|
if next(steps) == 2: # the player presses the dashboard button
|
|
with open(ui, "a") as f:
|
|
f.write("| [Dashboard] [ToggleDashboard] toggle_dashboard_action bSourceIsVRLinkRemote false\n")
|
|
monkeypatch.setattr(a.time, "sleep", sleep)
|
|
clock = iter(range(0, 10000))
|
|
monkeypatch.setattr(a.time, "time", lambda: next(clock))
|
|
a.dashboard_worker(str(log), os.getpid(), wait_start=10, window=50, ui_log=str(ui))
|
|
assert calls.count("SteamClient.OpenVR.VROverlay.HideDashboard()") == 1 # Steam's start-up menu only
|
|
|
|
|
|
def test_dashboard_worker_waits_for_vr_frames(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
clock = iter(range(0, 10000))
|
|
monkeypatch.setattr(a.time, "time", lambda: next(clock))
|
|
monkeypatch.setattr(a, "steam_js", lambda *x, **k: pytest.fail("no frames yet: must not touch Steam"))
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("2D app, no FrameBridge\n")
|
|
a.dashboard_worker(str(log), os.getpid(), wait_start=5)
|
|
|
|
|
|
def test_upgrade_launchers_adds_dashboard_helper(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "launch.sh").write_text(f'{a.OLD_WATCHDOG}\nsetsid lepton start &\nchild=$!\nwait "$child"\n')
|
|
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")]) == []
|
|
|
|
|
|
def test_power_schedules_systemctl_in_a_user_timer(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
calls = []
|
|
monkeypatch.setattr(a, "run", lambda cmd, **k: calls.append(cmd) or SimpleNamespace(returncode=0, stdout="",
|
|
stderr=""))
|
|
monkeypatch.setattr(a, "game_running", lambda: False)
|
|
assert a.cmd_power({"action": "restart"}) == {"action": "restart", "in_seconds": 3}
|
|
assert calls[-1][:2] == ["systemd-run", "--user"] and calls[-1][-2:] == ["systemctl", "reboot"]
|
|
assert "--on-active=3" in calls[-1]
|
|
monkeypatch.setattr(a, "game_running", lambda: True)
|
|
with pytest.raises(a.AgentError, match="game is running"):
|
|
a.cmd_power({"action": "sleep"})
|
|
assert a.cmd_power({"action": "sleep", "force": True})["action"] == "sleep"
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_power({"action": "format"})
|
|
|
|
|
|
def test_incomplete_deployment_records_dont_break_listing(monkeypatch, tmp_path):
|
|
"""GitHub #40: a deployment.json without "base" made every connection fail with KeyError: 'base'."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
good = tmp_path / "Applications/quest-frame/com.x.ok"
|
|
good.mkdir(parents=True)
|
|
(good / "deployment.json").write_text(json.dumps({"package": "com.x.ok", "appid": 5})) # no base / title
|
|
junk = tmp_path / "Applications/quest-frame/other"
|
|
junk.mkdir(parents=True)
|
|
(junk / "deployment.json").write_text(json.dumps({"name": "something else"}))
|
|
games = a.cmd_list_installed({})["games"]
|
|
assert [g["package"] for g in games] == ["com.x.ok"] and games[0]["base"] == str(good)
|
|
assert a.deployment("com.x.ok")["base"] == str(good)
|
|
|
|
|
|
def test_proton_defaults_to_stable(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
stable = {"name": "proton_11-arm64", "display_name": "Proton 11.0-2 (ARM64)", "aliases": "proton-stable-arm64,"
|
|
"proton-stable", "experimental": False, "installed": True, "require_installed": True}
|
|
exp = {"name": "proton-experimental-arm64", "display_name": "Proton Experimental (ARM64)",
|
|
"aliases": "proton-experimental", "experimental": True, "installed": False, "require_installed": True}
|
|
assert a.pick_proton([stable, exp])["name"] == "proton_11-arm64"
|
|
assert a.pick_proton([exp, stable])["name"] == "proton_11-arm64" # whatever the order
|
|
assert a.pick_proton([stable, exp], "proton-experimental")["name"] == "proton-experimental-arm64" # by alias
|
|
assert a.pick_proton([exp])["name"] == "proton-experimental-arm64" # only Experimental offered
|
|
|
|
|
|
def test_launch_test_waits_for_a_first_boot(monkeypatch, tmp_path):
|
|
"""The first start after an APK change: Lepton says "is not a running context" while it waits, then boots."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
first = "Waiting for boot...\nERROR: 'steamlaunch-1' is not a running context, use 'lepton ps'\n"
|
|
assert not a.not_started(first, 5) # still within the grace period
|
|
assert a.not_started(first, 31) # no boot: the container really failed
|
|
assert not a.not_started(first + "Boot complete!\nInstalling game.apk...\n", 120)
|
|
assert not a.not_started("Boot complete!\n", 120)
|
|
|
|
|
|
def test_devkit_appid_from_steams_console_log(monkeypatch, tmp_path):
|
|
"""GitHub #42: Steam added the devkit entry live but never saved shortcuts.vdf; its console log names the appid."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
logs = Path(a.STEAM) / "logs"
|
|
logs.mkdir(parents=True, exist_ok=True)
|
|
exe = f"{a.DEVKIT_GAMES}/I_Am_Cat/launch.sh"
|
|
why = "reason: k_unAppIdInvalid"
|
|
(logs / "console_log.txt").write_text(
|
|
f'[2026-10-05 14:45:37] sanitize shortcut app id "{exe}": replacing 0 with 3849978641, {why}\n'
|
|
f'[2026-10-05 14:45:38] sanitize shortcut app id "{a.DEVKIT_GAMES}/Other/launch.sh": replacing 0 with 5\n'
|
|
f'[2026-10-05 15:29:14] sanitize shortcut app id "{exe}": replacing 0 with 3849978642, {why}\n')
|
|
assert a.devkit_appid("I_Am_Cat") == 3849978642 # nothing in shortcuts.vdf: the newest log line
|
|
assert a.devkit_appid("Missing") is None
|
|
|
|
|
|
def test_devkit_appid_ignores_the_previous_steam_session(monkeypatch, tmp_path):
|
|
"""Live devkit entries die with Steam (GitHub #41): an appid from console_log.previous.txt is stale."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
logs = Path(a.STEAM) / "logs"
|
|
logs.mkdir(parents=True, exist_ok=True)
|
|
exe = f"{a.DEVKIT_GAMES}/Batman/launch.sh"
|
|
(logs / "console_log.previous.txt").write_text(f'sanitize shortcut app id "{exe}": replacing 0 with 77, x\n')
|
|
(logs / "console_log.txt").write_text("Steam started\n")
|
|
assert a.devkit_appid("Batman") is None
|
|
|
|
|
|
def test_launch_registers_a_lost_devkit_entry_again(monkeypatch, tmp_path):
|
|
"""After a Steam restart Steam forgot the (never saved) devkit entry: Play gets AppError_9 and registers it
|
|
again."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a, "check_pkg", lambda p: p)
|
|
monkeypatch.setattr(a, "deployment", lambda p: {"appid": 111, "title": "Batman"})
|
|
monkeypatch.setattr(a, "run", lambda *x, **k: subprocess.CompletedProcess([], 0, "", ""))
|
|
monkeypatch.setattr(a, "devkit_gameid", lambda p: "Batman")
|
|
monkeypatch.setattr(a, "devkit_appid", lambda g: 222) # the stale entry from before the restart
|
|
registered = []
|
|
monkeypatch.setattr(a, "devkit_register", lambda p: registered.append(p) or 333)
|
|
launches = []
|
|
|
|
def launch(appid, wait=10):
|
|
launches.append(appid)
|
|
return {"result": "error", "code": 9} if appid == 222 else {"result": "started"}
|
|
monkeypatch.setattr(a, "steam_launch", launch)
|
|
out = a.cmd_launch({"package": "com.camouflaj.manta"})
|
|
assert registered == ["com.camouflaj.manta"] and launches == [222, 333]
|
|
assert out["via"] == "devkit" and out["steam"]["result"] == "started"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32" or not __import__("shutil").which("flock"), reason="bash + flock")
|
|
def test_second_launch_while_starting_is_ignored(monkeypatch, tmp_path):
|
|
"""Play pressed again while Lepton still boots made the second Lepton stop the first one's container (both died:
|
|
Vader Immortal, BattleSisters). The launcher holds a lock for the game's lifetime; a second launch leaves it."""
|
|
import time
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "game"
|
|
(base / "lepton-app").mkdir(parents=True)
|
|
lepton = tmp_path / "lepton"
|
|
lepton.write_text(f"#!/bin/bash\necho started >>{tmp_path}/starts\nsleep ${{FAKE_RUN:-30}}\n")
|
|
lepton.chmod(0o755)
|
|
text = a.LAUNCH_SH.format(title="T", pkg="com.x.y", app_dir=f"app_dir={base}", appid=1, lepton_q=str(lepton),
|
|
extra_env="", cloud_dirs="true",
|
|
watchdog=a.WATCHDOG, dashboard="true", logcat="true", single=a.SINGLE_LINE,
|
|
plays_start="true", plays_end="true", save_snapshot="true", video_codec="",
|
|
restart=a.restart_block("com.x.y", str(lepton)))
|
|
launcher = tmp_path / "launch.sh"
|
|
launcher.write_text(text)
|
|
launcher.chmod(0o755)
|
|
first = subprocess.Popen([str(launcher)], start_new_session=True)
|
|
try:
|
|
for _ in range(100):
|
|
if (tmp_path / "starts").exists():
|
|
break
|
|
time.sleep(0.05)
|
|
second = subprocess.run([str(launcher)], timeout=10)
|
|
assert second.returncode == 0 and first.poll() is None # ignored, the first keeps running
|
|
assert (tmp_path / "starts").read_text().count("started") == 1
|
|
assert "second launch ignored" in (base / "launch-dup.log").read_text()
|
|
finally:
|
|
os.killpg(first.pid, 15)
|
|
first.wait(timeout=10)
|
|
third = subprocess.run([str(launcher)], timeout=20, env={**os.environ, "FAKE_RUN": "0"})
|
|
assert third.returncode == 0 and (tmp_path / "starts").read_text().count("started") == 2 # free again
|
|
|
|
|
|
def test_upgrade_launchers_adds_the_single_launch_lock(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
old = ('#!/bin/bash\napp_dir=/x\n[[ -d "$app_dir/lepton-app" ]] || { exit 1; }\nfix_perms\nparent=$PPID\n'
|
|
'( while sleep 2; do fix_perms; if true; then kill -TERM $$; fi; done ) & permfix=$!\nexport SteamAppId=1\n')
|
|
(anchor / "launch.sh").write_text(old)
|
|
a.upgrade_launchers()
|
|
text = (anchor / "launch.sh").read_text()
|
|
assert text.index(".launch.lock") < text.index("fix_perms") and "done ) 9>&- & permfix=$!" in text
|
|
assert text.count(".launch.lock") == 1
|
|
a.upgrade_launchers()
|
|
assert (anchor / "launch.sh").read_text().count(".launch.lock") == 1
|
|
|
|
|
|
def test_dashboard_worker_keeps_watching_for_two_minutes(monkeypatch, tmp_path):
|
|
"""Agent 59: Steam's menu came back after the first 30 s in headset sessions (The Boys, BONELAB)."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
import inspect
|
|
|
|
sig = inspect.signature(a.dashboard_worker)
|
|
assert sig.parameters["window"].default == 120 and sig.parameters["max_hides"].default == 10
|
|
|
|
|
|
def test_linux_x86_apps_run_through_fex(monkeypatch, tmp_path):
|
|
"""x86_64 Linux apps: FEX is found in the compat list like Proton, and the launcher starts the program through
|
|
the tool's command chain with STEAM_COMPAT_DATA_PATH set; no ldd check (it can't read x86 programs)."""
|
|
import shutil
|
|
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
apps = fake_steam_tools(a, tmp_path)
|
|
tools = a.arm64_compat_tools()
|
|
tools["fex"] = {"appid": 3127680, "display_name": "FEX-Emu", "from_oslist": "linux", "require_tool_appid": 4185400}
|
|
a.arm64_compat_tools = lambda: tools
|
|
assert [t["name"] for t in a.linux_x86_tools()] == ["fex"] # the runtimes aren't translators
|
|
st = a.cmd_proton_status({"kind": "linux_x86"})
|
|
assert st["ready"] is None and st["suggested"]["name"] == "fex"
|
|
assert a.cmd_proton_status({})["ready"]["name"] == "proton_11-arm64" # Proton unchanged
|
|
(apps / "common/FEX").mkdir(parents=True)
|
|
(apps / "common/FEX/toolmanifest.vdf").write_text(
|
|
'"manifest"\n{\n "commandline" "/fex-compat-tool %verb%"\n "require_tool_appid" "4185400"\n}\n')
|
|
(apps / "appmanifest_3127680.acf").write_text(
|
|
'"AppState"\n{\n\t"appid"\t\t"3127680"\n\t"name"\t\t"FEX"\n\t"StateFlags"\t\t"4"\n'
|
|
'\t"installdir"\t\t"FEX"\n}\n')
|
|
assert a.cmd_proton_status({"kind": "linux_x86"})["ready"]["name"] == "fex"
|
|
prep = a.cmd_prepare_linux({"package": "linux.thing", "title": "Thing"})
|
|
shutil.copy("/bin/true", os.path.join(prep["incoming"], "app", "thing"))
|
|
monkeypatch.setattr(a, "missing_libraries", lambda *x: pytest.fail("ldd on an x86 program"))
|
|
res = a.cmd_finalize_linux({"package": "linux.thing", "title": "Thing", "exe": "thing", "x86_64": True})
|
|
assert res["missing_libraries"] == [] and a.deployment("linux.thing")["x86_64"] is True
|
|
text = open(os.path.join(prep["anchor"], "launch.sh")).read()
|
|
line = next(x for x in text.splitlines() if "launch.log" in x and "_v2-entry-point" in x)
|
|
assert line.index("_v2-entry-point") < line.index("fex-compat-tool") < line.index("app/thing")
|
|
data = os.path.join(prep["base"], "compatdata")
|
|
assert f"export STEAM_COMPAT_DATA_PATH={data}" in text and f"mkdir -p {data}" in text # FEX exits without it
|
|
assert text.index("STEAM_COMPAT_DATA_PATH") < text.index("fex-compat-tool")
|
|
|
|
|
|
def test_hmd_state_from_vrserver_log(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
logs = Path(a.STEAM) / "logs"
|
|
logs.mkdir(parents=True)
|
|
assert a.hmd_state() is None
|
|
(logs / "vrserver.txt").write_text("x cv: [CCVTrackedHmdDriver] in StateActive\n"
|
|
"y cv: [CCVTrackedHmdDriver] in StateStandby \n")
|
|
assert a.hmd_state() == "Standby"
|
|
|
|
|
|
def test_take_screenshot_waits_for_steams_file(monkeypatch, tmp_path):
|
|
"""take_screenshot asks SteamVR (vr_screenshot) and reports the shot Steam saved, or why none came."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
shots = Path(a.STEAM) / "userdata" / "123" / "760" / "remote" / a.STEAMVR_APPID / "screenshots"
|
|
shots.mkdir(parents=True)
|
|
(shots / "20261008120000_1.jpg").write_bytes(b"old")
|
|
|
|
def submitted():
|
|
(shots / "20261008221922_1.jpg").write_bytes(b"new") # Steam saves the flat shot...
|
|
(shots / "20261008221922_1_vr.jpg").write_bytes(b"stereo") # ...and the stereo one (not listed)
|
|
monkeypatch.setattr(a, "vr_screenshot", submitted)
|
|
r = a.cmd_take_screenshot({"wait": 2})
|
|
assert r["taken"] and r["path"].endswith("20261008221922_1.jpg") and r["reason"] is None
|
|
monkeypatch.setattr(a, "vr_screenshot", lambda: "capture") # the headset sleeps: nothing captured
|
|
r = a.cmd_take_screenshot({"wait": 0.5})
|
|
assert r["taken"] is False and r["reason"] == "capture"
|
|
|
|
|
|
def test_vr_screenshot_without_steamvr(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(a, "OPENVR_LIBS", (str(tmp_path / "missing.so"),))
|
|
assert a.vr_screenshot() == "steamvr"
|
|
|
|
|
|
def test_game_logs_collect_unity_player_log(monkeypatch, tmp_path):
|
|
"""PC VR launch tests and diagnostics include Unity's Player.log (+ prev, crash error.log) from the Proton prefix
|
|
(GitHub #105: SUPERHOT VR quit without a trace in the Proton logs)."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "rift.superhot_vr"
|
|
data = base / "game/SUPERHOTVR_Data"
|
|
data.mkdir(parents=True)
|
|
(data / "app.info").write_text("SUPERHOT Team\nSUPERHOT VR")
|
|
low = base / a.LOCAL_LOW
|
|
mine = low / "SUPERHOT Team/SUPERHOT VR"
|
|
mine.mkdir(parents=True)
|
|
(mine / "Player.log").write_text("Mono path[0]\n" + "".join(f"line {i}\n" for i in range(3000))
|
|
+ "XR: OpenVR Error! OpenVR failed initialization with error code "
|
|
"VRInitError_Init_HmdNotFound\n")
|
|
(mine / "Player-prev.log").write_text("previous run\n")
|
|
other = low / "Other/Game"
|
|
other.mkdir(parents=True)
|
|
(other / "Player.log").write_text("not this game\n")
|
|
crash = base / a.LOCAL_APPDATA / "Temp/SUPERHOT Team/SUPERHOT VR/Crashes/Crash_2026-10-09_1"
|
|
crash.mkdir(parents=True)
|
|
(crash / "error.log").write_text("SUPERHOTVR.exe caused an Access Violation (0xc0000005)\n")
|
|
parts = a.game_logs(str(base))
|
|
text = "\n".join(parts)
|
|
assert "===== unity log " in text and "Mono path[0]" in text and "VRInitError_Init_HmdNotFound" in text
|
|
assert "lines left out" in text and "previous run" in text and "not this game" not in text
|
|
assert "Access Violation" in text
|
|
# a launch test only takes logs written since it started
|
|
old = 1_000_000
|
|
os.utime(mine / "Player-prev.log", (old, old))
|
|
os.utime(crash / "error.log", (old, old))
|
|
recent = "\n".join(a.game_logs(str(base), since=old + 10))
|
|
assert "VRInitError" in recent and "previous run" not in recent and "Access Violation" not in recent
|
|
# no app.info: every Player log in LocalLow; old Unity: <Name>_Data/output_log.txt
|
|
(data / "app.info").unlink()
|
|
(data / "output_log.txt").write_text("old unity\n")
|
|
text = "\n".join(a.game_logs(str(base)))
|
|
assert "not this game" in text and "old unity" in text
|
|
|
|
|
|
LEPTON = "\x1b[38;5;40m10-14 22:56:43.690 1225 1256 I FrameBridge: pacing: 72.0 fps\x1b[0m\n"
|
|
|
|
|
|
def run_keeper(a, monkeypatch, log, loops, writes=None, procs=None, tick=2.0, **kw):
|
|
"""Run logcat_keeper for `loops` polls on a fake clock (`tick` s each). writes: {loop: text appended to the log
|
|
before that poll}. Returns (popen calls, run calls); each started reader is a FakeProc that `procs` can end."""
|
|
monkeypatch.setattr(a.time, "sleep", lambda s: None)
|
|
state = {"loop": -1, "now": 1000.0}
|
|
calls, runs, started = [], [], []
|
|
|
|
def kill(pid, sig):
|
|
state["loop"] += 1
|
|
state["now"] += tick
|
|
if state["loop"] >= loops:
|
|
raise OSError()
|
|
text = (writes or {}).get(state["loop"])
|
|
if text:
|
|
with open(log, "a") as f:
|
|
f.write(text)
|
|
for i, end in (procs or {}).items():
|
|
if state["loop"] == end and i < len(started):
|
|
started[i].code = 0
|
|
|
|
monkeypatch.setattr(a.os, "kill", kill)
|
|
|
|
class FakeProc:
|
|
def __init__(self, argv, stdout, **k):
|
|
calls.append(argv)
|
|
stdout.write(b"10-14 22:56:44.000 1225 1256 I FrameBridge: new layer: type=35\n")
|
|
self.code = None
|
|
started.append(self)
|
|
|
|
def poll(self):
|
|
return self.code
|
|
|
|
def terminate(self):
|
|
calls.append("terminated")
|
|
self.code = -15
|
|
|
|
def run(argv, **k):
|
|
runs.append(argv)
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
|
|
a.logcat_keeper(str(log), "123", os.getpid(), popen=FakeProc, run=run, clock=lambda: state["now"], **kw)
|
|
return calls, runs
|
|
|
|
|
|
def test_logcat_keeper_reads_on_after_leptons_mirror_ended(monkeypatch, tmp_path):
|
|
"""Lepton's logcat mirror died right after Vader Immortal started ("logcat: Unexpected EOF!"): launch.log stopped
|
|
filling, so the dashboard was never closed. The keeper reads the container's logcat itself from about the moment
|
|
the mirror stopped (what came before is already in the log)."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Boot complete!\nWaiting for app com.x.y to exit...\n")
|
|
calls, runs = run_keeper(a, monkeypatch, log, 8, writes={3: LEPTON, 5: "logcat: Unexpected EOF!\n"})
|
|
argv = calls[0]
|
|
assert argv[:4] == ["podman", "exec", "lepton-steamlaunch-123", "logcat"] and calls[-1] == "terminated"
|
|
assert float(argv[argv.index("-T") + 1]) > 1002 # not from the start: Lepton's lines are already there
|
|
assert runs[0] == ["podman", "exec", "lepton-steamlaunch-123", "logcat", "-G", "16M"]
|
|
text = log.read_text()
|
|
assert "Lepton's logcat isn't coming through (logcat: Unexpected EOF)" in text and "new layer: type=35" in text
|
|
|
|
|
|
def test_logcat_keeper_reads_from_the_start_when_the_mirror_stays_silent(monkeypatch, tmp_path):
|
|
"""The owner's verifying session of Journey of the Gods (first start after an APK change): Lepton printed
|
|
"Waiting for app" and then nothing at all, no EOF. Silence starts the reader too, from the launcher's start."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Lepton v3.0.5 starting up\nBoot complete!\nWaiting for app gg.trs.grappa to exit...\n")
|
|
calls, _ = run_keeper(a, monkeypatch, log, 13) # 2 s polls: 20 s of silence after the 1st poll
|
|
assert len([c for c in calls if c != "terminated"]) == 1
|
|
argv = calls[0]
|
|
assert argv[argv.index("-T") + 1] == "1000.000"
|
|
assert re.search(r"no logcat line for 2\d s after the app started", log.read_text())
|
|
|
|
|
|
def test_logcat_keeper_waits_out_a_short_silence(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Waiting for app com.x.y to exit...\n")
|
|
calls, _ = run_keeper(a, monkeypatch, log, 8) # 16 s: under the 20 s threshold
|
|
assert calls == []
|
|
|
|
|
|
def test_logcat_keeper_starts_on_a_logcat_read_failure(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Waiting for app com.x.y to exit...\nlogcat: Logcat read failure: No such file or directory\n")
|
|
calls, runs = run_keeper(a, monkeypatch, log, 3)
|
|
assert calls and calls[0][3] == "logcat" and not runs # (no "Boot complete!": the buffer is left alone)
|
|
assert "Logcat read failure" in log.read_text()
|
|
|
|
|
|
def test_logcat_keeper_does_nothing_while_leptons_mirror_works(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Boot complete!\nWaiting for app com.x.y to exit...\n" + LEPTON)
|
|
calls, runs = run_keeper(a, monkeypatch, log, 20, writes={i: LEPTON for i in range(20)})
|
|
assert calls == [] and len(runs) == 1 # only the log buffer
|
|
|
|
|
|
def test_logcat_keeper_stops_its_reader_when_leptons_mirror_comes_back(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Waiting for app com.x.y to exit...\n")
|
|
calls, _ = run_keeper(a, monkeypatch, log, 16, writes={14: LEPTON})
|
|
assert calls[-1] == "terminated" and len(calls) == 2
|
|
assert "Lepton's logcat is back" in log.read_text()
|
|
|
|
|
|
def test_logcat_keeper_restarts_its_reader_from_where_it_ended(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = tmp_path / "launch.log"
|
|
log.write_text("Waiting for app com.x.y to exit...\nlogcat: Unexpected EOF!\n")
|
|
calls, _ = run_keeper(a, monkeypatch, log, 6, procs={0: 3})
|
|
readers = [c for c in calls if c != "terminated"]
|
|
assert len(readers) == 2
|
|
first, second = (float(r[r.index("-T") + 1]) for r in readers)
|
|
assert first == 1000.0 and second > first + 4 # the second goes on from about when the first ended
|
|
|
|
|
|
def test_upgrade_launchers_adds_logcat_keeper(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "launch.sh").write_text(f'{a.OLD_WATCHDOG}\nsetsid lepton start &\nchild=$!\nwait "$child"\n')
|
|
a.upgrade_launchers()
|
|
text = (anchor / "launch.sh").read_text()
|
|
assert "_logcat_keeper" in text and text.index("_logcat_keeper") < text.index('wait "$child"')
|
|
|
|
|
|
def test_launch_test_window_starts_when_the_app_starts(monkeypatch, tmp_path):
|
|
"""The first start after an APK change spends ~90 s booting Lepton and installing the app; a 45 s window counted
|
|
from the launcher stopped the container mid-install and left a broken APK copy ("base.apk is not zip", VR HOT)."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base = tmp_path / "base"
|
|
base.mkdir()
|
|
log = base / "launch.log"
|
|
log.write_text("Waiting for boot...\n")
|
|
clock = {"t": 1000.0}
|
|
monkeypatch.setattr(a.time, "time", lambda: clock["t"])
|
|
|
|
def sleep(s):
|
|
clock["t"] += s
|
|
if clock["t"] >= 1090 and "Waiting for app" not in log.read_text():
|
|
log.write_text("Waiting for boot...\nBoot complete!\nInstalling game.apk...\nSuccess\n"
|
|
"Waiting for app com.x.y to exit...\n")
|
|
monkeypatch.setattr(a.time, "sleep", sleep)
|
|
monkeypatch.setattr(a, "deployment", lambda pkg: {"base": str(base), "appid": 1})
|
|
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
|
monkeypatch.setattr(a, "ensure_host_fixes", lambda: None)
|
|
monkeypatch.setattr(a, "key_usage", lambda: None)
|
|
monkeypatch.setattr(a, "run", lambda *x, **k: type("R", (), {"returncode": 0, "stderr": ""})())
|
|
res = a.cmd_launch_test({"package": "com.x.y", "seconds": 45})
|
|
assert res["state"] == "RUNNING" and res["elapsed"] >= 90 + 45
|
|
|
|
|
|
def test_rename_keeps_the_shortcut_appid(monkeypatch, tmp_path):
|
|
"""Renaming an installed game (agent v77) changes deployment.json's title and the shortcut's name; the shortcut
|
|
keeps its appid (Play, grid art and the game's container depend on it)."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
exe = f'"{anchor}/launch.sh"'
|
|
appid = a.shortcut_appid(exe, "com.x.y")
|
|
(anchor / "deployment.json").write_text(json.dumps(
|
|
{"package": "com.x.y", "appid": appid, "base": str(anchor), "title": "com.x.y", "kind": "quest"}))
|
|
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
|
cfg.mkdir(parents=True)
|
|
vdf = cfg / "shortcuts.vdf"
|
|
assert a.upsert_shortcut(str(vdf), exe, "com.x.y", str(anchor)) == appid
|
|
started = []
|
|
monkeypatch.setattr(a, "cmd_shortcuts", lambda args: (started.append(args), {"started": True})[1])
|
|
with pytest.raises(a.AgentError, match="empty"):
|
|
a.cmd_rename({"package": "com.x.y", "title": " \n\t "})
|
|
with pytest.raises(a.AgentError, match="not installed"):
|
|
a.cmd_rename({"package": "com.other", "title": "X"})
|
|
r = a.cmd_rename({"package": "com.x.y", "title": " Vader Immortal:\nEpisode II "})
|
|
assert r["renamed"] and r["title"] == "Vader Immortal: Episode II" and r["appid"] == appid
|
|
assert started == [{"packages": ["com.x.y"], "restart": True}]
|
|
dep = json.loads((anchor / "deployment.json").read_text())
|
|
assert dep["title"] == "Vader Immortal: Episode II" and dep["appid"] == appid
|
|
# what the shortcuts worker then does with Steam closed: same appid, new name, one entry
|
|
monkeypatch.setattr(a, "devkit_gameid", lambda pkg: None)
|
|
result = {"added": [], "errors": []}
|
|
a.update_library("42", ["com.x.y"], result)
|
|
assert not result["errors"] and result["added"][0]["appid"] == appid
|
|
entries = list(a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
|
assert len(entries) == 1 and entries[0]["appname"] == "Vader Immortal: Episode II"
|
|
assert entries[0]["appid"] & 0xFFFFFFFF == appid
|
|
# the same name again: nothing to do, no Steam restart
|
|
started.clear()
|
|
r = a.cmd_rename({"package": "com.x.y", "title": "Vader Immortal: Episode II"})
|
|
assert not r["renamed"] and not started
|
|
assert len(a.cmd_rename({"package": "com.x.y", "title": "x" * 300, "shortcuts": False})["title"]) == a.TITLE_MAX
|
|
|
|
|
|
def test_two_revive_games_keep_their_own_shortcuts(monkeypatch, tmp_path):
|
|
"""Every Revive game on a PC starts ReviveInjector.exe: shortcuts are told apart by the game in the launch
|
|
options (one overwrote the other before); a changed injector flag still updates the same shortcut."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
vdf = tmp_path / "shortcuts.vdf"
|
|
exe = '"C:\\\\Revive\\\\ReviveInjector.exe"'
|
|
one = a.upsert_shortcut(str(vdf), exe, "Game One", '"D:\\\\G1\\\\"', "", "Rift via Revive",
|
|
launch_options='/openxr "D:\\\\G1\\\\One.exe"')
|
|
two = a.upsert_shortcut(str(vdf), exe, "Game Two", '"D:\\\\G2\\\\"', "", "Rift via Revive",
|
|
launch_options='"D:\\\\G2\\\\Two.exe"')
|
|
assert one != two
|
|
again = a.upsert_shortcut(str(vdf), exe, "Game One", '"D:\\\\G1\\\\"', "", "Rift via Revive",
|
|
launch_options='"D:\\\\G1\\\\One.exe"')
|
|
assert again == one
|
|
names = sorted(v["appname"] for v in a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
|
assert names == ["Game One", "Game Two"]
|
|
|
|
|
|
def test_game_threads_reports_stuck_threads(monkeypatch, tmp_path):
|
|
"""GitHub #157: diagnostics saved while a game hangs show what each thread waits in."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
proc = tmp_path / "proc"
|
|
|
|
def task(pid, tid, comm, ticks, wchan, syscall):
|
|
t = proc / str(pid) / "task" / str(tid)
|
|
t.mkdir(parents=True)
|
|
(t / "stat").write_text(f"{tid} ({comm}) S 1 1 1 0 -1 0 0 0 0 0 {ticks} 0 0 0 20 0 1 0")
|
|
(t / "comm").write_text(comm + "\n")
|
|
(t / "wchan").write_text(wchan)
|
|
(t / "syscall").write_text(syscall)
|
|
|
|
(proc / "200").mkdir(parents=True)
|
|
(proc / "200" / "cmdline").write_text("com.example.game\0")
|
|
task(200, 200, "GameThread", 50, "futex_wait_queue", "98 0x1 0x2")
|
|
task(200, 201, "RenderThread", 10, "do_epoll_wait", "22 0x1")
|
|
(proc / "200" / "net").mkdir()
|
|
(proc / "200" / "net" / "tcp").write_text(
|
|
" sl local_address rem_address st\n"
|
|
" 0: 0100007F:1F90 0B01A8C0:01BB 02 00000000:00000000\n")
|
|
(proc / "300").mkdir()
|
|
(proc / "300" / "cmdline").write_text("com.other\0")
|
|
|
|
def sleep(_): # GameThread uses 1 s of CPU between the two samples
|
|
(proc / "200" / "task" / "200" / "stat").write_text(
|
|
"200 (GameThread) R 1 1 1 0 -1 0 0 0 0 0 150 0 0 0 20 0 1 0")
|
|
|
|
out = a.game_threads("com.example.game", proc=str(proc), samples=2, gap=1, sleep=sleep)
|
|
assert "com.example.game (pid 200, 2 threads)" in out
|
|
game = next(ln for ln in out.splitlines() if "GameThread" in ln)
|
|
assert "futex" in game and "futex_wait_queue" in game and f"{100 * 1000 // a.CLK_TCK}" in game
|
|
assert "epoll_pwait" in out and "SYN_SENT" in out and "remote=local:443" in out
|
|
assert "192.168" not in out and a.game_threads("com.missing", proc=str(proc)) is None
|
|
|
|
|
|
# Sports Scramble's first start (2026-10-13): Unreal restarted itself after its first-run shader precompile
|
|
RESTART_LOG = ("Waiting for app com.Armature.SportsScramble to exit...\n"
|
|
"10-13 10:51:26.516 1224 1386 I .SportsScrambl: System.exit called, status: 0\n"
|
|
"Exited!\n")
|
|
RESTART_SYSLOG = (
|
|
"10-13 10:51:02.562 223 562 I ActivityTaskManager: START u0 {act=android.intent.action.MAIN "
|
|
"cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=com.Armature.SportsScramble/com.epicgames.ue4."
|
|
"GameActivity} from uid 0\n"
|
|
"10-13 10:51:26.507 223 1111 I ActivityTaskManager: START u0 {act=android.intent.action.MAIN "
|
|
"cat=[android.intent.category.LAUNCHER] flg=0x14008000 pkg=com.Armature.SportsScramble cmp=com.Armature."
|
|
"SportsScramble/com.epicgames.ue4.GameActivity} from uid 10014\n")
|
|
|
|
|
|
def _restart_logs(a, tmp_path, log, syslog, appid="2821789967"):
|
|
a.STEAM = str(tmp_path / "Steam")
|
|
d = tmp_path / "Steam/logs/lepton-logcats" / f"steamlaunch-{appid}"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "logcat-system.log").write_text(syslog)
|
|
p = tmp_path / "launch.log"
|
|
p.write_text(log)
|
|
return str(p)
|
|
|
|
|
|
def test_restart_requested_after_unreals_first_run_precompile(monkeypatch, tmp_path):
|
|
"""The app's own LAUNCHER start (uid >= 10000) right before its System.exit(0) = a self-restart (agent v86):
|
|
the launcher starts Lepton again, which ended the container with the first process."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
log = _restart_logs(a, tmp_path, RESTART_LOG, RESTART_SYSLOG)
|
|
assert a.restart_requested(log, "2821789967", "com.Armature.SportsScramble")
|
|
assert "restarted itself" in Path(log).read_text()
|
|
|
|
|
|
def test_restart_not_requested_for_a_plain_exit(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
launch_only = RESTART_SYSLOG.splitlines(keepends=True)[0] # Lepton's start (uid 0) only
|
|
assert not a.restart_requested(_restart_logs(a, tmp_path, RESTART_LOG, launch_only), "2821789967",
|
|
"com.Armature.SportsScramble")
|
|
# Android's WebView helper exits at every container boot; another package's start doesn't count
|
|
webview = "10-13 10:51:26.516 380 380 I WebViewLoader-: System.exit called, status: 0\n"
|
|
assert not a.restart_requested(_restart_logs(a, tmp_path, webview, RESTART_SYSLOG), "2821789967",
|
|
"com.Armature.SportsScramble")
|
|
assert not a.restart_requested(_restart_logs(a, tmp_path, RESTART_LOG, RESTART_SYSLOG), "2821789967",
|
|
"com.Armature.Sports")
|
|
late = RESTART_LOG.replace("10:51:26.516", "10:52:26.516") # the exit a minute later: not that restart
|
|
assert not a.restart_requested(_restart_logs(a, tmp_path, late, RESTART_SYSLOG), "2821789967",
|
|
"com.Armature.SportsScramble")
|
|
assert not a.restart_requested(str(tmp_path / "missing.log"), "1", "com.x.y")
|
|
|
|
|
|
def test_upgrade_launchers_adds_the_restart(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
|
anchor.mkdir(parents=True)
|
|
old = (f'{a.OLD_WATCHDOG}\nsetsid /opt/lepton/lepton start >"$app_dir/launch.log" 2>&1 &\nchild=$!\n'
|
|
f'{a.dashboard_line()}\n{a.logcat_line()}\nwait "$child"\n')
|
|
(anchor / "launch.sh").write_text(old)
|
|
a.upgrade_launchers()
|
|
text = (anchor / "launch.sh").read_text()
|
|
assert "_restart_requested" in text and "com.x.y" in text and text.endswith("exit $rc\n")
|
|
assert text.count("setsid /opt/lepton/lepton start") == 2
|
|
assert subprocess.run(["bash", "-n", str(anchor / "launch.sh")]).returncode == 0
|
|
a.upgrade_launchers() # once only
|
|
assert (anchor / "launch.sh").read_text() == text
|
|
|
|
|
|
def test_launcher_starts_lepton_again_once_when_the_game_restarts_itself(monkeypatch, tmp_path):
|
|
"""End to end in bash (agent v86): a fake Lepton whose game restarts itself (the logs of Sports Scramble's first
|
|
start) is started a second time; a game that keeps restarting isn't started a third time."""
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
# no dashboard helper (matching its command line as text fails when the agent's path needs quoting, and the real
|
|
# one would briefly keep the launch lock after the first run)
|
|
monkeypatch.setattr(a, "dashboard_line", lambda: "true")
|
|
home = tmp_path / "home"
|
|
syslog_dir = home / ".local/share/Steam/logs/lepton-logcats/steamlaunch-2821789967"
|
|
syslog_dir.mkdir(parents=True)
|
|
base = tmp_path / "game"
|
|
(base / "lepton-app").mkdir(parents=True)
|
|
(tmp_path / "log.txt").write_text(RESTART_LOG)
|
|
(tmp_path / "syslog.txt").write_text(RESTART_SYSLOG)
|
|
lepton = tmp_path / "lepton"
|
|
lepton.write_text(f"#!/bin/bash\necho started >>{tmp_path}/starts\ncat {tmp_path}/log.txt\n"
|
|
f"cp {tmp_path}/syslog.txt {syslog_dir}/logcat-system.log\n")
|
|
lepton.chmod(0o755)
|
|
text = a.LAUNCH_SH.format(title="T", pkg="com.Armature.SportsScramble", app_dir=f"app_dir={base}",
|
|
appid=2821789967, lepton_q=str(lepton), extra_env="", cloud_dirs="true",
|
|
watchdog=a.WATCHDOG, dashboard="true",
|
|
logcat="true", single=a.SINGLE_LINE, plays_start="true", plays_end="true",
|
|
save_snapshot="true", video_codec="",
|
|
restart=a.restart_block("com.Armature.SportsScramble", str(lepton)))
|
|
text = text.replace("python3 " + os.path.abspath(a.__file__) + " _dashboard_worker", "true")
|
|
launcher = tmp_path / "launch.sh"
|
|
launcher.write_text(text)
|
|
launcher.chmod(0o755)
|
|
env = {**os.environ, "HOME": str(home)}
|
|
subprocess.run([str(launcher)], timeout=30, env=env, start_new_session=True)
|
|
assert (tmp_path / "starts").read_text().count("started") == 2
|
|
assert "restarted itself" in (base / "launch-before-restart.log").read_text()
|
|
(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_foveation_settings_rewrite_only_launcher_exports(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "anchors" / "test.game"
|
|
anchor.mkdir(parents=True)
|
|
base = tmp_path / "game"
|
|
base.mkdir()
|
|
launch = anchor / "launch.sh"
|
|
launch.write_text("#!/bin/bash\nexport UNRELATED='keep me'\nexport FDM_DEBUG=old\n"
|
|
"export FOVE_INNER=0.1\nrun_game\n")
|
|
a.ANCHORS = str(anchor.parent)
|
|
dep = {"base": str(base)}
|
|
writes = []
|
|
monkeypatch.setattr(a, "deployment", lambda pkg: dep)
|
|
monkeypatch.setattr(a, "write_deployment", lambda pkg, record: writes.append(copy_record(record)))
|
|
monkeypatch.setattr(a, "install_controller_models", lambda *args: {})
|
|
monkeypatch.setattr(a, "sync_game_config", lambda *args: {})
|
|
monkeypatch.setattr(a, "sync_refresh_rate", lambda *args: {})
|
|
env = {"FDM_DEBUG": "enable,disable_offsets", "FOVE_INNER": None,
|
|
"ENABLE_VULKAN_FDM_INJECTION_LAYER": "1"}
|
|
result = a.cmd_set_settings({"package": "test.game", "settings": {"scale": 1.2}, "foveation_env": env})
|
|
text = launch.read_text()
|
|
assert "export FDM_DEBUG=enable,disable_offsets" in text and "FOVE_INNER=" not in text
|
|
assert "export UNRELATED='keep me'\n" in text and text.endswith("run_game\n")
|
|
assert result["foveation_env"] == env and writes[-1]["foveation_env"] == env
|
|
assert (base / "settings.conf").read_text() == "scale=1.2\n"
|
|
assert not list(anchor.glob("*.tmp"))
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_set_settings({"package": "test.game", "foveation_env": {"PATH": "bad"}})
|
|
assert launch.read_text() == text
|
|
|
|
|
|
def copy_record(record):
|
|
return json.loads(json.dumps(record))
|
|
|
|
|
|
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)
|