#!/usr/bin/env python3

"""The login keyring's lock state, and a way to unlock it.

Why this exists
---------------
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
and a bare Hyprland session does not, is anywhere to SEE that it failed.

It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
service_method_open_session, seen once here), and when it does, D-Bus activates
a replacement. That replacement never received the login password, so the login
keyring comes back LOCKED in the middle of a session that unlocked it correctly
at login. Everything that stores a secret then starts failing in ways that do
not mention keyrings at all: a mail client that will not authenticate, a git
push that cannot find its key, an integration that reports "not configured".

So this reports the state plainly and offers the one action that fixes it.

Unlocking prompts
-----------------
`unlock` asks the Secret Service to unlock, which raises the gcr password
dialog. That is deliberate: the password is not ours to store or handle, and it
never passes through this script. The dialog is the same one GNOME shows.

Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
is not hung, it is waiting for a dialog nobody is looking at. That is worth
knowing before debugging one for an hour.

Usage:
  panama-keyring status   -> {"available", "locked", "collections", "daemon"}
  panama-keyring unlock   -> raises the password prompt; prints the new state
"""

import json
import os
import re
import sys


def secrets_name_owner_pid():
    """PID currently owning the org.freedesktop.secrets D-Bus name, if any.

    This is the only reliable way to identify which daemon actually answers
    Secret Service calls right now.
    """
    try:
        import gi

        gi.require_version("Gio", "2.0")
        from gi.repository import Gio, GLib

        bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        result = bus.call_sync(
            "org.freedesktop.DBus",
            "/org/freedesktop/DBus",
            "org.freedesktop.DBus",
            "GetConnectionUnixProcessID",
            GLib.Variant("(s)", ("org.freedesktop.secrets",)),
            GLib.VariantType("(u)"),
            Gio.DBusCallFlags.NONE,
            -1,
            None,
        )
        return result.unpack()[0]
    except Exception:  # noqa: BLE001 - no name owner is a legitimate state
        return None


def any_keyring_daemon_running():
    try:
        for pid in os.listdir("/proc"):
            if not pid.isdigit():
                continue
            try:
                with open(f"/proc/{pid}/cmdline", "rb") as handle:
                    cmdline = handle.read().decode("utf-8", "replace")
            except OSError:
                continue
            if "gnome-keyring-daemon" in cmdline:
                return True
    except OSError:
        pass
    return False


def daemon_origin():
    """Whether the running secrets daemon came from PAM or from D-Bus activation.

    A D-Bus-activated daemon is the signature of the crash-and-replace case
    above: it is the one that cannot have the login password. PAM's daemon lives
    outside the app slice, so the cgroup tells the two apart.

    A machine can have two gnome-keyring-daemon processes at once -- a
    lingering PAM one alongside its D-Bus-activated replacement -- so which
    process this reports on matters: it must be the one that actually owns
    org.freedesktop.secrets right now, not merely the first one /proc happens
    to enumerate.
    """
    owner_pid = secrets_name_owner_pid()
    if owner_pid is None:
        return "unknown" if any_keyring_daemon_running() else "none"

    try:
        with open(f"/proc/{owner_pid}/cmdline", "rb") as handle:
            cmdline = handle.read().decode("utf-8", "replace")
    except OSError:
        return "unknown"
    if "gnome-keyring-daemon" not in cmdline:
        return "unknown"

    try:
        with open(f"/proc/{owner_pid}/cgroup", "r") as handle:
            cgroup = handle.read()
    except OSError:
        return "unknown"
    if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
        return "dbus"
    return "pam"


def load_service():
    import gi

    gi.require_version("Secret", "1")
    from gi.repository import Secret

    return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)


def report(service, Secret):
    collections = [
        {"label": c.get_label(), "locked": c.get_locked()}
        for c in service.get_collections()
    ]
    # The login keyring is the one that matters; the others are per-application
    # stores that manage their own unlocking.
    login = next((c for c in collections if c["label"] == "Login"), None)
    return {
        "available": True,
        "locked": bool(login["locked"]) if login else False,
        "hasLogin": login is not None,
        "collections": collections,
        "daemon": daemon_origin(),
        "error": "",
    }


def main():
    action = sys.argv[1] if len(sys.argv) > 1 else "status"
    if action not in ("status", "unlock"):
        print("usage: panama-keyring [status|unlock]", file=sys.stderr)
        return 2

    try:
        Secret, service = load_service()
    except Exception as error:  # noqa: BLE001 - any failure here is "no keyring"
        # No Secret Service at all is a legitimate state, not a crash: report it
        # so the UI can say so instead of showing an empty card.
        print(json.dumps({
            "available": False, "locked": False, "hasLogin": False,
            "collections": [], "daemon": daemon_origin(),
            "error": f"The secret service is not answering: {error}",
        }))
        return 0

    if action == "unlock":
        login = next(
            (c for c in service.get_collections() if c.get_label() == "Login"), None)
        if login is not None and login.get_locked():
            try:
                # Blocks until the dialog is answered or dismissed.
                service.unlock_sync([login], None)
            except Exception as error:  # noqa: BLE001
                state = report(service, Secret)
                state["error"] = f"The keyring was not unlocked: {error}"
                print(json.dumps(state))
                return 0
            # The collection object caches its state; re-read it.
            Secret, service = load_service()

    print(json.dumps(report(service, Secret)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
