steam-backlog-enforcer/steam_backlog_enforcer/library_hider.py
Krzysztof kuhy Rudnicki 21fe2f7742 Stop the enforcer launching Steam and notifications as root
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
2026-08-24 13:55:44 +02:00

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