pragma Singleton // The login keyring's lock state. // // The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is // under GNOME. What a bare Hyprland session lacks is anywhere to see when that // has stopped being true. // // It stops being true rarely but expensively: gnome-keyring-daemon can crash, // D-Bus activates a replacement, and the replacement never received the login // password -- so the keyring is locked in the middle of a session that unlocked // it correctly. Nothing announces this. What the user sees instead is a mail // account that will not authenticate, a git push that cannot find its key, or // an integration reporting "not configured", none of which mention keyrings. // // Checked on demand and after an unlock, not polled: the state changes only // when a daemon dies or a password is entered. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring" property bool available: false property bool locked: false property bool scanned: false property bool unlocking: false property string lastError: "" // "pam" when the daemon that holds the keyring is the one PAM started at // login, "dbus" when it is a D-Bus-activated replacement -- which is the // signature of the crash case, and worth showing, because a dbus daemon // that is currently unlocked was unlocked by hand and will not survive. property string daemon: "" readonly property bool replacementDaemon: root.daemon === "dbus" // What is stored, never what is stored IN it. No property on this service // ever holds a password: the value is read by the helper, handed straight // to the clipboard, and forgotten. It does not cross into QML at all. property var collections: [] property bool listed: false property bool listing: false property bool working: false // Set for a moment after a successful copy, so the page can say what // happened without the page having to know how long a clipboard lasts. property string copiedPath: "" readonly property int storedCount: { let total = 0; for (const collection of root.collections) total += (collection.items ?? []).length; return total; } function refresh(): void { if (!query.running) query.running = true; } // Raises the standard password dialog. The password never passes through // Panama -- the Secret Service prompts, the same way it does under GNOME. function unlock(): void { if (root.unlocking) return; root.unlocking = true; unlockProcess.running = true; } // The stored-secret list. Separate from status() because it is the only // read that needs the keyring UNLOCKED, and because a settings page should // not enumerate someone's passwords just because it was opened. function list(): void { if (root.listing) return; root.listing = true; items.command = [root.helperPath, "items"]; items.running = true; } // Puts one stored secret on the clipboard. The value never reaches this // process; the helper reads it and writes it to wl-copy's stdin, and clears // it again shortly afterwards if it is still there. function copy(path: string): void { if (root.working) return; root.working = true; root.copiedPath = path; items.command = [root.helperPath, "copy", path]; items.running = true; } // Irreversible. The page confirms before calling this. function forget(path: string): void { if (root.working) return; root.working = true; root.copiedPath = ""; items.command = [root.helperPath, "forget", path]; items.running = true; } function absorbItems(text: string): void { try { const parsed = JSON.parse(text); root.collections = Array.isArray(parsed.collections) ? parsed.collections : []; root.lastError = String(parsed.error ?? ""); if (root.lastError !== "") root.copiedPath = ""; } catch (error) { root.collections = []; root.lastError = "Could not read the stored secrets."; console.warn("Keyring: could not parse item output:", error); } root.listed = true; } function absorb(text: string): void { try { const parsed = JSON.parse(text); root.available = parsed.available === true; root.locked = parsed.locked === true; root.daemon = String(parsed.daemon ?? ""); root.lastError = String(parsed.error ?? ""); } catch (error) { root.available = false; root.lastError = "Could not read the keyring helper's output."; console.warn("Keyring: could not parse helper output:", error); } root.scanned = true; } Process { id: query command: [root.helperPath, "status"] stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } } // One process for all three item operations: each of them answers with the // same list, so a page never has to ask again to find out what changed. Process { id: items stdout: StdioCollector { onStreamFinished: root.absorbItems(this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } onExited: { root.listing = false; root.working = false; } } // The clipboard notice is transient, and says so by disappearing. Timer { running: root.copiedPath !== "" interval: 12000 onTriggered: root.copiedPath = "" } Process { id: unlockProcess command: [root.helperPath, "unlock"] stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } onExited: root.unlocking = false } }