mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
576 lines
23 KiB
Python
Executable File
576 lines
23 KiB
Python
Executable File
#!/usr/bin/python3
|
|
import base64
|
|
import ctypes
|
|
import json
|
|
import os
|
|
import runpy
|
|
import select
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.realpath(__file__))
|
|
COMPAT = runpy.run_path(os.path.join(HERE, "steam-compatibility"))
|
|
BINDING = "droiddeckCompatChanged"
|
|
INSTALL_JS = r"""
|
|
(() => {
|
|
if (window.__droiddeckCompat) return "present";
|
|
const enc = new TextEncoder(), dec = new TextDecoder();
|
|
const varint = (value, out) => { let n = BigInt(value); do { let b = Number(n & 0x7fn); n >>= 7n; if (n) b |= 0x80; out.push(b); } while (n); };
|
|
const key = (field, wire, out) => varint(field * 8 + wire, out);
|
|
const str = (field, s, out) => { const b = enc.encode(s); key(field, 2, out); varint(b.length, out); for (const x of b) out.push(x); };
|
|
const uint = (field, v, out) => { key(field, 0, out); varint(v, out); };
|
|
const fixed64 = (field, v, out) => { key(field, 1, out); let n = BigInt(v); for (let i = 0; i < 8; i++) { out.push(Number(n & 0xffn)); n >>= 8n; } };
|
|
const parse = (bytes) => {
|
|
const fields = {};
|
|
let i = 0;
|
|
const read = () => { let r = 0n, s = 0n, b; do { b = bytes[i++]; r |= BigInt(b & 0x7f) << s; s += 7n; } while (b & 0x80); return r; };
|
|
while (i < bytes.length) {
|
|
const k = Number(read()), field = k >> 3, wire = k & 7;
|
|
let v;
|
|
if (wire === 0) v = read();
|
|
else if (wire === 1) { v = 0n; for (let j = 0; j < 8; j++) v |= BigInt(bytes[i + j]) << BigInt(8 * j); i += 8; }
|
|
else if (wire === 2) { const n = Number(read()); v = bytes.subarray(i, i + n); i += n; }
|
|
else if (wire === 5) { v = bytes.subarray(i, i + 4); i += 4; }
|
|
else throw new Error("unexpected wire type " + wire);
|
|
(fields[field] = fields[field] || []).push(v);
|
|
}
|
|
return fields;
|
|
};
|
|
const text = (f, n) => f[n] ? dec.decode(f[n][0]) : "";
|
|
const self = { seq: 0, pending: new Map(), ws: null, opening: null, changes: 0 };
|
|
const frame = (method, body, auth) => {
|
|
const h = [];
|
|
fixed64(10, ++self.seq, h);
|
|
str(12, method, h);
|
|
if (auth) str(40, auth, h);
|
|
const out = new Uint8Array(8 + h.length + body.length);
|
|
const view = new DataView(out.buffer);
|
|
view.setUint32(0, (0x80000000 | 146) >>> 0, true);
|
|
view.setUint32(4, h.length, true);
|
|
out.set(h, 8);
|
|
out.set(body, 8 + h.length);
|
|
return [self.seq, out];
|
|
};
|
|
const onMessage = (event) => {
|
|
const bytes = new Uint8Array(event.data);
|
|
const length = new DataView(event.data).getUint32(4, true);
|
|
const header = parse(bytes.subarray(8, 8 + length));
|
|
const target = header[11] ? Number(header[11][0] & 0xffffffffn) : -1;
|
|
if (self.pending.has(target)) {
|
|
const done = self.pending.get(target);
|
|
self.pending.delete(target);
|
|
const result = header[13] ? Number(header[13][0]) : 2;
|
|
done(result === 1 ? { ok: true, body: parse(bytes.subarray(8 + length)) } : { ok: false, eresult: result, error: text(header, 14) });
|
|
return;
|
|
}
|
|
if (text(header, 12).startsWith("CompatManager.NotifyStateChanged")) {
|
|
self.changes++;
|
|
if (typeof window.__BINDING__ === "function") window.__BINDING__(String(self.changes));
|
|
}
|
|
};
|
|
const send = (method, body, auth) => new Promise((resolve, reject) => {
|
|
const [id, out] = frame(method, Uint8Array.from(body), auth);
|
|
const timer = setTimeout(() => { self.pending.delete(id); reject(new Error(method + " timed out")); }, 10000);
|
|
self.pending.set(id, (r) => { clearTimeout(timer); resolve(r); });
|
|
self.ws.send(out);
|
|
});
|
|
const open = async () => {
|
|
const info = await SteamClient.WebUITransport.GetTransportInfo();
|
|
const ws = new WebSocket("ws://127.0.0.1:" + info.portClientdll + "/transportsocket/");
|
|
ws.binaryType = "arraybuffer";
|
|
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error("the client's transport refused the connection")); });
|
|
ws.onmessage = onMessage;
|
|
ws.onclose = () => {
|
|
if (self.ws === ws) self.ws = null;
|
|
for (const done of self.pending.values()) done({ ok: false, eresult: 0, error: "connection closed" });
|
|
self.pending.clear();
|
|
};
|
|
self.ws = ws;
|
|
const body = [];
|
|
str(1, info.authKeyClientdll, body);
|
|
const r = await send("TransportAuth.Authenticate#1", body, info.authKeyClientdll);
|
|
if (!r.ok) { ws.close(); throw new Error("the client's transport refused authentication (" + r.eresult + ")"); }
|
|
};
|
|
const ready = async () => {
|
|
if (self.ws && self.ws.readyState === 1) return;
|
|
if (!self.opening) self.opening = open().finally(() => { self.opening = null; });
|
|
await self.opening;
|
|
};
|
|
self.get = async (appid) => {
|
|
await ready();
|
|
const body = [];
|
|
uint(1, appid, body);
|
|
const r = await send("CompatManager.GetCompatTools#1", body);
|
|
if (!r.ok) throw new Error("GetCompatTools " + appid + " failed (" + r.eresult + " " + r.error + ")");
|
|
return { selected: text(r.body, 3), fallback: text(r.body, 4), tools: (r.body[1] || []).map((t) => text(parse(t), 1)) };
|
|
};
|
|
self.set = async (appid, name) => {
|
|
await ready();
|
|
const body = [];
|
|
uint(1, appid, body);
|
|
str(2, name, body);
|
|
const r = await send("CompatManager.SpecifyCompatTool#1", body);
|
|
if (!r.ok) throw new Error("SpecifyCompatTool " + appid + " " + name + " failed (" + r.eresult + " " + r.error + ")");
|
|
return true;
|
|
};
|
|
self.many = async (apps) => {
|
|
const out = {};
|
|
for (const app of apps) out[app] = (await self.get(Number(app))).selected;
|
|
return out;
|
|
};
|
|
window.__droiddeckCompat = self;
|
|
return "installed";
|
|
})()
|
|
""".replace("__BINDING__", BINDING)
|
|
SWITCH_BINDING = "droiddeckSwitchToDesktop"
|
|
SWITCH_JS = r"""
|
|
(() => {
|
|
if (window.__droiddeckSwitch) return "present";
|
|
const name = new TextEncoder().encode("SteamOSManager.SwitchToDesktop#");
|
|
const send = WebSocket.prototype.send;
|
|
const asks = (data) => {
|
|
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data)
|
|
: ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : null;
|
|
if (!bytes) return false;
|
|
const end = Math.min(bytes.length, 256) - name.length;
|
|
for (let i = 8; i <= end; i++) {
|
|
let j = 0;
|
|
while (j < name.length && bytes[i + j] === name[j]) j++;
|
|
if (j === name.length) return true;
|
|
}
|
|
return false;
|
|
};
|
|
WebSocket.prototype.send = function (data) {
|
|
if (typeof window.__BINDING__ === "function" && asks(data)) window.__BINDING__("");
|
|
return send.call(this, data);
|
|
};
|
|
window.__droiddeckSwitch = true;
|
|
return "installed";
|
|
})()
|
|
""".replace("__BINDING__", SWITCH_BINDING)
|
|
SESSION_SELECT = "/usr/bin/steamos-session-select"
|
|
|
|
|
|
class Closed(Exception):
|
|
pass
|
|
|
|
|
|
def say(message):
|
|
print("droiddeck-steam-compat: %s" % message, flush=True)
|
|
|
|
|
|
def called(event, name):
|
|
return event.get("method") == "Runtime.bindingCalled" and event.get("params", {}).get("name") == name
|
|
|
|
|
|
def switch_to_desktop():
|
|
say("Switch to Desktop: asking for the desktop")
|
|
try:
|
|
subprocess.call([SESSION_SELECT, "plasma"])
|
|
except OSError as e:
|
|
say("Switch to Desktop failed: %s" % e)
|
|
|
|
|
|
def connect(port, timeout=5):
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
try:
|
|
sock.bind(("127.0.0.1", 0))
|
|
sock.connect(("127.0.0.1", port))
|
|
except OSError:
|
|
sock.close()
|
|
raise
|
|
return sock
|
|
|
|
|
|
def http_get(port, path):
|
|
sock = connect(port)
|
|
try:
|
|
sock.sendall(("GET %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nConnection: close\r\n\r\n" % (path, port)).encode())
|
|
data = b""
|
|
length = None
|
|
while length is None or len(data) < length:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
if length is None and b"\r\n\r\n" in data:
|
|
head, _, rest = data.partition(b"\r\n\r\n")
|
|
for line in head.split(b"\r\n")[1:]:
|
|
name, _, value = line.partition(b":")
|
|
if name.strip().lower() == b"content-length":
|
|
length = int(value.strip())
|
|
if length is None:
|
|
length = float("inf")
|
|
data = rest
|
|
if not head.startswith(b"HTTP/1.1 200"):
|
|
raise OSError("devtools answered %r" % head[:40])
|
|
finally:
|
|
sock.close()
|
|
return json.loads(data.decode("utf-8", "replace"))
|
|
|
|
|
|
class WebSocket:
|
|
def __init__(self, port, path):
|
|
self.sock = connect(port)
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
self.sock.sendall(("GET %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, port, key)).encode())
|
|
self.buffer = b""
|
|
while b"\r\n\r\n" not in self.buffer:
|
|
self.fill()
|
|
head, _, self.buffer = self.buffer.partition(b"\r\n\r\n")
|
|
if not head.startswith(b"HTTP/1.1 101"):
|
|
raise OSError("devtools refused the websocket: %r" % head[:60])
|
|
self.sock.settimeout(None)
|
|
|
|
def fileno(self):
|
|
return self.sock.fileno()
|
|
|
|
def fill(self):
|
|
chunk = self.sock.recv(65536)
|
|
if not chunk:
|
|
raise Closed("devtools closed the connection")
|
|
self.buffer += chunk
|
|
|
|
def take(self, n):
|
|
while len(self.buffer) < n:
|
|
self.fill()
|
|
data, self.buffer = self.buffer[:n], self.buffer[n:]
|
|
return data
|
|
|
|
def send(self, text, opcode=1):
|
|
payload = text.encode() if isinstance(text, str) else text
|
|
mask = os.urandom(4)
|
|
n = len(payload)
|
|
if n < 126:
|
|
head = struct.pack("!BB", 0x80 | opcode, 0x80 | n)
|
|
elif n < 65536:
|
|
head = struct.pack("!BBH", 0x80 | opcode, 0xFE, n)
|
|
else:
|
|
head = struct.pack("!BBQ", 0x80 | opcode, 0xFF, n)
|
|
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
|
|
|
|
def pending(self):
|
|
return len(self.buffer) > 0
|
|
|
|
def recv(self):
|
|
message = b""
|
|
while True:
|
|
first, second = self.take(2)
|
|
n = second & 0x7F
|
|
if n == 126:
|
|
n = struct.unpack("!H", self.take(2))[0]
|
|
elif n == 127:
|
|
n = struct.unpack("!Q", self.take(8))[0]
|
|
mask = self.take(4) if second & 0x80 else None
|
|
payload = self.take(n)
|
|
if mask:
|
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
opcode = first & 0x0F
|
|
if opcode == 8:
|
|
raise Closed("devtools closed the websocket")
|
|
if opcode == 9:
|
|
self.send(payload, 10)
|
|
continue
|
|
if opcode == 10:
|
|
continue
|
|
message += payload
|
|
if first & 0x80:
|
|
return message.decode("utf-8", "replace")
|
|
|
|
def close(self):
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class Page:
|
|
def __init__(self, port, path):
|
|
self.ws = WebSocket(port, path)
|
|
self.next_id = 0
|
|
self.events = []
|
|
|
|
def call(self, method, params=None, timeout=30):
|
|
self.next_id += 1
|
|
mine = self.next_id
|
|
self.ws.send(json.dumps({"id": mine, "method": method, "params": params or {}}))
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
if not self.ws.pending():
|
|
left = deadline - time.monotonic()
|
|
if left <= 0 or not select.select([self.ws], [], [], left)[0]:
|
|
raise TimeoutError("%s timed out" % method)
|
|
message = json.loads(self.ws.recv())
|
|
if message.get("id") == mine:
|
|
if "error" in message:
|
|
raise RuntimeError("%s: %s" % (method, message["error"].get("message")))
|
|
return message.get("result", {})
|
|
if "method" in message:
|
|
self.events.append(message)
|
|
|
|
def evaluate(self, expression, timeout=30):
|
|
result = self.call("Runtime.evaluate", {"expression": expression, "awaitPromise": True, "returnByValue": True}, timeout)
|
|
if "exceptionDetails" in result:
|
|
details = result["exceptionDetails"]
|
|
raise RuntimeError((details.get("exception") or {}).get("description") or details.get("text") or "script failed")
|
|
return result.get("result", {}).get("value")
|
|
|
|
def compat(self, call):
|
|
return self.evaluate("%s;\nwindow.__droiddeckCompat.%s" % (INSTALL_JS, call))
|
|
|
|
def drain(self):
|
|
while True:
|
|
if not self.ws.pending() and not select.select([self.ws], [], [], 0)[0]:
|
|
break
|
|
message = json.loads(self.ws.recv())
|
|
if "method" in message:
|
|
self.events.append(message)
|
|
events, self.events = self.events, []
|
|
return events
|
|
|
|
|
|
def shared_context(port):
|
|
for target in http_get(port, "/json/list"):
|
|
if target.get("title") == "SharedJSContext" and target.get("webSocketDebuggerUrl"):
|
|
path = target["webSocketDebuggerUrl"].split("/", 3)[3]
|
|
return "/" + path
|
|
return None
|
|
|
|
|
|
class Watcher:
|
|
MASK = 0x00000008 | 0x00000080 | 0x00000100 | 0x00000200
|
|
|
|
def __init__(self, files):
|
|
self.files = files
|
|
self.fd = -1
|
|
try:
|
|
libc = ctypes.CDLL(None, use_errno=True)
|
|
fd = libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC)
|
|
dirs = sorted({os.path.dirname(f) for f in files if os.path.isdir(os.path.dirname(f))})
|
|
if fd >= 0 and dirs and all(libc.inotify_add_watch(fd, d.encode(), self.MASK) >= 0 for d in dirs):
|
|
self.fd = fd
|
|
elif fd >= 0:
|
|
os.close(fd)
|
|
except (OSError, AttributeError):
|
|
self.fd = -1
|
|
self.stamp = self.snapshot()
|
|
|
|
def snapshot(self):
|
|
stamp = []
|
|
for f in self.files:
|
|
for p in (f, os.path.dirname(f)):
|
|
try:
|
|
stamp.append(os.stat(p).st_mtime_ns)
|
|
except OSError:
|
|
stamp.append(0)
|
|
return stamp
|
|
|
|
def fileno(self):
|
|
return self.fd
|
|
|
|
def changed(self):
|
|
if self.fd < 0:
|
|
stamp = self.snapshot()
|
|
moved, self.stamp = stamp != self.stamp, stamp
|
|
return moved
|
|
names = set()
|
|
try:
|
|
while True:
|
|
data = os.read(self.fd, 65536)
|
|
if not data:
|
|
break
|
|
at = 0
|
|
while at + 16 <= len(data):
|
|
_, _, _, size = struct.unpack_from("iIII", data, at)
|
|
names.add(data[at + 16:at + 16 + size].split(b"\0", 1)[0].decode("utf-8", "replace"))
|
|
at += 16 + size
|
|
except BlockingIOError:
|
|
pass
|
|
wanted = {os.path.basename(f) for f in self.files}
|
|
return any(n in wanted or n.startswith("appmanifest_") for n in names)
|
|
|
|
|
|
class Helper:
|
|
def __init__(self, steam_root, port, steamos=False):
|
|
self.steam_root = steam_root
|
|
self.port = port
|
|
self.steamos = steamos
|
|
self.page = None
|
|
self.known = set()
|
|
self.installed = None
|
|
self.fex_asked = set()
|
|
|
|
def attach(self):
|
|
path = shared_context(self.port)
|
|
if path is None:
|
|
raise LookupError("no SharedJSContext yet")
|
|
page = Page(self.port, path)
|
|
try:
|
|
if page.evaluate("typeof SteamClient === 'object' && typeof SteamClient.WebUITransport === 'object'") is not True:
|
|
raise LookupError("SteamClient is not ready")
|
|
page.call("Runtime.addBinding", {"name": BINDING})
|
|
if not self.steamos:
|
|
page.call("Runtime.addBinding", {"name": SWITCH_BINDING})
|
|
page.evaluate(SWITCH_JS)
|
|
page.compat("get(0)")
|
|
except Exception:
|
|
page.ws.close()
|
|
raise
|
|
self.page = page
|
|
return True
|
|
|
|
def reconcile(self):
|
|
page = self.page
|
|
with COMPAT["locked"]():
|
|
state_file = COMPAT["state_path"]("state.json")
|
|
state = COMPAT["read_json"](state_file)
|
|
request = COMPAT["read_json"](COMPAT["state_path"]("request.json"))
|
|
catalog = COMPAT["tool_catalog"](self.steam_root)
|
|
top = page.compat("get(0)")
|
|
self.known = set(top["tools"])
|
|
live = [t for t in catalog if t["name"] in self.known]
|
|
wanted = COMPAT["requested_tool"](request, catalog)
|
|
newer = isinstance(request.get("seq"), int) and request["seq"] > int(state.get("applied", 0) or 0)
|
|
waiting = newer and wanted is not None and wanted not in self.known
|
|
default, state = COMPAT["choose_default"](top["selected"], state, request, live, apply_request=not waiting)
|
|
installed = COMPAT["installed_apps"](self.steam_root)
|
|
self.installed = installed
|
|
picks = COMPAT["valve_picks"](self.steam_root)
|
|
prior_auto = state.get("auto", {})
|
|
if not isinstance(prior_auto, dict):
|
|
prior_auto = {}
|
|
prior_native = state.get("native", ())
|
|
native_choices = set(prior_native if isinstance(prior_native, (list, tuple, set)) else ())
|
|
apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"])
|
|
overrides = COMPAT["requested_games"](
|
|
COMPAT["game_requests"](), catalog, [t["name"] for t in live])
|
|
apps = sorted(set(apps) | set(overrides), key=COMPAT["app_order"])
|
|
current = dict(page.compat("many(%s)" % json.dumps(apps)) or {})
|
|
current["0"] = top["selected"]
|
|
native = COMPAT["native_targets"](self.steam_root, apps)
|
|
raw_mapping = COMPAT["read_config_mapping"](
|
|
os.path.join(self.steam_root, "config", "config.vdf"))
|
|
if raw_mapping is None:
|
|
for app in native_choices:
|
|
current[app] = ""
|
|
else:
|
|
for app in set(native) | native_choices:
|
|
if app in raw_mapping:
|
|
current[app] = raw_mapping[app]
|
|
elif app in prior_auto or app in native_choices:
|
|
current[app] = ""
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
current, installed, [t["name"] for t in live], default,
|
|
COMPAT["followers"](state, current), native, picks, overrides,
|
|
prior_auto.keys(), native_choices)
|
|
for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])):
|
|
page.compat("set(%d, %s)" % (int(app), json.dumps(tool if tool is not None else "")))
|
|
say("%s -> %s" % ("default" if app == "0" else "app %s" % app,
|
|
tool if tool is not None else "Steam native default"))
|
|
state.update(
|
|
auto={app: tool for app, tool in auto.items() if tool is not COMPAT["NATIVE_CHOICE"]},
|
|
native=sorted(app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]),
|
|
tools=catalog, live=True)
|
|
if waiting:
|
|
state["pending"] = wanted
|
|
say("%s is new to the running client; it applies when Steam next starts" % wanted)
|
|
else:
|
|
state.pop("pending", None)
|
|
COMPAT["write_json"](state_file, state)
|
|
if (any(app in installed and tool == COMPAT["FEX_TOOL"] for app, tool in native.items()) or
|
|
os.path.exists(COMPAT["fex_wanted_marker"]())):
|
|
for app in COMPAT["request_fex"](self.steam_root, self.fex_asked):
|
|
say("asked the client to install %s for x86 Linux titles and programs" % app)
|
|
|
|
def request_waiting(self):
|
|
request = COMPAT["read_json"](COMPAT["state_path"]("request.json"))
|
|
state = COMPAT["read_json"](COMPAT["state_path"]("state.json"))
|
|
return isinstance(request.get("seq"), int) and request["seq"] > int(state.get("applied", 0) or 0)
|
|
|
|
def drain(self):
|
|
"""The page's events; a switch to the desktop is acted on as it comes."""
|
|
events = self.page.drain()
|
|
if any(called(e, SWITCH_BINDING) for e in events):
|
|
switch_to_desktop()
|
|
return events
|
|
|
|
def serve(self, watcher):
|
|
with open(COMPAT["state_path"]("live.pid"), "w") as f:
|
|
f.write("%d\n" % os.getpid())
|
|
say("attached to the client on port %d" % self.port)
|
|
self.reconcile()
|
|
while True:
|
|
sources = [self.page.ws] + ([watcher] if watcher.fileno() >= 0 else [])
|
|
ready = [self.page.ws] if self.page.ws.pending() else select.select(sources, [], [], 30 if watcher.fileno() >= 0 else 2)[0]
|
|
due = False
|
|
if self.page.ws in ready:
|
|
due = any(called(e, BINDING) for e in self.drain())
|
|
if (watcher in ready or watcher.fileno() < 0) and watcher.changed():
|
|
due = True
|
|
if due:
|
|
time.sleep(0.3)
|
|
if watcher.fileno() >= 0 and select.select([watcher], [], [], 0)[0]:
|
|
watcher.changed()
|
|
self.drain()
|
|
self.reconcile()
|
|
|
|
|
|
def detach():
|
|
try:
|
|
os.unlink(COMPAT["state_path"]("live.pid"))
|
|
except OSError:
|
|
pass
|
|
with COMPAT["locked"]():
|
|
state_file = COMPAT["state_path"]("state.json")
|
|
state = COMPAT["read_json"](state_file)
|
|
if state.get("live"):
|
|
state["live"] = False
|
|
COMPAT["write_json"](state_file, state)
|
|
|
|
|
|
def main():
|
|
steam_root = sys.argv[1]
|
|
try:
|
|
port = int(os.environ.get("BL_CDP_PORT", ""))
|
|
except ValueError:
|
|
say("no devtools port for this session; the client's mapping is kept through config.vdf")
|
|
return 0
|
|
os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True)
|
|
watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json")] +
|
|
[os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")])
|
|
steamos = os.environ.get("BL_STEAMDECK") == "1" and os.environ.get("BL_STEAM_UI") != "desktop"
|
|
helper = Helper(steam_root, port, steamos)
|
|
failures = 0
|
|
last = None
|
|
while True:
|
|
try:
|
|
helper.attach()
|
|
failures = 0
|
|
last = None
|
|
helper.serve(watcher)
|
|
except Exception as e:
|
|
if helper.page is not None:
|
|
say("lost the client (%s); reattaching" % e)
|
|
helper.page.ws.close()
|
|
helper.page = None
|
|
detach()
|
|
elif str(e) != last:
|
|
say("waiting for the client (%s)" % e)
|
|
last = str(e)
|
|
failures += 1
|
|
time.sleep(min(2 * max(failures, 1), 10))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
try:
|
|
sys.exit(main())
|
|
finally:
|
|
detach()
|