#!/usr/bin/env python3
"""wallet-unlock: unlock the shared KDE wallet with a password dialog shown on
the display you're using (the desktop), instead of ksecretd's own prompt, which
pops up as a floating VR window on the Steam session's display.

  wallet-unlock            ask (kdialog) if the wallet is locked; exit 0 when unlocked.
                           Schedules an automatic lock after the hours set on the installer window's
                           Wallet apps page (wallet.conf; WALLET_LOCK_HOURS overrides; default 4).
  wallet-unlock --lock     lock it now
  wallet-unlock --schedule-lock     start the auto-lock countdown again (Extend timer)
  wallet-unlock --autolock on|off   whether it locks itself at all (timer and idle close)
  wallet-unlock --status   print locked / unlocked
  wallet-unlock --info     JSON: state, when it was unlocked, when it locks (installer window)
  wallet-unlock --watch    run by wallet-lock-sync.service:
                           - when the wallet is closed (KWalletManager's Close, idle timeout),
                             really lock it (kwalletd6's close leaves ksecretd unlocked);
                           - whenever it gets unlocked, (re)start the auto-lock timer;
                           - keep kwalletd6, what KWalletManager asks, in step: one started after
                             an unlock, or running while this dialog unlocked ksecretd, said
                             the wallet was closed while apps used it;
                           - replace ksecretd's own password prompt ("KDE Wallet Service",
                             floating in VR; while it's open every other unlock is ignored)
                             with this dialog, on the same display.

It derives the same key KDE's login-time auto-unlock (kwallet-pam) uses,
PBKDF2-SHA512 over the wallet's salt, and hands it to ksecretd's pamOpen on the
outer session bus. The password and key never appear on a command line.
"""
import contextlib
import fcntl
import hashlib
import json
import math
import os
import re
import subprocess
import sys
import time
from pathlib import Path

sys.path.insert(0, os.environ.get("FRAME_TWEAKS_PYLIB") or str(Path.home() / ".local/share/frame-tweaks/i18n"))   # (lib/common.sh install_i18n)
try:
    from i18n import _, N_, ngettext
except ImportError:                       # (not installed by Frame Tweaks: English)
    def _(text):
        return text

    def ngettext(singular, plural, n):
        return singular if n == 1 else plural
    N_ = _
import ftconf  # noqa: E402  (settings files: lib/ftconf.py, installed beside i18n.py)

from jeepney import DBusAddress, MessageType, new_method_call
from jeepney.bus_messages import message_bus
from jeepney.io.blocking import open_dbus_connection

WALLET = "kdewallet"
SALT = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "kwalletd" / f"{WALLET}.salt"
OUTER_BUS = f"unix:path=/run/user/{os.getuid()}/bus"
SECRETS = DBusAddress("/org/freedesktop/secrets", bus_name="org.freedesktop.secrets",
                      interface="org.freedesktop.Secret.Service")
COLLECTION = f"/org/freedesktop/secrets/collection/{WALLET}"
KSECRETD = DBusAddress("/ksecretd", bus_name="org.kde.ksecretd", interface="org.kde.KWallet")
KWALLETD = DBusAddress("/modules/kwalletd6", bus_name="org.kde.kwalletd6", interface="org.kde.KWallet")
# When the wallet was unlocked and when the auto-lock fires (cleared by locking, and by a reboot).
SESSION = Path(f"/run/user/{os.getuid()}/wallet-session.json")


def bus():
    return open_dbus_connection(OUTER_BUS)


def call(conn, msg):
    reply = conn.send_and_get_reply(msg)
    if reply.header.message_type == MessageType.error:
        raise RuntimeError(f"{reply.header.fields.get(4, 'D-Bus error')}: {reply.body}")
    return reply.body


def ensure_ksecretd(conn):
    """ksecretd holds the wallet; it is D-Bus activatable only as secretservicecompat."""
    if call(conn, message_bus.NameHasOwner("org.kde.ksecretd"))[0]:
        return
    call(conn, message_bus.StartServiceByName("org.kde.secretservicecompat"))
    for _ in range(50):
        if call(conn, message_bus.NameHasOwner("org.kde.ksecretd"))[0]:
            return
        time.sleep(0.1)
    raise RuntimeError("ksecretd did not start")


def is_locked(conn):
    """Locked = ksecretd doesn't have the wallet open. (Not the Secret Service "Locked"
    property: after a pamOpen unlock it still says True, which made every correct password
    look wrong.)"""
    ensure_ksecretd(conn)
    return not ksecretd_open(conn)


def ksecretd_locked(conn):
    """is_locked, without starting ksecretd: when it isn't running, it shows no prompt either."""
    return bool(call(conn, message_bus.NameHasOwner("org.kde.ksecretd"))[0]) and not ksecretd_open(conn)


def ksecretd_close(conn):
    """Fully close the wallet inside ksecretd. The Secret Service Lock only hides the
    secrets: the wallet stays loaded ("open" and "locked" at once), and then pamOpen
    sees it as already open and does nothing, so every password looks wrong."""
    try:
        call(conn, new_method_call(KSECRETD, "close", "sb", (WALLET, True)))
    except Exception:
        pass


def ksecretd_open(conn):
    try:
        return bool(call(conn, new_method_call(KSECRETD, "isOpen", "s", (WALLET,)))[0])
    except Exception:
        return False


def ksecretd_id():
    """Which ksecretd holds the wallet (its pid and start time), or None. A wallet unlocked in an
    earlier one (gone with a SteamVR restart, say) is another unlock: the Wallet apps page counted
    "open for" from the first one."""
    try:
        with bus() as conn:
            owner = call(conn, message_bus.GetNameOwner("org.kde.ksecretd"))[0]
            pid = call(conn, message_bus.GetConnectionUnixProcessID(owner))[0]
        return f"{pid}:{Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()[19]}"
    except Exception:
        return None


def lock(conn):
    ensure_ksecretd(conn)
    try:
        call(conn, new_method_call(SECRETS, "Lock", "ao", ([COLLECTION],)))
    except Exception:
        pass
    ksecretd_close(conn)
    try:  # kwalletd6 keeps its own open handle on top of ksecretd
        call(conn, new_method_call(KWALLETD, "close", "sb", (WALLET, True)))
    except Exception:
        pass
    SESSION.unlink(missing_ok=True)


def session():
    try:
        return json.loads(SESSION.read_text())
    except (OSError, ValueError):
        return {}


def timer_lock_time():
    """When the running auto-lock timer fires (Unix time), else None. (A timer an older version
    started counted down from its start: that + WALLET_LOCK_HOURS.)"""
    env = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}", DBUS_SESSION_BUS_ADDRESS=OUTER_BUS)
    r = subprocess.run(["systemctl", "--user", "show", "--timestamp=unix", "-p", "TimersCalendar",
                        "-p", "ActiveEnterTimestamp", "-p", "ActiveState", "wallet-autolock.timer"],
                       env=env, capture_output=True, text=True)
    props = dict(line.split("=", 1) for line in r.stdout.splitlines() if "=" in line)
    if props.get("ActiveState") != "active":
        return None
    at = re.search(r"\bnext_elapse=@(\d+)", props.get("TimersCalendar", ""))
    if at:
        return int(at[1])
    started = props.get("ActiveEnterTimestamp", "").lstrip("@")
    return int(started) + float(lock_hours()) * 3600 if started.isdigit() else None


def idle_close_minutes():
    """kwalletd also closes (we then lock) the wallet after this long unused, if enabled."""
    conf = Path.home() / ".config/kwalletrc"
    text = conf.read_text() if conf.exists() else ""
    group = text.split("[Wallet]", 1)[1].split("\n[", 1)[0] if "[Wallet]" in text else ""
    if "Close When Idle=true" not in group:
        return None
    for line in group.splitlines():
        if line.startswith("Idle Timeout="):
            try:
                return int(line.split("=", 1)[1])
            except ValueError:
                return None
    return None


def x_env(display):
    """Environment to reach an X display; the desktop's :2 needs its XAUTHORITY."""
    env = dict(os.environ, DISPLAY=display)
    if display != ":0":
        src = subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "ksmserver"],
                             capture_output=True, text=True).stdout.split()
        if src:
            try:
                senv = dict(kv.split("=", 1) for kv in
                            Path(f"/proc/{src[0]}/environ").read_text().split("\0") if "=" in kv)
                env["DISPLAY"] = senv.get("DISPLAY", display)
                if "XAUTHORITY" in senv:
                    env["XAUTHORITY"] = senv["XAUTHORITY"]
            except OSError:
                pass
    return env


def ksecretd_prompts():
    """[(display, window id)] of visible ksecretd dialogs: the "bad" route. (Ours are
    titled "Unlock wallet", class kdialog.)"""
    found = []
    for display in (":0", ":2"):
        env = x_env(display)
        try:
            tree = subprocess.run(["xwininfo", "-root", "-tree"], env=env, capture_output=True,
                                  text=True, timeout=3).stdout
        except (OSError, subprocess.SubprocessError):
            continue
        for line in tree.splitlines():
            if '"KDE Wallet Service": ("ksecretd"' not in line:
                continue
            wid = line.split()[0]
            info = subprocess.run(["xwininfo", "-id", wid], env=env, capture_output=True, text=True).stdout
            if "IsViewable" in info and "Width: 1\n" not in info:
                found.append((env["DISPLAY"], int(wid, 16)))
    return found


def close_window(display, wid):
    """Ask a window to close, like its ✕ button (WM_DELETE_WINDOW); ksecretd treats it as
    Cancel. Plain Xlib through ctypes: no xdotool on SteamOS."""
    import ctypes as C

    class ClientMessage(C.Structure):
        _fields_ = [("type", C.c_int), ("serial", C.c_ulong), ("send_event", C.c_int),
                    ("display", C.c_void_p), ("window", C.c_ulong), ("message_type", C.c_ulong),
                    ("format", C.c_int), ("data", C.c_long * 5), ("pad", C.c_long * 24)]
    xlib = C.cdll.LoadLibrary("libX11.so.6")
    xlib.XOpenDisplay.restype = C.c_void_p
    xlib.XInternAtom.restype = C.c_ulong
    xlib.XInternAtom.argtypes = [C.c_void_p, C.c_char_p, C.c_int]
    xlib.XSendEvent.argtypes = [C.c_void_p, C.c_ulong, C.c_int, C.c_long, C.c_void_p]
    old = os.environ.get("XAUTHORITY")
    env = x_env(display)
    if "XAUTHORITY" in env:
        os.environ["XAUTHORITY"] = env["XAUTHORITY"]
    dpy = xlib.XOpenDisplay(display.encode())
    if old is not None:
        os.environ["XAUTHORITY"] = old
    if not dpy:
        return False
    ev = ClientMessage(type=33, window=wid, format=32,                      # 33 = ClientMessage
                       message_type=xlib.XInternAtom(dpy, b"WM_PROTOCOLS", False))
    ev.data[0] = xlib.XInternAtom(dpy, b"WM_DELETE_WINDOW", False)
    ev.data[1] = 0                                                           # CurrentTime
    xlib.XSendEvent(dpy, wid, False, 0, C.byref(ev))
    xlib.XFlush(C.c_void_p(dpy))
    xlib.XCloseDisplay(C.c_void_p(dpy))
    return True


def prompt_pending():
    """True if one of ksecretd's own password prompts is showing (VR display :0 or the
    desktop). While it is, ksecretd ignores pamOpen, so a right password looks wrong. (The
    broker's rule for a showing one: a 1-pixel window isn't.)"""
    return bool(ksecretd_prompts())


def ask_password(retry):
    text = (_("Wrong password (is Caps Lock on?). Try again:") if retry
            else _("Password for the KDE wallet:"))
    r = subprocess.run(["kdialog", "--title", _("Unlock wallet"), "--password", text],
                       capture_output=True, text=True)
    return None if r.returncode else r.stdout.rstrip("\n")


def unlock(conn):
    ensure_ksecretd(conn)
    if ksecretd_open(conn):          # stuck "open but locked": pamOpen would be ignored
        ksecretd_close(conn)
    try:
        salt = SALT.read_bytes()
    except OSError:                  # (no wallet made at login: KWalletManager makes one)
        subprocess.run(["kdialog", "--title", _("Unlock wallet"), "--sorry",
                        _("There's no KDE wallet here to unlock with a password yet (kdewallet, made at "
                          "login). KWalletManager can make one.")])
        return False
    for attempt in range(3):
        pw = ask_password(attempt > 0)
        if pw is None:
            return not is_locked(conn)     # (a slow unlock that landed meanwhile counts)
        key = hashlib.pbkdf2_hmac("sha512", pw.encode(), salt, 50000, 56)
        del pw
        conn.send(new_method_call(KSECRETD, "pamOpen", "sayi", (WALLET, key, 0)))
        for _poll in range(50):        # pamOpen has no reply; poll the result (5 s: a busy Frame is slow)
            time.sleep(0.1)
            if not is_locked(conn):
                return True
        if prompt_pending():
            subprocess.run(["kdialog", "--title", _("Unlock wallet"), "--sorry",
                            _("A KDE Wallet password prompt is already open (look in VR, around the "
                              "dashboard). While it is open the wallet ignores this dialog.\n\n"
                              "Type the password there, or close that prompt and try again.")])
            return False
    return False


# One password dialog at a time (ask_once): held while one is open; when one was last cancelled.
ASKING = Path(f"/run/user/{os.getuid()}/wallet-unlock.lock")
CANCELLED = Path(f"/run/user/{os.getuid()}/wallet-unlock.cancelled")


def ask_once(conn):
    """Ask for the password unless a dialog is open already. A request meanwhile (Firefox
    started twice, a git push beside a gh call, ksecretd's prompt replaced) waits for that one
    and takes its answer instead of opening another dialog. Returns "unlocked" (this dialog
    unlocked it), "already" (another one did) or None (still locked: a dialog was cancelled)."""
    since = time.time()
    with open(ASKING, "a") as held:
        try:
            fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print(_("Waiting for the wallet password dialog that's already open…"), file=sys.stderr, flush=True)
            fcntl.flock(held, fcntl.LOCK_EX)
            if not is_locked(conn):
                return "already"
            try:
                if CANCELLED.stat().st_mtime >= since:   # cancelled while this one waited: no again
                    return None
            except OSError:
                pass
        if not is_locked(conn):
            return "already"
        if unlock(conn):
            return "unlocked"
        CANCELLED.touch()
        return None


LOCK_CONF = Path.home() / ".config/frame-tweaks/wallet.conf"


def lock_hours():
    """Hours from an unlock to the auto-lock: WALLET_LOCK_HOURS, else wallet.conf's (set on the
    Wallet apps page), else 4."""
    for v in (os.environ.get("WALLET_LOCK_HOURS"), conf_value("WALLET_LOCK_HOURS")):
        try:
            if v and 0 < float(v) < math.inf:
                return v
        except ValueError:
            pass
    return "4"


def conf_value(key):
    return ftconf.read_shell(LOCK_CONF).get(key)


def autolock_on():
    """Whether the wallet locks itself (Wallet apps page's Auto-lock box; on unless turned off)."""
    return (os.environ.get("WALLET_AUTOLOCK") or conf_value("WALLET_AUTOLOCK") or "1") != "0"


def set_autolock(conn, on):
    """Turn both automatic locks on or off: the timer after an unlock, and kwalletd's close
    after being unused (kwalletrc Close When Idle; the Idle Timeout itself is kept)."""
    ftconf.set_shell(LOCK_CONF, {"WALLET_AUTOLOCK": 1 if on else 0})
    subprocess.run(["kwriteconfig6", "--file", "kwalletrc", "--group", "Wallet", "--key", "Close When Idle",
                    "true" if on else "false"], stderr=subprocess.DEVNULL)
    for addr in (KWALLETD, KSECRETD):          # re-read kwalletrc now
        try:
            call(conn, new_method_call(addr, "reconfigure"))
        except Exception:
            pass
    if not on or is_locked(conn):
        stop_lock_timer()
        return None
    return schedule_lock()


# Held while the auto-lock is (re)scheduled: an unlock schedules it, and so does the watcher as it
# sees the wallet open, a moment apart. (The second systemd-run found the first one's timer and
# failed: "unlocked for ? hours".)
SCHEDULING = Path(f"/run/user/{os.getuid()}/wallet-autolock.lock")


@contextlib.contextmanager
def scheduling():
    with open(SCHEDULING, "a") as held:
        fcntl.flock(held, fcntl.LOCK_EX)
        yield


def stop_lock_timer():
    env = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}", DBUS_SESSION_BUS_ADDRESS=OUTER_BUS)
    with scheduling():
        for cmd in (["stop", "wallet-autolock.timer", "wallet-autolock.service"],
                    ["reset-failed", "wallet-autolock.timer", "wallet-autolock.service"]):
            subprocess.run(["systemctl", "--user", *cmd], env=env, stderr=subprocess.DEVNULL)
        s = session()
        s.pop("locks", None)
        try:
            SESSION.write_text(json.dumps(s))
        except OSError:
            pass


def schedule_lock(at=None):
    """(Re)start a transient systemd timer that locks the wallet later; None with auto-lock off.
    It fires at a time on the clock (OnCalendar=@…), not after a countdown (OnActiveSec=): a
    countdown started again at every systemctl daemon-reload (installers run one), and stood
    still while the Frame slept, so the wallet stayed unlocked hours past the time the Wallet apps
    page showed. A time that went by while the Frame slept fires as it wakes."""
    if not autolock_on():
        stop_lock_timer()
        return None
    hours = lock_hours()
    env = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}",
               DBUS_SESSION_BUS_ADDRESS=OUTER_BUS)
    with scheduling():
        deadline = math.ceil(time.time() + float(hours) * 3600)
        if at is not None and time.time() + 60 < at < deadline:
            deadline = int(at)                   # (the time it had: kept, when the watcher starts again)
        if session().get("locks") == deadline and timer_lock_time() == deadline:
            return hours                  # (just scheduled by the other one, to the second)
        # Clear the previous timer *and* its service: a failed earlier run left a unit with this name
        # behind, and systemd-run then refused the name, silently, so nothing locked the wallet.
        for cmd in (["stop", "wallet-autolock.timer", "wallet-autolock.service"],
                    ["reset-failed", "wallet-autolock.timer", "wallet-autolock.service"]):
            subprocess.run(["systemctl", "--user", *cmd], env=env, stderr=subprocess.DEVNULL)
        r = subprocess.run(["systemd-run", "--user", "--quiet", "--unit=wallet-autolock",
                            f"--on-calendar=@{deadline}", "--timer-property=AccuracySec=1s",
                            os.path.abspath(sys.argv[0]), "--lock"],
                           env=env, capture_output=True, text=True)
        if r.returncode != 0 and timer_lock_time() != deadline:
            print(_("wallet-unlock: couldn't schedule the auto-lock: {error}").format(error=r.stderr.strip()),
                  file=sys.stderr, flush=True)
            return "?"
        s, holder = session(), ksecretd_id()
        if s.get("ksecretd") != holder:           # (unlocked in another ksecretd: a new unlock)
            s = {"ksecretd": holder}
        s.setdefault("unlocked", time.time())     # (the same unlock: Extend keeps when it was)
        s["locks"] = deadline                     # (the timer's own time: the page shows this one)
        try:
            SESSION.write_text(json.dumps(s))
        except OSError:
            pass
    return hours


def open_in_kwalletd6():
    """Whether kwalletd6 still has the wallet open (some handle there: ours, or an app's)."""
    try:
        with bus() as calls:
            return bool(call(calls, message_bus.NameHasOwner("org.kde.kwalletd6"))[0]) and \
                bool(call(calls, new_method_call(KWALLETD, "isOpen", "s", (WALLET,)))[0])
    except Exception:
        return False                    # (can't tell: closed, as it said)


def lock_set():
    """Whether the auto-lock is set for the unlock in this ksecretd (its timer running, the session
    saying so): an app opening the wallet then isn't a new unlock."""
    s = session()
    return bool(s.get("locks")) and s.get("ksecretd") == ksecretd_id() and timer_lock_time() is not None


def keep_auto_lock():
    """The watcher starting (a reinstall, an update): an unlocked wallet with no auto-lock
    scheduled gets one, at the time it had if that's still to come. It was scheduled only on the
    next unlock, so after ↻ the wallet stayed unlocked past the time the page showed."""
    try:
        with bus() as calls:
            unlocked = bool(call(calls, message_bus.NameHasOwner("org.kde.ksecretd"))[0]) and ksecretd_open(calls)
        if not unlocked or not autolock_on() or timer_lock_time() is not None:
            return
        s = session()
        hours = schedule_lock(at=s.get("locks") if s.get("ksecretd") == ksecretd_id() else None)
        print(f"{time.strftime('%T')} unlocked with no auto-lock -> auto-lock in {hours} h", flush=True)
    except Exception as e:
        print(f"auto-lock: {e}", flush=True)


class FrontEnd:
    """Keeps kwalletd6 in step with ksecretd, holding a handle of its own there ("Frame Tweaks")
    while the wallet is unlocked. kwalletd6 is what KWalletManager asks, and it counts as open only
    what was opened through it: one started after an unlock said "closed" while Firefox and
    Signal used the wallet. And when the last app with a handle there quits (Signal), kwalletd6
    says the wallet closed, which locks it for every app: whether that happened depended on
    whether this held a handle. It always does now (the owner's choice: quitting an app doesn't
    lock the wallet). Its handle idling out (Close When Idle) while apps still hold theirs, it
    takes another; idling out last, that's the wallet unused: it closes, and is locked."""

    def __init__(self):
        self.conn = None
        self.handle = None
        self.opened = -10.0      # (time.monotonic() of our open: its walletOpened isn't an app's)

    def ours(self):
        return time.monotonic() - self.opened < 5

    def forget(self):
        """Our handle is gone (the wallet closed or locked, kwalletd6 started again, ours idled out)."""
        with contextlib.suppress(Exception):
            self.conn.close()
        self.conn = self.handle = None

    def sync(self, only_while_open_there=False):
        """Hold our handle while ksecretd has the wallet open (only_while_open_there: only if apps
        keep it open in kwalletd6: ours idled out)."""
        try:
            with bus() as calls:
                if not (call(calls, message_bus.NameHasOwner("org.kde.kwalletd6"))[0] and ksecretd_open(calls)):
                    return              # (not running: not started for this; or the wallet is locked)
                if self.conn is not None:
                    return              # (ours is held)
                if only_while_open_there and not call(calls, new_method_call(KWALLETD, "isOpen", "s", (WALLET,)))[0]:
                    return
            self.conn = bus()
            self.opened = time.monotonic()
            self.handle = call(self.conn, new_method_call(KWALLETD, "open", "sxs", (WALLET, 0, "Frame Tweaks")))[0]
            print(f"{time.strftime('%T')} wallet open in ksecretd -> holding it open in kwalletd6 too", flush=True)
        except Exception as e:
            print(f"front end: {e}", flush=True)
            self.forget()
            self.opened = -10.0  # (no open of ours: a walletOpened now is an app's, with its auto-lock)


def watch():
    """kwalletd6 'closing' a wallet leaves ksecretd unlocked, so apps keep reading
    secrets. Turn every close into a real lock."""
    from jeepney import MatchRule
    from jeepney.bus_messages import message_bus as mb
    import threading
    threading.Thread(target=broker, daemon=True).start()
    # The signals come on this connection, and the watcher's own calls go on another: a call waiting
    # for its reply drops whatever else arrives meanwhile (jeepney's blocking send_and_get_reply), so
    # the wallet opened again while this locked it went unnoticed, and nothing scheduled its auto-lock.
    front = FrontEnd()
    with bus() as conn:
        for member in ("walletClosed", "allWalletsClosed", "walletOpened"):
            rule = MatchRule(type="signal", interface="org.kde.KWallet", member=member)
            call(conn, mb.AddMatch(rule))
        rule = MatchRule(type="signal", sender="org.freedesktop.DBus", interface="org.freedesktop.DBus",
                         member="NameOwnerChanged", path="/org/freedesktop/DBus")
        rule.add_arg_condition(0, "org.kde.kwalletd6")
        call(conn, mb.AddMatch(rule))
        front.sync()
        keep_auto_lock()
        while True:
            msg = conn.receive()
            if msg.header.message_type != MessageType.signal:
                continue
            member = msg.header.fields.get(3)          # 3 = MEMBER
            body = msg.body
            if member == "NameOwnerChanged":           # kwalletd6 started (by KWalletManager, say), or ended
                front.forget()                         # (a handle in the one before is no handle)
                if len(body) > 2 and body[2]:
                    front.sync()
                continue
            if member == "walletClosed" and body and front.handle is not None and body[0] == front.handle:
                front.forget()                         # (ours idled out: another, if apps still use it)
                front.sync(only_while_open_there=True)
                continue
            if member == "walletOpened" and body and body[0] == WALLET:
                if front.ours():                       # (ours, keeping kwalletd6 in step: not an app)
                    continue
                if lock_set():
                    # An app opening the wallet that's unlocked already: the auto-lock stays where it
                    # is, counted from the unlock or the last Extend, as the Wallet apps page shows it.
                    # (It moved later with every app's open: the owner's choice was the unlock.)
                    print(f"{time.strftime('%T')} wallet opened by an app; auto-lock stays", flush=True)
                    front.sync()
                    continue
                # Unlocked by any route: the auto-lock counts from now.
                hours = schedule_lock()
                print(f"{time.strftime('%T')} wallet opened -> " + (f"auto-lock in {hours} h" if hours else "auto-lock off"), flush=True)
                front.sync()
                continue
            if member == "allWalletsClosed" or (member == "walletClosed" and body and body[0] == WALLET):
                if member == "walletClosed" and open_in_kwalletd6():
                    # (an app with a handle there quit, Signal say: the wallet is still open, ours
                    # holds it. Quitting an app doesn't lock the wallet: the owner's choice.)
                    print(f"{time.strftime('%T')} an app closed the wallet; still open in kwalletd6 -> left unlocked",
                          flush=True)
                    continue
                front.forget()                         # (closed: ours with it)
                try:
                    with bus() as calls:
                        if not is_locked(calls) or ksecretd_open(calls):
                            lock(calls)
                            print(f"{time.strftime('%T')} wallet closed -> locked and closed in ksecretd", flush=True)
                        else:                           # (closed already, not by lock(): its session and timer go too)
                            SESSION.unlink(missing_ok=True)
                            stop_lock_timer()
                except Exception as e:                  # keep watching even if one lock fails
                    print(f"lock failed: {e}", flush=True)


def broker():
    """Replace ksecretd's own password prompt with ours. Only while the wallet is locked:
    a ksecretd window on an unlocked wallet is an "allow this app?" question, left alone. So it
    asks ksecretd first, and lists the displays' windows (a few programs each time) only while
    it's locked: doing that every 1.5 s took about 2.6% of a core all day."""
    exe = os.path.abspath(sys.argv[0])
    conn = None
    while True:
        time.sleep(1.5)
        try:
            conn = conn or bus()
            if not ksecretd_locked(conn):
                continue
            prompts = ksecretd_prompts()
            if not prompts or not ksecretd_locked(conn):     # (still locked, now there's one to close)
                continue
            for display, wid in prompts:
                close_window(display, wid)
                print(f"{time.strftime('%T')} closed ksecretd's prompt on {display}; showing ours", flush=True)
            time.sleep(0.5)
            env = x_env(prompts[0][0])
            env.pop("WAYLAND_DISPLAY", None)          # kdialog on that X display
            env["QT_QPA_PLATFORM"] = "xcb"
            subprocess.run([exe], env=env)              # our dialog; unlocks on success
        except Exception as e:                          # keep brokering whatever happens
            print(f"broker: {e}", flush=True)
            with contextlib.suppress(Exception):        # (a new connection next time)
                conn.close()
            conn = None


def notify(text):
    subprocess.run(["notify-send", "-a", "Wallet", text], stderr=subprocess.DEVNULL)


def main():
    arg = sys.argv[1] if len(sys.argv) > 1 else ""
    if arg == "--watch":
        watch()
        return
    with bus() as conn:
        if arg == "--status":
            print("locked" if is_locked(conn) else "unlocked")
        elif arg == "--info":
            locked = is_locked(conn)
            s = {} if locked else session()
            if not locked and "locks" not in s:     # unlocked before times were recorded
                s["locks"] = timer_lock_time()
            print(json.dumps({"state": "locked" if locked else "unlocked", "unlocked_at": s.get("unlocked"),
                              "locks_at": s.get("locks"), "idle_close_minutes": idle_close_minutes(),
                              "lock_hours": float(lock_hours()), "autolock": autolock_on()}))
        elif arg == "--lock":
            lock(conn)
            if not is_locked(conn):
                sys.exit(_("wallet-unlock: the wallet is still unlocked (locking failed)"))
            print("locked")
        elif arg == "--watch":
            pass
        elif arg == "--schedule-lock":
            hours = schedule_lock()
            print(_("wallet locks in {hours} h").format(hours=hours) if hours else
                  _("auto-lock is off: the wallet stays unlocked until you lock it"))
        elif arg == "--autolock" and len(sys.argv) > 2 and sys.argv[2] in ("on", "off"):
            hours = set_autolock(conn, sys.argv[2] == "on")
            print(_("auto-lock off") if sys.argv[2] == "off" else
                  _("auto-lock on: the wallet locks in {hours} h").format(hours=hours) if hours else
                  _("auto-lock on"))
        elif arg:
            sys.exit(__doc__)
        elif not is_locked(conn):
            print(_("Wallet already unlocked."))
        elif (asked := ask_once(conn)) == "already":
            print(_("Wallet already unlocked."))
        elif asked:
            hours = schedule_lock()
            print(_("Wallet unlocked; it locks again in {hours} h.").format(hours=hours) if hours else
                  _("Wallet unlocked (auto-lock is off)."))
            notify(_("Wallet unlocked for {hours} hours").format(hours=hours) if hours else _("Wallet unlocked"))
        else:
            print(_("Wallet still locked."), file=sys.stderr)
            sys.exit(1)


if __name__ == "__main__":
    main()
