#!/usr/bin/python3
r"""Before Proton starts a store game, asks the app for what that store needs at launch.

Run by droiddeck-proton / droiddeck-proton-wrap (steam-compatibility) for every real game launch,
whoever started it: the Games tab, a pinned shortcut, or the Steam client's own Play button. It
looks for the store launcher DroidDeck wrote (.droiddeck-launch.bat) among the arguments; beside it
.droiddeck-store.json says which store and game it is.

For an Epic game the game needs a fresh exchange code to sign in, which only the app can mint. The
request goes through files under $BL_LAUNCH_DIR/stores (the session root, the same path on both
sides):

    req/<id>.json    {"op": "epic-code", "store": "epic", "id": "<app name>"}   (tmp + rename)
    resp/<id>.json   {"ok": true, "code": true|false, "reason": "..."}

The app writes .droiddeck-epic-code into the game's folder itself - the code never passes through
here - and the launcher .bat reads and deletes it. No answer within TIMEOUT seconds: the game starts
with its offline identity. A code file older than STALE_SECONDS is removed first, so a code left by
an earlier launch is never used.

The game's prefix also gets the EOS overlay's pointer, HKCU\Software\Epic Games\EOS "OverlayPath"
(the mechanism from Legendary's lfs/eos.py), naming the one copy the app keeps at OVERLAY_DIR; with
it the game's EOS SDK can show Epic's own sign-in steps in game (a consent Epic asks for once per
product) instead of waiting on a browser. Wine's browser is set to droiddeck-open-url, which hands
web addresses to Android.

    droiddeck-store-launch <the arguments Steam gave the compat tool>
"""

import json
import os
import sys
import time
from pathlib import Path

LAUNCHER = ".droiddeck-launch.bat"
SIDECAR = ".droiddeck-store.json"
EPIC_CODE = ".droiddeck-epic-code"
TIMEOUT = 5.0
STALE_SECONDS = 300
OVERLAY_DIR = "/root/.local/share/droiddeck/epic-overlay"
SERVER_WAIT = 8.0
BROWSER = "/usr/local/bin/droiddeck-open-url"
OVERLAY_DLL = "EOSOVH-Win64-Shipping.dll"
EOS_KEY = "[Software\\\\Epic Games\\\\EOS]"
BROWSER_KEY = "[Software\\\\Wine\\\\WineBrowser]"


def log(line):
    print("droiddeck-store-launch: " + line, file=sys.stderr, flush=True)


def find_store_game(argv):
    """The (folder, sidecar) of the store launcher among [argv], or None when this is not one."""
    for raw in argv:
        arg = raw.strip().strip('"').strip("'")
        if not arg.lower().endswith(LAUNCHER):
            continue
        folder = Path(arg.replace("\\", "/")).parent
        try:
            sidecar = json.loads((folder / SIDECAR).read_text())
        except (OSError, ValueError):
            continue
        if not isinstance(sidecar, dict) or not sidecar.get("store") or not sidecar.get("id"):
            continue
        return folder, sidecar
    return None


def drop_stale(folder, now=None):
    """Removes a code file older than STALE_SECONDS; True when one was removed."""
    code = folder / EPIC_CODE
    try:
        if (now if now is not None else time.time()) - code.stat().st_mtime > STALE_SECONDS:
            code.unlink()
            return True
    except OSError:
        pass
    return False


def ask(channel, request, timeout=TIMEOUT):
    """Writes [request] under channel/req and waits for channel/resp; the answer, or None on timeout."""
    req_dir, resp_dir = channel / "req", channel / "resp"
    req_dir.mkdir(parents=True, exist_ok=True)
    resp_dir.mkdir(parents=True, exist_ok=True)
    rid = "%d-%d" % (os.getpid(), int(time.time() * 1000))
    tmp = req_dir / (rid + ".json.tmp")
    tmp.write_text(json.dumps(request))
    os.rename(tmp, req_dir / (rid + ".json"))
    answer = resp_dir / (rid + ".json")
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            value = json.loads(answer.read_text())
            answer.unlink()
            return value
        except (OSError, ValueError):
            time.sleep(0.05)
    # Not answered: the request is withdrawn so a late app does not mint a code nobody reads.
    try:
        (req_dir / (rid + ".json")).unlink()
    except OSError:
        pass
    return None


def windows_path(unix):
    """A guest path as Wine names it through Z: (the whole filesystem)."""
    return "Z:" + unix.replace("/", "\\")


def reg_escape(value):
    return value.replace("\\", "\\\\").replace('"', '\\"')


def set_value(reg, key, name, value, now=None):
    """
    Appends `key` with `"name"="value"` to a prefix's user.reg unless the last word on it already
    says so. Appended rather than edited in place, as DirectAudio's key is: Wine reads later sections
    over earlier ones, and nothing of the file is rewritten under a wineserver that might hold it.
    False when there is no user.reg yet (Proton creates the prefix after this; the next launch has it).
    """
    if not reg.is_file():
        return False
    line = '"%s"="%s"' % (name, reg_escape(value))
    inside, last = False, None
    for row in reg.read_text(errors="replace").splitlines():
        if row.startswith("["):
            inside = row.startswith(key + " ") or row == key
        elif inside and row.startswith('"%s"=' % name):
            last = row
    if last == line:
        return True
    with open(reg, "a") as out:
        out.write("\n%s %d\n%s\n" % (key, int(now if now is not None else time.time()), line))
    return True


def overlay_version(overlay_dir=OVERLAY_DIR):
    """The overlay build the app placed, or None when it is not there."""
    d = Path(overlay_dir)
    try:
        if not (d / OVERLAY_DLL).is_file():
            return None
    except OSError:  # unreadable is the same as not there
        return None
    try:
        return (d / "droiddeck-version").read_text().strip() or "unknown"
    except OSError:
        return "unknown"


def remove_value(reg, key, name):
    """Drops every `"name"=` line under `key` in user.reg; True when one was there."""
    if not reg.is_file():
        return False
    rows, inside, dropped = [], False, False
    for row in reg.read_text(errors="replace").splitlines(keepends=True):
        if row.startswith("["):
            inside = row.startswith(key + " ") or row.rstrip("\n") == key
        elif inside and row.startswith('"%s"=' % name):
            dropped = True
            continue
        rows.append(row)
    if dropped:
        tmp = reg.with_name(reg.name + ".dd-new")
        tmp.write_text("".join(rows))
        os.replace(tmp, reg)
    return dropped


def options(sidecar):
    """An Epic game's launch choices from its sidecar, as the Games tab's Epic card sets them."""
    e = sidecar.get("epic") if isinstance(sidecar.get("epic"), dict) else {}
    # The overlay is off unless chosen (v2 on): the old default wrote overlay=true without a version,
    # and with it on a game could exit while loading. The app's EpicOptions reads it the same way.
    chosen = isinstance(e.get("v"), int) and e.get("v") >= 2
    return {"eos": e.get("eos", True) is not False, "offline": e.get("offline", False) is True, "overlay": chosen and e.get("overlay") is True}


def server_socket(pfx):
    """The wineserver socket for prefix [pfx], as Wine names it (server-<dev>-<inode> under /tmp/.wine-<uid>)."""
    try:
        st = os.stat(pfx)
    except OSError:
        return None
    base = os.environ.get("BL_WINE_SERVER_BASE") or "/tmp/.wine-%d" % os.getuid()
    return Path(base) / ("server-%x-%x" % (st.st_dev, st.st_ino)) / "socket"


def wait_for_server(pfx, timeout=SERVER_WAIT):
    """
    Waits for a wineserver still holding this prefix (an earlier verb's, or the last launch's) to
    exit: it rewrites user.reg from its own memory when it does, which drops a line appended
    meanwhile. True when none is left.
    """
    sock = server_socket(pfx)
    if sock is None:
        return True
    deadline = time.monotonic() + timeout
    while sock.exists() and time.monotonic() < deadline:
        time.sleep(0.1)
    return not sock.exists()


def has_value(reg, key, name, value=None):
    """Whether the last word on `key` in user.reg sets "name" (to [value], when given)."""
    if not reg.is_file():
        return False
    inside, last = False, None
    for row in reg.read_text(errors="replace").splitlines():
        if row.startswith("["):
            inside = row.startswith(key + " ") or row == key
        elif inside and row.startswith('"%s"=' % name):
            last = row
    if last is None:
        return False
    return value is None or last == '"%s"="%s"' % (name, reg_escape(value))


def provision_prefix(compat_data, overlay_dir=OVERLAY_DIR, overlay=False, wait=SERVER_WAIT):
    """The EOS overlay pointer (or its removal, when [overlay] is off) and Wine's browser, in the game's prefix."""
    if not compat_data:
        return "no-prefix"
    pfx = Path(compat_data) / "pfx"
    reg = pfx / "user.reg"
    if not reg.is_file():
        return "prefix-not-created"
    # A wineserver still up for this prefix would write its own copy of user.reg over these lines.
    busy = not wait_for_server(pfx, wait)
    # winebrowser starts the first entry it can; an absolute path does not depend on the PATH Wine
    # hands it.
    set_value(reg, BROWSER_KEY, "Browsers", BROWSER + ",xdg-open")
    if not overlay:
        remove_value(reg, EOS_KEY, "OverlayPath")
        state = "overlay-off" if not has_value(reg, EOS_KEY, "OverlayPath") else "overlay-still-set"
    elif overlay_version(overlay_dir) is None:
        return "overlay-missing"
    else:
        target = windows_path(overlay_dir)
        set_value(reg, EOS_KEY, "OverlayPath", target)
        # Read back, so the log says what the file holds, not what was meant.
        state = "overlay-set" if has_value(reg, EOS_KEY, "OverlayPath", target) else "overlay-not-written"
    return state + (" server-running" if busy else "")


def main(argv):
    found = find_store_game(argv)
    if found is None:
        return 0
    folder, sidecar = found
    store, game = str(sidecar.get("store")), str(sidecar.get("id"))
    if store != "epic":
        return 0
    drop_stale(folder)
    choice = options(sidecar)
    compat_data = os.environ.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
    prefix_id = os.path.basename(compat_data) if compat_data else ""
    launch_dir = os.environ.get("BL_LAUNCH_DIR", "")
    if not (choice["eos"] and not choice["offline"]):
        # Sign-in off, or offline: no code, and none left over from before.
        try:
            (folder / EPIC_CODE).unlink()
        except OSError:
            pass
        log("epic launch id=%s code=no reason=%s" % (game, "offline" if choice["offline"] else "eos-off"))
    elif not launch_dir:
        log("epic launch id=%s code=no reason=no-channel" % game)
    else:
        answer = ask(Path(launch_dir) / "stores", {"op": "epic-code", "store": store, "id": game, "prefix": prefix_id})
        if answer is None:
            log("epic launch id=%s code=no reason=timeout" % game)
        else:
            got = bool(answer.get("code"))
            log("epic launch id=%s code=%s reason=%s" % (game, "yes" if got else "no", answer.get("reason", "")))
    done = provision_prefix(compat_data, overlay=choice["overlay"])
    log("epic overlay installed=%s prefix=%s %s" % (overlay_version() or "no", prefix_id or "?", done))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except Exception as e:  # never in the way of the launch
        log("skipped: %s" % type(e).__name__)
        sys.exit(0)
