steam-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """steam-engine — drive a running Steam client's internals over CDP."""
2
+
3
+ from .client import SteamEngine
4
+ from .jsproxy import JsProxy
5
+
6
+ __all__ = ["SteamEngine", "JsProxy"]
7
+ __version__ = "0.1.0"
steam_engine/cdp.py ADDED
@@ -0,0 +1,105 @@
1
+ """Minimal synchronous Chrome DevTools Protocol client for Steam's CEF webhelper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+
9
+ from websockets.sync.client import connect as ws_connect
10
+
11
+
12
+ @dataclass
13
+ class Target:
14
+ kind: str
15
+ title: str
16
+ url: str
17
+ ws_url: str
18
+
19
+
20
+ def list_targets(port: int = 1337) -> list[Target]:
21
+ """GET http://127.0.0.1:<port>/json — all debuggable targets."""
22
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=5) as r:
23
+ raw = json.load(r)
24
+ return [
25
+ Target(
26
+ kind=t.get("type", ""),
27
+ title=t.get("title", ""),
28
+ url=t.get("url", ""),
29
+ ws_url=t.get("webSocketDebuggerUrl", ""),
30
+ )
31
+ for t in raw
32
+ ]
33
+
34
+
35
+ def pick_ui_target(targets: list[Target]) -> Target | None:
36
+ """The SharedJSContext page — where collectionStore/appStore/SteamClient live."""
37
+ for t in targets:
38
+ if t.title == "SharedJSContext":
39
+ return t
40
+ for t in targets:
41
+ if "steamloopback.host" in t.url:
42
+ return t
43
+ return None
44
+
45
+
46
+ class Session:
47
+ """A CDP session attached to one target. Sequential request/response only —
48
+ events are discarded. Not thread-safe."""
49
+
50
+ def __init__(self, ws):
51
+ self._ws = ws
52
+ self._id = 0
53
+
54
+ @classmethod
55
+ def connect(cls, port: int = 1337, target: Target | None = None) -> Session:
56
+ if target is None:
57
+ target = pick_ui_target(list_targets(port))
58
+ if target is None or not target.ws_url:
59
+ raise RuntimeError(
60
+ "SharedJSContext target not found — is Steam running with "
61
+ "--remote-debugging-port open?"
62
+ )
63
+ return cls(ws_connect(target.ws_url, open_timeout=10))
64
+
65
+ def eval(self, expression: str):
66
+ """Runtime.evaluate with returnByValue+awaitPromise. Returns result.value."""
67
+ self._id += 1
68
+ my_id = self._id
69
+ self._ws.send(
70
+ json.dumps(
71
+ {
72
+ "id": my_id,
73
+ "method": "Runtime.evaluate",
74
+ "params": {
75
+ "expression": expression,
76
+ "returnByValue": True,
77
+ "awaitPromise": True,
78
+ },
79
+ }
80
+ )
81
+ )
82
+ while True:
83
+ msg = json.loads(self._ws.recv())
84
+ if msg.get("id") != my_id:
85
+ continue # event or unrelated response
86
+ result = msg.get("result", {})
87
+ exc = result.get("exceptionDetails")
88
+ if exc:
89
+ desc = (
90
+ exc.get("exception", {}).get("description")
91
+ or exc.get("text")
92
+ or "js exception"
93
+ )
94
+ raise RuntimeError(f"JS exception: {desc}")
95
+ return result.get("result", {}).get("value")
96
+
97
+ def eval_json(self, expression: str):
98
+ """Evaluate an expression that returns a JSON.stringify'ed string."""
99
+ v = self.eval(expression)
100
+ if not isinstance(v, str):
101
+ raise RuntimeError(f"expected JSON string result, got {type(v).__name__}")
102
+ return json.loads(v)
103
+
104
+ def close(self):
105
+ self._ws.close()
steam_engine/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """steam-engine CLI — generic driver commands only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from .cdp import list_targets
11
+ from .client import SteamEngine
12
+ from .enable import enable as _enable
13
+
14
+
15
+ def _parser() -> argparse.ArgumentParser:
16
+ p = argparse.ArgumentParser(
17
+ prog="steam-engine",
18
+ description="Drive a running Steam client's internal JS (SteamClient.*, window.* stores) over CDP",
19
+ )
20
+ p.add_argument("--port", type=int,
21
+ default=int(os.environ.get("STEAM_CDP_PORT", "1337")),
22
+ help="remote-debugging port (env STEAM_CDP_PORT)")
23
+ sub = p.add_subparsers(dest="cmd", required=True)
24
+
25
+ sub.add_parser("status", help="probe the CDP endpoint")
26
+ sub.add_parser("targets", help="list debuggable targets")
27
+ e = sub.add_parser("enable", help="patch webhelper wrapper (Linux)")
28
+ e.add_argument("--restart", action="store_true",
29
+ help="kill steamwebhelper so Steam respawns it patched")
30
+ ev = sub.add_parser("eval", help="evaluate raw JS in SharedJSContext")
31
+ ev.add_argument("expr")
32
+
33
+ return p
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ args = _parser().parse_args(argv)
38
+
39
+ if args.cmd == "enable":
40
+ print(_enable(args.port, restart=args.restart))
41
+ return 0
42
+ if args.cmd == "status":
43
+ st = SteamEngine.status(args.port)
44
+ print(json.dumps(st, indent=2))
45
+ return 0 if st["reachable"] else 1
46
+ if args.cmd == "targets":
47
+ for t in list_targets(args.port):
48
+ print(f"{t.kind:<10} {t.title:<35} {t.url[:80]}")
49
+ return 0
50
+ if args.cmd == "eval":
51
+ with SteamEngine.connect(args.port) as se:
52
+ print(json.dumps(se.eval(args.expr), indent=2, default=str))
53
+ return 0
54
+ return 1
55
+
56
+
57
+ if __name__ == "__main__":
58
+ sys.exit(main())
steam_engine/client.py ADDED
@@ -0,0 +1,105 @@
1
+ """SteamEngine — main entry point.
2
+
3
+ from steam_engine import SteamEngine
4
+
5
+ with SteamEngine.connect() as se:
6
+ se.client.Apps.SetAppLaunchOptions(292030, "-fullscreen")
7
+ apps = se.window.appStore.allApps.get()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+
15
+ from . import cdp
16
+ from .enable import enable as _enable
17
+ from .jsproxy import JsProxy
18
+
19
+
20
+ class SteamEngine:
21
+ def __init__(self, session: cdp.Session):
22
+ self._session = session
23
+ self._name_cache: dict[tuple[str, str], str | None] = {}
24
+ # SteamClient.* — PascalCase bridge into the native client.
25
+ self.client = JsProxy(self, "SteamClient", convert=True)
26
+ # window.* — every store (collectionStore, appStore, downloadsStore, ...).
27
+ self.window = JsProxy(self, "window")
28
+
29
+ @classmethod
30
+ def connect(cls, port: int = 1337) -> SteamEngine:
31
+ """Attach to Steam's SharedJSContext via the remote-debugging port."""
32
+ return cls(cdp.Session.connect(port))
33
+
34
+ def __enter__(self) -> SteamEngine:
35
+ return self
36
+
37
+ def __exit__(self, *exc):
38
+ self.close()
39
+
40
+ def close(self):
41
+ self._session.close()
42
+
43
+ # ---- low level ------------------------------------------------------
44
+
45
+ def eval(self, expression: str) -> Any:
46
+ """Evaluate raw JS in SharedJSContext. Returns result.value."""
47
+ return self._session.eval(expression)
48
+
49
+ def eval_json(self, expression: str) -> Any:
50
+ return self._session.eval_json(expression)
51
+
52
+ def get(self, path: str) -> Any:
53
+ return self._session.eval_json(f"JSON.stringify({path})")
54
+
55
+ def _resolve_name(self, parent: str, name: str) -> str | None:
56
+ """Find the real member name on `parent`, ignoring case/underscores —
57
+ so `get_os_type` finds `GetOSType`."""
58
+ key = name.replace("_", "").lower()
59
+ cache_key = (parent, key)
60
+ if cache_key not in self._name_cache:
61
+ self._name_cache[cache_key] = self._session.eval_json(
62
+ f"JSON.stringify(Object.getOwnPropertyNames({parent})"
63
+ f".find(n=>n.toLowerCase()==={json.dumps(key)}) ?? null)"
64
+ )
65
+ return self._name_cache[cache_key]
66
+
67
+ def call(self, path: str, *args: Any, convert: bool = False) -> Any:
68
+ """Call `path(...args)`; args are JSON-serialized. Awaits promises.
69
+ Returns the JSON-decoded result, or None for undefined/unserializable."""
70
+ if convert:
71
+ parent, _, leaf = path.rpartition(".")
72
+ resolved = self._resolve_name(parent, leaf)
73
+ if resolved:
74
+ path = f"{parent}.{resolved}"
75
+ arglist = ",".join(json.dumps(a) for a in args)
76
+ expr = (
77
+ "(async()=>{"
78
+ f"const r = await {path}({arglist});"
79
+ "try { return JSON.stringify({v: r === undefined ? null : r}); }"
80
+ "catch { return JSON.stringify({v: null, unserializable: typeof r}); }"
81
+ "})()"
82
+ )
83
+ out = self._session.eval(expr)
84
+ return json.loads(out).get("v") if isinstance(out, str) else out
85
+
86
+ # ---- enable / status ------------------------------------------------
87
+
88
+ @staticmethod
89
+ def status(port: int = 1337) -> dict:
90
+ """Probe the debug endpoint without attaching a session."""
91
+ try:
92
+ targets = cdp.list_targets(port)
93
+ except Exception as e:
94
+ return {"reachable": False, "error": str(e), "port": port}
95
+ return {
96
+ "reachable": True,
97
+ "port": port,
98
+ "targets": len(targets),
99
+ "shared_js_context": any(t.title == "SharedJSContext" for t in targets),
100
+ }
101
+
102
+ @staticmethod
103
+ def enable(port: int = 1337, restart: bool = False) -> str:
104
+ """Patch the webhelper wrapper so it opens the debug port (Linux)."""
105
+ return _enable(port, restart)
steam_engine/enable.py ADDED
@@ -0,0 +1,64 @@
1
+ """Enable the CEF remote-debugging port on the Steam client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ WRAP = "ubuntu12_64/steamwebhelper_sniper_wrap.sh"
10
+ EXEC_LINE = 'exec ./steamwebhelper "$@"'
11
+
12
+
13
+ def _steam_dir() -> Path | None:
14
+ home = Path.home()
15
+ candidates = [
16
+ home / ".local/share/Steam",
17
+ home / ".steam/steam",
18
+ home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", # flatpak
19
+ ]
20
+ for c in candidates:
21
+ if (c / WRAP).exists():
22
+ return c
23
+ return None
24
+
25
+
26
+ def enable(port: int = 1337, restart: bool = False) -> str:
27
+ """Patch steamwebhelper_sniper_wrap.sh to pass --remote-debugging-port.
28
+ Returns a human-readable status message."""
29
+ if sys.platform != "linux":
30
+ raise RuntimeError(
31
+ "on this platform, launch Steam with -cef-enable-debugging "
32
+ "(Windows/macOS) or inject --remote-debugging-port into the "
33
+ "steamwebhelper command line"
34
+ )
35
+ steam = _steam_dir()
36
+ if steam is None:
37
+ raise RuntimeError("could not locate " + WRAP)
38
+ wrap = steam / WRAP
39
+ body = wrap.read_text()
40
+ if "--remote-debugging-port" in body:
41
+ msg = f"{wrap} already patched"
42
+ else:
43
+ if EXEC_LINE not in body:
44
+ raise RuntimeError(
45
+ f"unexpected wrap script contents - edit {wrap} manually"
46
+ )
47
+ patched = (
48
+ "exec ./steamwebhelper --remote-debugging-address=127.0.0.1 "
49
+ f"--remote-debugging-port={port} \"$@\""
50
+ )
51
+ wrap.with_suffix(".sh.bak").write_text(body)
52
+ wrap.write_text(body.replace(EXEC_LINE, patched))
53
+ msg = f"patched {wrap}"
54
+ if restart:
55
+ # the browser process is the only one carrying -uimode in argv
56
+ r = subprocess.run(["pkill", "-f", "steamwebhelper.*-uimode"])
57
+ msg += (
58
+ "; steamwebhelper killed - Steam will respawn it with the debug port"
59
+ if r.returncode == 0
60
+ else "; no running steamwebhelper found"
61
+ )
62
+ else:
63
+ msg += "; restart Steam (or pass restart=True) to activate"
64
+ return msg
@@ -0,0 +1,64 @@
1
+ """Lazy JS path proxy — `se.client.Apps.RunGame(123)` evaluates
2
+ `SteamClient.Apps.RunGame(123)` in the page; `se.window.appStore.allApps.get()`
3
+ reads the property. Nothing is bound until called, so the entire
4
+ SteamClient.* / window.* surface is reachable without hand-written bindings."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Protocol
10
+
11
+
12
+ class Engine(Protocol):
13
+ """What a proxy needs from its host — any object providing these works,
14
+ including test doubles."""
15
+
16
+ def call(self, path: str, *args: Any, convert: bool = False) -> Any: ...
17
+ def get(self, path: str) -> Any: ...
18
+ def eval_json(self, expression: str) -> Any: ...
19
+
20
+
21
+ def _to_js_name(name: str, convert: bool) -> str:
22
+ """snake_case/lowerCamel -> PascalCase, for SteamClient.* ergonomics."""
23
+ if not convert:
24
+ return name
25
+ if "_" in name:
26
+ return "".join(p.title() for p in name.split("_") if p)
27
+ if name[:1].islower():
28
+ return name[0].upper() + name[1:]
29
+ return name
30
+
31
+
32
+ class JsProxy:
33
+ __slots__ = ("_se", "_path", "_convert")
34
+
35
+ def __init__(self, se: Engine, path: str, convert: bool = False):
36
+ self._se = se
37
+ self._path = path
38
+ self._convert = convert
39
+
40
+ def __getattr__(self, name: str) -> JsProxy:
41
+ if name.startswith("_"):
42
+ raise AttributeError(name)
43
+ return JsProxy(
44
+ self._se, f"{self._path}.{_to_js_name(name, self._convert)}", self._convert
45
+ )
46
+
47
+ def __getitem__(self, key) -> JsProxy:
48
+ return JsProxy(self._se, f"{self._path}[{json.dumps(key)}]", self._convert)
49
+
50
+ def __call__(self, *args: Any) -> Any:
51
+ return self._se.call(self._path, *args, convert=self._convert)
52
+
53
+ def get(self) -> Any:
54
+ """Read the property value (must be JSON-serializable)."""
55
+ return self._se.get(self._path)
56
+
57
+ def keys(self) -> list[str]:
58
+ """Own property names — handy when .get() hits unserializable objects."""
59
+ return self._se.eval_json(
60
+ f"JSON.stringify(Object.getOwnPropertyNames({self._path}))"
61
+ )
62
+
63
+ def __repr__(self) -> str:
64
+ return f"<JsProxy {self._path}>"
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: steam-engine
3
+ Version: 0.1.0
4
+ Summary: Drive a running Steam client's internals over Chrome DevTools Protocol (collections, apps, downloads, all of SteamClient.*)
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: websockets>=12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # steam-engine
11
+
12
+ Call a running Steam client’s internal JavaScript API from Python.
13
+
14
+ `steam-engine` connects to Steam’s CEF renderer over Chrome DevTools Protocol. It exposes `SteamClient.*` methods and `window.*` stores through a Python proxy, so operations run through Steam itself.
15
+
16
+ For a higher-level collections API, see the sibling project `steam-collections`.
17
+
18
+ ## Get started
19
+
20
+ Install from the repository, then enable debugging in Steam:
21
+
22
+ ```sh
23
+ pip install -e .
24
+ steam-engine enable --restart
25
+ steam-engine status
26
+ ```
27
+
28
+ `enable` is Linux-only. It patches `ubuntu12_64/steamwebhelper_sniper_wrap.sh` and backs up the original to `.sh.bak`. Steam updates can replace this wrapper; run `enable` again if the connection stops working.
29
+
30
+ On Windows or macOS, launch Steam with `-cef-enable-debugging` or add `--remote-debugging-port` to the webhelper command line.
31
+
32
+ The default port is `1337`. Set `STEAM_CDP_PORT` to use another port.
33
+
34
+ ## Use from Python
35
+
36
+ Steam must be running with debugging enabled.
37
+
38
+ ```python
39
+ from steam_engine import SteamEngine
40
+
41
+ with SteamEngine.connect() as se:
42
+ # Call Steam's native bridge.
43
+ se.client.Apps.SetAppLaunchOptions(292030, "-fullscreen")
44
+
45
+ # Snake_case works too.
46
+ se.client.Apps.specify_compat_tool(105600, "proton_experimental")
47
+
48
+ # Read a JavaScript property.
49
+ apps = se.window.appStore.allApps.get()
50
+
51
+ # Run JavaScript directly.
52
+ count = se.eval("collectionStore.userCollections.length")
53
+ ```
54
+
55
+ The proxy supports:
56
+
57
+ | Syntax | Behavior |
58
+ | --------------------- | ------------------------------------------------------------ |
59
+ | `proxy.method(*args)` | Call a method with JSON-serialized arguments; await promises |
60
+ | `proxy.prop.get()` | Read a JSON-serializable property |
61
+ | `proxy.prop.keys()` | List own property names |
62
+ | `proxy.prop[index]` | Access an indexed value |
63
+
64
+ Method names ignore case and underscores. Results are JSON-decoded; `undefined` and unserializable results become `None`.
65
+
66
+ ## Use from the terminal
67
+
68
+ ```sh
69
+ steam-engine status # Check the connection
70
+ steam-engine targets # List debuggable targets
71
+ steam-engine enable --restart # Enable debugging and restart Steam (Linux)
72
+ steam-engine eval 'collectionStore.userCollections.length'
73
+ ```
74
+
75
+ ## API reference
76
+
77
+ The generated reference in `docs/` lists the discovered methods and stores. To refresh and browse it:
78
+
79
+ ```sh
80
+ python scripts/dump_api_surface.py
81
+ mkdocs serve
82
+ ```
83
+
84
+ Raw data is available at `docs/data/api_surface.json`.
85
+
86
+ Steam’s internal API is undocumented. Method names and arguments can change between client builds.
87
+
@@ -0,0 +1,10 @@
1
+ steam_engine/__init__.py,sha256=fw8dMIs-pakiaY7A4l0LtQLvN81ESOsLyq8aH0TR9js,196
2
+ steam_engine/cdp.py,sha256=qdROL3SwuEOpuzY_1A-EJFPA6ZVEDE5VkSnapINUz9o,3298
3
+ steam_engine/cli.py,sha256=q1FAhWHltWg1iIvLFpVP7iIzYxy0oIHPQfMSZpW6--k,1871
4
+ steam_engine/client.py,sha256=N7kksLvOeZC-rDdQoKvR_ru0nUh_IqOF0rol5nZ2HT4,3873
5
+ steam_engine/enable.py,sha256=BLIcE4gNanVs5zhmn1dN23cMl_0ZXafxoc3j6PoPVkc,2165
6
+ steam_engine/jsproxy.py,sha256=D7iE1qiIAbYpHHonnACCAVMZ2b46D3vomctrjQHWEW4,2160
7
+ steam_engine-0.1.0.dist-info/METADATA,sha256=MG_oSMQlBVxZ2Ijy8ULfEsHTpkTsuc8gAcIfG2wX_xE,3060
8
+ steam_engine-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ steam_engine-0.1.0.dist-info/entry_points.txt,sha256=RdL_xC83JMOIk2pePftOlnQswaKGyfiQNh4CcWDFuJU,55
10
+ steam_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ steam-engine = steam_engine.cli:main