mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 21:13:48 +01:00
The enforce service runs as root (no User=) but sets DISPLAY=:0, so any GUI
it starts lands on the desktop session. Two resolvers disagreed about who to
drop privileges to:
library_hider._resolve_desktop_user() read STEAM_ENFORCER_DESKTOP_USER
game_install._get_real_user() read only SUDO_USER or USER
systemd sets neither of the latter, so _get_real_user() returned None under
the service and every `geteuid() == 0 and real_user and ...` guard fell
through to running as root. Steam answered with a zenity modal ("Cannot run
as root user", /usr/lib/steam/steam:261) on screen.
Confirmed from the live process tree: root `bash /usr/lib/steam/steam
-cef-disable-gpu ... steam://install/618510` with a root zenity child. It
never reached the journal, because that text goes to zenity and _run_as_user
sends child output to DEVNULL.
- _desktop_env: add resolve_desktop_user/desktop_uid/desktop_user_cmd. Only
the argv builder is shared, never the spawn call: tests/_no_subprocess.py
patches subprocess per module by name, so a spawn site in an unpatched
module could fire a real `sudo -u kuhy steam`.
- game_install._get_real_user: delegate to the shared resolver. This alone
also fixes the root `steam -silent` in _ensure_steam_running and the
no-op os.chown on new appmanifests.
- _trigger_steam_install / enforcer.send_notification: drop privileges.
send_notification had no DBUS_SESSION_BUS_ADDRESS, so it silently reached
nobody for all six of its callers.
- _run_as_user: fail closed. Running unchanged as root when no desktop user
resolves is what put the modal on screen; refuse and log instead.
Also stop the retry storm behind it. steamapps does not exist (Steam has
never completed sign-in), so is_game_installed() was always False and the
loop re-triggered every allowed game every pass: 1656 attempts in 30
minutes, service RSS 1.5 GB (peak 4.3 GB), a FileNotFoundError traceback
each time. Guard install_game and _reinstall_missing_allowed on
steam_library_ready() -- a missing library means no install can succeed by
any route, so this is a correctness check rather than a tuned retry limit.
The module splits here are forced by the pre-commit 250-line gate, which
blocks any commit touching an over-cap file: game_install, library_hider and
_enforce_loop were already over before this change. They are split by
responsibility (_cdp transport, _steam_launch lifecycle, _steam_process
spawning, _steam_errors, _steam_client, _game_names, game_uninstall,
_enforce_steps, _owned_apps_cache, _echo, _protected_apps). game_install and
library_hider re-export their old public names, so the 15 modules that
import from them are untouched by this commit.
Verified as real root under the unit's environment: argv is now
`sudo -u kuhy env DISPLAY=:0 ... xdg-open steam://install/...` for both the
installer and notify-send; three enforce passes spawn no process and log
once; _run_as_user refuses with no desktop user. After restart: 0 retries,
0 tracebacks, 0 root-owned files under ~/.local/share/Steam, RSS 342 MB.
1311 tests pass. _desktop_env and _steam_state at 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
198 lines
6.7 KiB
Python
198 lines
6.7 KiB
Python
"""Hide / unhide games in the Steam library via Chrome DevTools Protocol.
|
|
|
|
Modern Steam clients (2023+) use an internal ``collectionStore`` JS
|
|
object running inside the CEF (Chromium Embedded Framework) browser.
|
|
Game collections (including "hidden") are synced to Steam Cloud and
|
|
can only be reliably modified through this API.
|
|
|
|
This module connects to Steam's ``SharedJSContext`` page over CDP
|
|
(Chrome DevTools Protocol) on a local debug port and evaluates
|
|
JavaScript to call ``collectionStore.SetAppsAsHidden()``.
|
|
|
|
Steam must be running with ``-cef-enable-debugging`` and
|
|
``-devtools-port=<PORT>`` for this to work. If it isn't, the module
|
|
will shut Steam down and relaunch it with the required flags.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from steam_backlog_enforcer._cdp import _cdp_result_value, _evaluate_js
|
|
from steam_backlog_enforcer._steam_errors import (
|
|
SteamUnavailableError,
|
|
)
|
|
from steam_backlog_enforcer._steam_launch import (
|
|
ensure_steam_debug_port,
|
|
restart_steam,
|
|
steam_is_installed,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Re-exported: the Steam lifecycle now lives in _steam_launch, but callers
|
|
# still import these from here. __all__ stops the linter deleting them.
|
|
__all__ = [
|
|
"hide_other_games",
|
|
"restart_steam",
|
|
"steam_is_installed",
|
|
"try_hide_other_games",
|
|
"unhide_all_games",
|
|
]
|
|
|
|
_CDP_PORT = 9222
|
|
_CDP_TIMEOUT = 120
|
|
_STEAM_STARTUP_WAIT = 45
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Hide / unhide logic
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
_HIDE_BATCH_SIZE = 50
|
|
_MAX_HIDE_PASSES = 30
|
|
_SETTLE_DELAY_MS = 200
|
|
|
|
|
|
def hide_other_games(
|
|
owned_app_ids: list[int],
|
|
allowed_app_ids: set[int],
|
|
) -> int:
|
|
"""Hide every game except *allowed_app_ids* in the Steam library.
|
|
|
|
Uses the Chrome DevTools Protocol to call
|
|
``collectionStore.SetAppsAsHidden()`` in Steam's JS context.
|
|
|
|
The entire retry loop runs inside a single JS evaluation to avoid
|
|
WebSocket round-trip overhead. ``SetAppsAsHidden`` is unreliable
|
|
in a single pass for large libraries, so the JS loop retries until
|
|
``visibleApps`` converges to only the allowed game.
|
|
|
|
On the first pass, caller-provided *owned_app_ids* are included to
|
|
cover games that might not yet appear in ``visibleApps`` due to
|
|
stale MobX state.
|
|
|
|
Returns the total number of games hidden across all passes.
|
|
"""
|
|
ensure_steam_debug_port()
|
|
|
|
allowed_json = json.dumps(sorted(allowed_app_ids))
|
|
extra_ids = sorted(aid for aid in owned_app_ids if aid not in allowed_app_ids)
|
|
extra_json = json.dumps(extra_ids)
|
|
js = f"""
|
|
(async () => {{
|
|
const allowed = new Set({allowed_json});
|
|
const coll = collectionStore.allGamesCollection;
|
|
const extraIds = {extra_json};
|
|
let totalHidden = 0;
|
|
const maxPasses = {_MAX_HIDE_PASSES};
|
|
const batchSize = {_HIDE_BATCH_SIZE};
|
|
|
|
async function safeHide(ids) {{
|
|
if (ids.length === 0) return 0;
|
|
try {{
|
|
await collectionStore.SetAppsAsHidden(ids, true);
|
|
return ids.length;
|
|
}} catch(e) {{
|
|
if (ids.length === 1) return 0;
|
|
const mid = Math.floor(ids.length / 2);
|
|
return (await safeHide(ids.slice(0, mid))) +
|
|
(await safeHide(ids.slice(mid)));
|
|
}}
|
|
}}
|
|
|
|
for (let pass = 0; pass < maxPasses; pass++) {{
|
|
let visible = coll && coll.visibleApps
|
|
? coll.visibleApps.map(a => a.appid).filter(id => !allowed.has(id))
|
|
: [];
|
|
|
|
if (pass === 0) {{
|
|
const visSet = new Set(visible);
|
|
for (const id of extraIds) {{
|
|
if (!visSet.has(id)) visible.push(id);
|
|
}}
|
|
}}
|
|
|
|
if (visible.length === 0) break;
|
|
|
|
for (let i = 0; i < visible.length; i += batchSize) {{
|
|
const batch = visible.slice(i, i + batchSize);
|
|
totalHidden += await safeHide(batch);
|
|
}}
|
|
|
|
await new Promise(r => setTimeout(r, {_SETTLE_DELAY_MS}));
|
|
}}
|
|
|
|
if (allowed.size > 0) {{
|
|
await collectionStore.SetAppsAsHidden([...allowed], false);
|
|
}}
|
|
|
|
return JSON.stringify({{ totalHidden }});
|
|
}})()
|
|
"""
|
|
|
|
result = _evaluate_js(js)
|
|
value = _cdp_result_value(result)
|
|
parsed = json.loads(value)
|
|
count: int = parsed["totalHidden"]
|
|
logger.info("Hid %d games via CDP.", count)
|
|
return count
|
|
|
|
|
|
def try_hide_other_games(
|
|
owned_app_ids: list[int],
|
|
allowed_app_ids: set[int],
|
|
) -> tuple[int, str | None]:
|
|
"""Hide other games, degrading gracefully when Steam cannot be driven.
|
|
|
|
An unreachable Steam (not running, no debug port) or a deferred restart
|
|
(a game update is in flight) is never fatal: there is simply no library to
|
|
reconcile this pass. Every interactive command should skip and carry on
|
|
rather than abort with a traceback, so this wrapper turns the
|
|
:class:`SteamUnavailableError` family into a return value.
|
|
|
|
Stdout-free on purpose — callers phrase the skip message themselves.
|
|
|
|
Args:
|
|
owned_app_ids: All owned app ids, used to seed the first hide pass.
|
|
allowed_app_ids: Every game that must stay visible.
|
|
|
|
Returns:
|
|
``(hidden_count, skip_reason)``. ``skip_reason`` is ``None`` when the
|
|
pass ran; otherwise it explains why hiding was skipped and the count
|
|
is 0.
|
|
"""
|
|
try:
|
|
return hide_other_games(owned_app_ids, allowed_app_ids), None
|
|
except SteamUnavailableError as exc:
|
|
return 0, str(exc)
|
|
|
|
|
|
def unhide_all_games(owned_app_ids: list[int]) -> int:
|
|
"""Remove all games from the hidden collection.
|
|
|
|
Returns the number of games that were unhidden.
|
|
"""
|
|
ensure_steam_debug_port()
|
|
|
|
json.dumps(sorted(owned_app_ids))
|
|
js = """
|
|
(async () => {
|
|
const hidden = collectionStore.GetCollection('hidden');
|
|
if (!hidden || !hidden.allApps) return JSON.stringify({ count: 0 });
|
|
const hiddenIds = hidden.allApps.map(a => a.appid);
|
|
if (hiddenIds.length === 0) return JSON.stringify({ count: 0 });
|
|
await collectionStore.SetAppsAsHidden(hiddenIds, false);
|
|
return JSON.stringify({ count: hiddenIds.length });
|
|
})()
|
|
"""
|
|
|
|
result = _evaluate_js(js)
|
|
value = _cdp_result_value(result)
|
|
parsed = json.loads(value)
|
|
count: int = parsed["count"]
|
|
logger.info("Unhidden %d games via CDP.", count)
|
|
return count
|