mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-20 17:00:58 +02:00
280 lines
13 KiB
Python
280 lines
13 KiB
Python
"""Screenshots tab: the agent's listing (screenshots.vdf + folders, games matched by play session), its delete path
|
|
checks, the launchers' play log, and the PC-side cache/download helpers."""
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
from test_agent import load_agent
|
|
|
|
VDF = """"screenshots"
|
|
{
|
|
\t"250820"
|
|
\t{
|
|
\t\t"0"
|
|
\t\t{
|
|
\t\t\t"type"\t\t"1"
|
|
\t\t\t"filename"\t\t"250820/screenshots/20260928184431_1.jpg"
|
|
\t\t\t"thumbnail"\t\t"250820/screenshots/thumbnails/20260928184431_1.jpg"
|
|
\t\t\t"imported"\t\t"1"
|
|
\t\t\t"externalfilename"\t\t"/run/user/1000/Screenshot_Mon_Sep_28_18-44-31_2026.png"
|
|
\t\t\t"width"\t\t"1920"
|
|
\t\t\t"height"\t\t"1080"
|
|
\t\t\t"gameid"\t\t"250820"
|
|
\t\t\t"creation"\t\t"1790635471"
|
|
\t\t}
|
|
\t\t"1"
|
|
\t\t{
|
|
\t\t\t"filename"\t\t"250820/screenshots/20260927100000_1.jpg"
|
|
\t\t\t"creation"\t\t"1790500000"
|
|
\t\t}
|
|
\t\t"2"
|
|
\t\t{
|
|
\t\t\t"filename"\t\t"250820/screenshots/20260101000000_1.jpg"
|
|
\t\t\t"creation"\t\t"1700000000"
|
|
\t\t}
|
|
\t\t"3"
|
|
\t\t{
|
|
\t\t\t"filename"\t\t"../../../../config/escape.jpg"
|
|
\t\t}
|
|
\t}
|
|
}
|
|
"""
|
|
|
|
|
|
def make_tree(a, home):
|
|
user = home / ".local/share/Steam/userdata/123"
|
|
shots = user / "760/remote/250820/screenshots"
|
|
(shots / "thumbnails").mkdir(parents=True)
|
|
(user / "760/screenshots.vdf").write_text(VDF)
|
|
for name in ("20260928184431_1.jpg", "20260927100000_1.jpg"):
|
|
(shots / name).write_bytes(b"\xff\xd8" + b"x" * 100)
|
|
(shots / "thumbnails/20260928184431_1.jpg").write_bytes(b"\xff\xd8thumb")
|
|
# 20260101000000_1.jpg is listed but deleted; one file isn't in the vdf yet (Steam writes it at exit)
|
|
(shots / "20260929120000_1.jpg").write_bytes(b"\xff\xd8" + b"y" * 50)
|
|
(user / "config").mkdir()
|
|
# a FramePort game installed with its own Steam shortcut + plays.log sessions
|
|
anchor = home / "Applications/quest-frame/com.x.game"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "deployment.json").write_text(json.dumps({"package": "com.x.game", "title": "X Game",
|
|
"appid": 3000000001, "base": str(anchor)}))
|
|
(anchor / "plays.log").write_text("start 1790635000\nend 1790636000\nstart 1790499000\n")
|
|
other = home / "Applications/quest-frame/com.y.other"
|
|
other.mkdir(parents=True)
|
|
(other / "deployment.json").write_text(json.dumps({"package": "com.y.other", "title": "Other", "appid": 5}))
|
|
(other / "plays.log").write_text("start 1790499500\nend 1790500100\n")
|
|
# a screenshot filed under a FramePort game's own shortcut appid
|
|
own = user / "760/remote/3000000001/screenshots"
|
|
own.mkdir(parents=True)
|
|
(own / "20260930080000_1.jpg").write_bytes(b"\xff\xd8z")
|
|
return user
|
|
|
|
|
|
def test_kv_parse(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
d = a.kv_parse(VDF)
|
|
assert d["screenshots"]["250820"]["0"]["creation"] == "1790635471"
|
|
assert a.kv_parse('"a" { "b" "x\\"y" // c\n "c" { "d" "1"')["a"] == {"b": 'x"y', "c": {"d": "1"}} # cut off
|
|
assert a.kv_parse("") == {}
|
|
|
|
|
|
def test_list_screenshots_attributes_by_session(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
make_tree(a, tmp_path)
|
|
r = a.cmd_list_screenshots({})
|
|
names = [os.path.basename(s["path"]) for s in r["shots"]]
|
|
assert names == ["20260930080000_1.jpg", "20260929120000_1.jpg", "20260928184431_1.jpg",
|
|
"20260927100000_1.jpg"] # newest first; the deleted and the escaping entry are left out
|
|
by = {os.path.basename(s["path"]): s for s in r["shots"]}
|
|
# inside X Game's closed session
|
|
assert by["20260928184431_1.jpg"]["package"] == "com.x.game"
|
|
assert by["20260928184431_1.jpg"]["title"] == "X Game" and by["20260928184431_1.jpg"]["width"] == 1920
|
|
assert by["20260928184431_1.jpg"]["thumb"].endswith("thumbnails/20260928184431_1.jpg")
|
|
# X Game's open session (no end) was ended by Other's later start, so this one is Other's
|
|
assert by["20260927100000_1.jpg"]["package"] == "com.y.other"
|
|
# not in the vdf: timed by its name, outside every session -> SteamVR
|
|
assert by["20260929120000_1.jpg"]["package"] is None and by["20260929120000_1.jpg"]["title"] == "SteamVR"
|
|
assert by["20260929120000_1.jpg"]["time"] == int(time.mktime((2026, 9, 29, 12, 0, 0, 0, 0, -1)))
|
|
assert by["20260929120000_1.jpg"]["thumb"] is None
|
|
# filed under the game's own shortcut
|
|
assert by["20260930080000_1.jpg"]["package"] == "com.x.game" and by["20260930080000_1.jpg"]["account"] == "123"
|
|
assert r["total"] == 4
|
|
assert {(g["package"], g["count"]) for g in r["games"]} == {("com.x.game", 2), ("com.y.other", 1), (None, 1)}
|
|
page = a.cmd_list_screenshots({"package": "com.x.game", "offset": 1, "limit": 5})
|
|
assert page["total"] == 2 and [s["path"] for s in page["shots"]] == [by["20260928184431_1.jpg"]["path"]]
|
|
assert [s["title"] for s in a.cmd_list_screenshots({"package": ""})["shots"]] == ["SteamVR"]
|
|
|
|
|
|
def test_list_screenshots_without_steam(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
assert a.cmd_list_screenshots({}) == {"shots": [], "total": 0, "games": []}
|
|
|
|
|
|
def test_open_session_ends_after_a_while(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
anchor = tmp_path / "Applications/quest-frame/com.x.game"
|
|
anchor.mkdir(parents=True)
|
|
(anchor / "plays.log").write_text("start 1000\ngarbage\nend x\n")
|
|
sessions = a.play_sessions()
|
|
assert sessions == [(1000, 1000 + a.OPEN_SESSION, "com.x.game")]
|
|
assert a.session_game(1500, sessions) == "com.x.game" and a.session_game(999, sessions) is None
|
|
assert a.session_game(1000 + a.OPEN_SESSION + 60, sessions) is None
|
|
|
|
|
|
def test_delete_screenshots_only_in_screenshot_folders(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
user = make_tree(a, tmp_path)
|
|
shots = user / "760/remote/250820/screenshots"
|
|
target = str(shots / "20260928184431_1.jpg")
|
|
bad = ["/etc/passwd", str(user / "760/screenshots.vdf"), str(shots / "thumbnails/20260928184431_1.jpg"),
|
|
str(shots / "../screenshots/20260928184431_1.jpg"), str(shots / "notashot.jpg"), "relative.jpg",
|
|
str(shots / "20260101000000_1.jpg"), 42]
|
|
(shots / "notashot.jpg").write_bytes(b"x")
|
|
for p in bad:
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_delete_screenshots({"paths": [target, p]})
|
|
assert os.path.exists(target) # nothing deleted when any path is refused
|
|
outside = tmp_path / "secret"
|
|
outside.mkdir()
|
|
(outside / "20260101000000_1.jpg").write_bytes(b"s")
|
|
link_dir = user / "760/remote/999/screenshots"
|
|
link_dir.parent.mkdir(parents=True)
|
|
link_dir.symlink_to(outside)
|
|
with pytest.raises(a.AgentError):
|
|
a.cmd_delete_screenshots({"paths": [str(link_dir / "20260101000000_1.jpg")]})
|
|
assert (outside / "20260101000000_1.jpg").exists()
|
|
(shots / "20260928184431_1_vr.jpg").write_bytes(b"stereo") # Steam's stereo copy of a VR screenshot
|
|
r = a.cmd_delete_screenshots({"paths": [target]})
|
|
assert r == {"deleted": [target]}
|
|
assert not os.path.exists(target) and not (shots / "thumbnails/20260928184431_1.jpg").exists()
|
|
assert not (shots / "20260928184431_1_vr.jpg").exists()
|
|
assert "20260928184431_1.jpg" in (user / "760/screenshots.vdf").read_text() # Steam's index is left alone
|
|
assert target not in [s["path"] for s in a.cmd_list_screenshots({})["shots"]]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="bash launcher")
|
|
def test_launcher_logs_play_sessions(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
base, anchor = tmp_path / "base", tmp_path / "anchor dir"
|
|
(base / "lepton-app").mkdir(parents=True)
|
|
anchor.mkdir()
|
|
lepton = tmp_path / "lepton"
|
|
lepton.write_text("#!/bin/bash\nexit 0\n")
|
|
lepton.chmod(0o755)
|
|
a.write_launcher(str(anchor), str(base), "com.x.y", "X", 123, str(lepton), {"FRAMEPORT_KEEP_DASHBOARD": "1"})
|
|
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"}
|
|
(tmp_path / "podman").write_text("#!/bin/sh\nexit 0\n")
|
|
(tmp_path / "podman").chmod(0o755)
|
|
subprocess.run(["bash", str(anchor / "launch.sh")], env=env, timeout=30, capture_output=True)
|
|
lines = (anchor / "plays.log").read_text().split("\n")
|
|
assert lines[0].startswith("start ") and lines[1].startswith("end ") and lines[2] == ""
|
|
assert int(lines[0].split()[1]) <= int(lines[1].split()[1])
|
|
|
|
|
|
def test_upgrade_launchers_adds_play_log(monkeypatch, tmp_path):
|
|
a = load_agent(monkeypatch, tmp_path)
|
|
root = tmp_path / "Applications/quest-frame"
|
|
quest, pcvr = root / "com.x.y", root / "rift.z"
|
|
quest.mkdir(parents=True)
|
|
pcvr.mkdir(parents=True)
|
|
a.write_launcher(str(quest), "/b", "com.x.y", "X", 1, "/lepton", {})
|
|
old = "\n".join(line for line in (quest / "launch.sh").read_text().split("\n") if "plays.log" not in line)
|
|
(quest / "launch.sh").write_text(old)
|
|
(pcvr / "launch.sh").write_text('#!/bin/bash\necho hi >"$base/launch.log"\nexec wine game.exe\n')
|
|
assert sorted(a.upgrade_launchers()) == ["com.x.y", "rift.z"]
|
|
q = (quest / "launch.sh").read_text()
|
|
assert q.count("plays.log") == 2 and q.index('"start $(date') < q.index("\nsetsid ")
|
|
assert ' echo "end $(date +%s)"' in q
|
|
assert subprocess.run(["bash", "-n", str(quest / "launch.sh")]).returncode == 0
|
|
p = (pcvr / "launch.sh").read_text()
|
|
assert p.count("plays.log") == 1 and p.index("plays.log") < p.index("\nexec ")
|
|
assert a.upgrade_launchers() == []
|
|
|
|
|
|
# ------------------------------------------------------------------ PC side
|
|
class FakeSFTP:
|
|
def __init__(self):
|
|
self.gets = []
|
|
|
|
def get(self, remote, local, callback=None):
|
|
self.gets.append(remote)
|
|
shutil.copyfile(remote, local)
|
|
if callback:
|
|
callback(os.path.getsize(remote), os.path.getsize(remote))
|
|
|
|
|
|
class FakeFrame:
|
|
def __init__(self, agent_mod):
|
|
self.sftp = FakeSFTP()
|
|
self.a = agent_mod
|
|
self.target = type("T", (), {"host": "10.0.0.5", "name": ""})()
|
|
|
|
def agent(self, command, **args):
|
|
return self.a.COMMANDS[command](args)
|
|
|
|
|
|
@pytest.fixture
|
|
def pc(monkeypatch, tmp_path):
|
|
from frameport.install import installer
|
|
|
|
monkeypatch.setattr(installer, "transfer_link", lambda f, r, n: contextlib.nullcontext(f))
|
|
frame_home = tmp_path / "frame"
|
|
frame_home.mkdir()
|
|
a = load_agent(monkeypatch, frame_home)
|
|
make_tree(a, frame_home)
|
|
monkeypatch.setenv("FRAMEPORT_HOME", str(tmp_path / "pc"))
|
|
return FakeFrame(a)
|
|
|
|
|
|
def test_pc_cache_and_download(pc, tmp_path):
|
|
from frameport.core.paths import user_data_dir
|
|
from frameport.install import screenshots
|
|
|
|
r = screenshots.list_shots(pc, package="com.x.game")
|
|
assert r["total"] == 2
|
|
shot = next(s for s in r["shots"] if s["thumb"])
|
|
assert screenshots.cached(pc, shot) is None
|
|
t = screenshots.thumb_path(pc, shot)
|
|
assert t.read_bytes() == b"\xff\xd8thumb" and t.is_relative_to(user_data_dir() / "screenshots-cache" / "10.0.0.5")
|
|
assert screenshots.thumb_path(pc, shot) == t and pc.sftp.gets == [shot["thumb"]] # downloaded once
|
|
assert screenshots.cached(pc, shot) == t
|
|
full = screenshots.image_path(pc, shot)
|
|
assert full.read_bytes() == open(shot["path"], "rb").read()
|
|
# without a Steam thumbnail one is made from the full image (here: not an image -> the original is used)
|
|
no_thumb = next(s for s in r["shots"] if not s["thumb"])
|
|
assert screenshots.thumb_path(pc, no_thumb).exists()
|
|
out = tmp_path / "out"
|
|
got = screenshots.download(pc, r["shots"], out)
|
|
assert got["files"] == 2 and (out / "X Game" / os.path.basename(shot["path"])).exists()
|
|
assert screenshots.download(pc, r["shots"], out)["skipped"] == 2 # same size: not copied again
|
|
assert screenshots.delete(pc, [shot]) == 1 and not os.path.exists(shot["path"])
|
|
assert screenshots.cached(pc, shot) is None
|
|
|
|
|
|
def test_pc_download_cancel(pc, tmp_path):
|
|
from frameport.core.events import Cancelled, Reporter
|
|
from frameport.install import screenshots
|
|
|
|
rep = Reporter()
|
|
rep.cancelled.set()
|
|
with pytest.raises(Cancelled):
|
|
screenshots.download(pc, screenshots.list_shots(pc)["shots"], tmp_path / "out", rep)
|
|
assert not list((tmp_path / "out").rglob("*.jpg"))
|
|
|
|
|
|
def test_group_by_day():
|
|
from frameport.ui.views.screenshots import agent_filter, filter_key, group_by_day
|
|
|
|
now = time.mktime((2026, 10, 4, 15, 0, 0, 0, 0, -1))
|
|
shots = [{"time": now - 60}, {"time": now - 3600}, {"time": now - 86400}, {"time": now - 5 * 86400},
|
|
{"time": None}]
|
|
groups = group_by_day(shots, now)
|
|
assert [(label, len(s)) for label, s in groups] == [("Today", 2), ("Yesterday", 1), ("2026-09-29", 1),
|
|
("Unknown date", 1)]
|
|
assert agent_filter(filter_key(None)) is None and agent_filter(filter_key("")) == ""
|
|
assert agent_filter(filter_key("com.x")) == "com.x"
|