Files
DroidDeck/tools/linuxfs/overlay/usr/local/bin/droiddeck-gog
T

196 lines
7.5 KiB
Python
Executable File

#!/usr/bin/python3
"""The app's GOG store talks to Heroic's gogdl through this: one JSON object per line on stdout,
so progress reaches the front end as numbers rather than log lines. The app puts gogdl in the
runtime (GOGDL below) and keeps the sign-in in its own files (the auth path every command takes).
Tokens never leave on stdout except from "token", whose output the app does not log.
droiddeck-gog login <auth> <code> trades the sign-in page's code for tokens
droiddeck-gog token <auth> a current access token (refreshed when due)
droiddeck-gog info <auth> <id> folder name and sizes of the Windows build
droiddeck-gog install <auth> <id> <base> downloads into <base>/<folder name>
droiddeck-gog update <auth> <id> <dir> brings an installed game to the latest build
Lines: {"e":"info",...}, {"e":"progress","percent":45,"written":..,"total":..},
{"e":"error","message":"..."} and {"e":"done",...} at the end. Exit status 0 on success.
"""
import json
import os
import re
import subprocess
import sys
GOGDL = "/opt/droiddeck/gogdl/gogdl"
PLATFORM = "windows"
MARKER = ".droiddeck-gog.json"
PROGRESS = re.compile(r"= Progress: ([\d.]+) (\d+)/(\d+)")
# gogdl's own log lines: "[NAME] LEVEL: message".
LOG = re.compile(r"^\[([^\]]+)\] (\w+): (.*)$")
def emit(**kw):
sys.stdout.write(json.dumps(kw) + "\n")
sys.stdout.flush()
def gogdl(auth, *args):
return [sys.executable or "/usr/bin/python3", GOGDL, "--auth-config-path", auth] + list(args)
def last_json(text):
"""gogdl prints its answer as the last JSON line of stdout, after anything else it says."""
for line in reversed(text.splitlines()):
line = line.strip()
if line.startswith("{") or line == "null":
try:
return json.loads(line)
except ValueError:
continue
return None
def quiet(auth, *args):
"""Runs a gogdl command that answers in JSON; its log (stderr) is dropped, never echoed."""
run = subprocess.run(gogdl(auth, *args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return run.returncode, last_json(run.stdout), run.stderr
def login(auth, code):
os.makedirs(os.path.dirname(auth) or ".", exist_ok=True)
status, data, _ = quiet(auth, "auth", "--code", code)
if status != 0 or not isinstance(data, dict) or data.get("error") or not data.get("access_token"):
emit(e="error", message="GOG did not accept the sign-in")
return 1
emit(e="done", user_id=str(data.get("user_id", "")))
return 0
def token(auth):
if not os.path.isfile(auth):
emit(e="error", message="signed out")
return 1
status, data, _ = quiet(auth, "auth")
if status != 0 or not isinstance(data, dict) or not data.get("access_token"):
emit(e="error", message="signed out")
return 1
expires = float(data.get("loginTime", 0)) + float(data.get("expires_in", 0))
emit(e="token", access_token=data["access_token"], user_id=str(data.get("user_id", "")), expires=int(expires))
return 0
def sizes(size, language):
"""Bytes to download and on disk: the depots every language needs ("*") plus one language's."""
pick = [k for k in ("*", language) if k in size]
if language not in size:
pick += [k for k in size if k != "*"][:1]
return (sum(int(size[k].get("download_size") or 0) for k in pick),
sum(int(size[k].get("disk_size") or 0) for k in pick))
def language_of(data):
langs = data.get("languages") or []
for want in ("en-US", "en"):
if want in langs:
return want
return langs[0] if langs else "en-US"
def info(auth, game_id, out=True):
status, data, err = quiet(auth, "info", game_id, "--platform", PLATFORM, "--skip-dlcs")
if status != 0 or not isinstance(data, dict) or not data.get("folder_name"):
reason = failure(err) or "GOG has no Windows build of this game to download"
if out:
emit(e="error", message=reason)
return None, reason
language = language_of(data)
download, disk = sizes(data.get("size") or {}, language)
result = dict(folder=data["folder_name"], language=language, download_size=download, disk_size=disk,
build=str(data.get("buildId", "")))
if out:
emit(e="info", **result)
return result, None
def failure(text):
"""The last error gogdl logged, for the user; None when it logged none."""
errors = [m.group(3) for m in (LOG.match(line) for line in (text or "").splitlines())
if m and m.group(2) in ("ERROR", "CRITICAL")]
return errors[-1] if errors else None
def follow(argv):
"""Runs a download, turning its progress lines into ours. Returns (status, last error)."""
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
error, last = None, -1
for line in proc.stdout:
line = line.rstrip("\n")
m = PROGRESS.search(line)
if m:
percent = int(float(m.group(1)))
if percent != last:
last = percent
emit(e="progress", percent=percent, written=int(m.group(2)), total=int(m.group(3)))
continue
log = LOG.match(line)
if log and log.group(2) in ("ERROR", "CRITICAL"):
error = log.group(3)
# Everything else goes to the app's log of this command as it is.
sys.stdout.write(line + "\n")
sys.stdout.flush()
return proc.wait(), error
def install(auth, game_id, base):
data, _ = info(auth, game_id)
if data is None:
return 1
target = os.path.join(base, data["folder"])
os.makedirs(target, exist_ok=True)
# Which game a folder is, from the first byte: the app lists a stopped download by it.
with open(os.path.join(target, MARKER), "w", encoding="utf-8") as f:
json.dump(dict(id=game_id, build=data["build"], language=data["language"]), f)
status, error = follow(gogdl(auth, "download", game_id, "--platform", PLATFORM, "--path", base,
"--skip-dlcs", "--lang", data["language"]))
if status != 0 or not os.path.isfile(os.path.join(target, "goggame-%s.info" % game_id)):
emit(e="error", message=error or "the download stopped (gogdl exit %d)" % status)
return 1
emit(e="done", dir=target)
return 0
def update(auth, game_id, target):
data, _ = info(auth, game_id, out=False)
language = data["language"] if data else "en-US"
status, error = follow(gogdl(auth, "update", game_id, "--platform", PLATFORM, "--path", target,
"--skip-dlcs", "--lang", language))
if status != 0:
emit(e="error", message=error or "the update stopped (gogdl exit %d)" % status)
return 1
emit(e="done", dir=target)
return 0
def main(argv):
if len(argv) < 3:
print(__doc__)
return 2
verb, auth, rest = argv[1], argv[2], argv[3:]
if not os.path.isfile(GOGDL):
emit(e="error", message="gogdl is not set up")
return 1
if verb == "login" and len(rest) == 1:
return login(auth, rest[0])
if verb == "token" and not rest:
return token(auth)
if verb == "info" and len(rest) == 1:
return 0 if info(auth, rest[0])[0] else 1
if verb == "install" and len(rest) == 2:
return install(auth, rest[0], rest[1])
if verb == "update" and len(rest) == 2:
return update(auth, rest[0], rest[1])
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))