From 0018673747b3af2271d99b4d2c8c350166b75ab7 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 13 Jul 2026 04:54:17 +0800 Subject: [PATCH] fix(plugin): discover Steam Game Mode screenshots + safe preview fallback --- plugins/decky-vault/main.py | 40 +++++--- plugins/decky-vault/src/lib/api.ts | 4 +- plugins/decky-vault/tests/test_screenshots.py | 91 +++++++++++++++++++ 3 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 plugins/decky-vault/tests/test_screenshots.py diff --git a/plugins/decky-vault/main.py b/plugins/decky-vault/main.py index 75112ea..8efce87 100644 --- a/plugins/decky-vault/main.py +++ b/plugins/decky-vault/main.py @@ -641,42 +641,50 @@ exec mangohud "$@" except Exception as e: return {"success": False, "error": str(e), "status": 0} - async def list_screenshots(self, limit: int = 50) -> dict: - """RPC: List recent Steam Deck screenshots from ~/Pictures/Screenshots/. - Returns {screenshots: [{path, name, mtime, size}], error?}. - Steam saves timestamped JPGs in a 'Steam Client' subfolder and keeps - a 'most_recent.jpg' symlink-like copy at the top level.""" + async def list_screenshots(self, limit: int = 50, app_id: int | None = None) -> dict: + """RPC: List recent Steam screenshots from both Game Mode (userdata/760/remote) + and Desktop Mode (~/Pictures/Screenshots). Returns newest first. + When app_id is given, keeps all Desktop exports + that app's userdata shots.""" import glob - import time try: home = os.path.expanduser("~") base = os.path.join(home, "Pictures", "Screenshots") + userdata = os.path.join(home, ".local", "share", "Steam", "userdata") patterns = [ os.path.join(base, "*.jpg"), os.path.join(base, "*.png"), os.path.join(base, "Steam Client", "*.jpg"), os.path.join(base, "Steam Client", "*.png"), + os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.jpg"), + os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.png"), ] - seen = set() - files = [] + seen, files = set(), [] for pat in patterns: for f in glob.glob(pat): if not os.path.isfile(f) or f in seen: continue - # Skip the most_recent.jpg duplicate if a real timestamped - # copy exists — it's just a pointer to the latest one. if os.path.basename(f) == "most_recent.jpg": continue seen.add(f) + f_app_id = None + parts = f.split(os.sep) + if "760" in parts: + idx = parts.index("760") + if idx >= 2: + try: + f_app_id = int(parts[idx + 2]) + except (ValueError, IndexError): + pass try: files.append({ - "path": f, - "name": os.path.basename(f), - "mtime": os.path.getmtime(f), - "size": os.path.getsize(f), + "path": f, "name": os.path.basename(f), + "mtime": os.path.getmtime(f), "size": os.path.getsize(f), + "appId": f_app_id, }) except OSError: continue + if app_id is not None: + files = [x for x in files if x["appId"] is None or x["appId"] == app_id] files.sort(key=lambda x: x["mtime"], reverse=True) return {"screenshots": files[:limit]} except Exception as e: @@ -707,9 +715,11 @@ exec mangohud "$@" b64 = base64.b64encode(buf.getvalue()).decode("ascii") return {"dataUrl": f"data:image/jpeg;base64,{b64}"} except ImportError: - # No Pillow — return the raw file as a data URL + # No Pillow — only return raw if small enough for CEF; else skip preview. ext = os.path.splitext(path)[1].lower() mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg") + if len(raw) > 1_000_000: + return {"dataUrl": "", "error": "Preview unavailable (too large, no Pillow)"} b64 = base64.b64encode(raw).decode("ascii") return {"dataUrl": f"data:{mime};base64,{b64}"} except Exception as e: diff --git a/plugins/decky-vault/src/lib/api.ts b/plugins/decky-vault/src/lib/api.ts index 25ff508..d216132 100644 --- a/plugins/decky-vault/src/lib/api.ts +++ b/plugins/decky-vault/src/lib/api.ts @@ -93,8 +93,8 @@ export const uploadToDeckyvault = callable<[ status?: number }>("upload_to_deckyvault") -export const listScreenshots = callable<[limit?: number], { - screenshots: Array<{ path: string; name: string; mtime: number; size: number }> +export const listScreenshots = callable<[limit?: number, appId?: number], { + screenshots: Array<{ path: string; name: string; mtime: number; size: number; appId: number | null }> error?: string }>("list_screenshots") diff --git a/plugins/decky-vault/tests/test_screenshots.py b/plugins/decky-vault/tests/test_screenshots.py new file mode 100644 index 0000000..ae8ffdf --- /dev/null +++ b/plugins/decky-vault/tests/test_screenshots.py @@ -0,0 +1,91 @@ +"""Tests for screenshot discovery across Steam Game Mode + Desktop paths (Bug 3).""" +import os +import tempfile +import time +import pytest + + +def _touch(path, mtime_age=10, content=b"\xff\xd8\xff\xe0"): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(content) + t = time.time() - mtime_age + os.utime(path, (t, t)) + + +def _discover(home, app_id=None): + """Mirror of Plugin.list_screenshot discovery against a fake home dir.""" + import glob + base = os.path.join(home, "Pictures", "Screenshots") + userdata = os.path.join(home, ".local", "share", "Steam", "userdata") + patterns = [ + os.path.join(base, "*.jpg"), + os.path.join(base, "*.png"), + os.path.join(base, "Steam Client", "*.jpg"), + os.path.join(base, "Steam Client", "*.png"), + os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.jpg"), + os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.png"), + ] + seen, files = set(), [] + for pat in patterns: + for f in glob.glob(pat): + if not os.path.isfile(f) or f in seen: + continue + if os.path.basename(f) == "most_recent.jpg": + continue + seen.add(f) + parts = f.split(os.sep) + f_app_id = None + if "760" in parts: + idx = parts.index("760") + if idx >= 2: + try: + f_app_id = int(parts[idx + 2]) + except (ValueError, IndexError): + pass + try: + files.append({"path": f, "name": os.path.basename(f), + "mtime": os.path.getmtime(f), "size": os.path.getsize(f), + "appId": f_app_id}) + except OSError: + continue + files.sort(key=lambda x: x["mtime"], reverse=True) + if app_id is not None: + files = [x for x in files if x["appId"] is None or x["appId"] == app_id] + return files + + +def test_discovers_all_three_locations(): + with tempfile.TemporaryDirectory() as home: + _touch(os.path.join(home, "Pictures", "Screenshots", "desktop.jpg"), mtime_age=30) + _touch(os.path.join(home, "Pictures", "Screenshots", "Steam Client", "sc.jpg"), mtime_age=20) + _touch(os.path.join(home, ".local", "share", "Steam", "userdata", "111", "760", + "remote", "2531310", "screenshots", "game.jpg"), mtime_age=10) + found = _discover(home) + names = [f["name"] for f in found] + assert set(names) == {"desktop.jpg", "sc.jpg", "game.jpg"} + assert found[0]["name"] == "game.jpg" + + +def test_app_id_filter_keeps_desktop_plus_app(): + with tempfile.TemporaryDirectory() as home: + _touch(os.path.join(home, "Pictures", "Screenshots", "desktop.jpg"), mtime_age=30) + _touch(os.path.join(home, ".local", "share", "Steam", "userdata", "111", "760", + "remote", "2531310", "screenshots", "want.jpg"), mtime_age=10) + _touch(os.path.join(home, ".local", "share", "Steam", "userdata", "111", "760", + "remote", "9999", "screenshots", "other.jpg"), mtime_age=5) + found = _discover(home, app_id=2531310) + names = [f["name"] for f in found] + assert "want.jpg" in names + assert "desktop.jpg" in names + assert "other.jpg" not in names + + +def test_most_recent_duplicate_skipped(): + with tempfile.TemporaryDirectory() as home: + _touch(os.path.join(home, "Pictures", "Screenshots", "most_recent.jpg"), mtime_age=1) + _touch(os.path.join(home, "Pictures", "Screenshots", "2026-01-01.jpg"), mtime_age=2) + found = _discover(home) + names = [f["name"] for f in found] + assert "most_recent.jpg" not in names + assert "2026-01-01.jpg" in names \ No newline at end of file