Krolyxon
Commit 38c75f1

Branches

Refs →

aurora38c75f1

aurora: native polkit prompt and bluetooth pairing agent

Author
krolyxon
Date
2026-10-25 15:15 +0530
Parent
0e3807e
Commit
38c75f172b5a094679c1918a9bf2903394911838

+453 −3

7 files changed

  1. M .config/hypr/hyprland.lua +1 −1
  2. A .config/quickshell/aurora/Agents.qml +71 −0
  3. A .config/quickshell/aurora/AuthPrompt.qml +231 −0
  4. A .config/quickshell/aurora/btagent.py +145 −0
  5. M .config/quickshell/aurora/shell.qml +3 −0
  6. M packages/desktop.txt +2 −0
  7. M packages/utils.txt +0 −2
M .config/hypr/hyprland.lua View
@@ -27,7 +27,7 @@ hl.on("hyprland.start", function()
2727 hl.exec_cmd("hypridle")
2828 -- hl.exec_cmd("blueman-applet")
2929 hl.exec_cmd("hyprpaper") -- wallpaper is set by aurora (Wall.qml), it reads hyprpaper.conf
30− hl.exec_cmd("systemctl --user start hyprpolkitagent")
30+ -- polkit agent: aurora (Agents.qml); it starts hyprpolkitagent itself if it can't register
3131 end)
3232
3333 --------------------------------------------------
A .config/quickshell/aurora/Agents.qml View
@@ -0,0 +1,71 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+import Quickshell.Services.Polkit
6+
7+// Authentication agents, shown by AuthPrompt.
8+// polkit Quickshell's native PolkitAgent (replaced hyprpolkitagent). Polkit allows one agent per
9+// session: if registering fails (another agent running), hyprpolkitagent is started as a fallback.
10+// bluez btagent.py (python-gobject), the default pairing agent: passkey confirmation, PIN/passkey
11+// entry and display. JSON lines over its stdout/stdin, see the script. Restarted with backoff.
12+Singleton {
13+ id: root
14+
15+ readonly property var flow: agent.flow
16+ readonly property bool polkitActive: agent.isActive && agent.flow !== null
17+ readonly property bool registered: agent.isRegistered
18+
19+ PolkitAgent { id: agent }
20+
21+ // ---- bluetooth: the request being shown, {id, method, alias, passkey|pincode}; id 0 = display only
22+ property var bt: null
23+ readonly property bool btActive: bt !== null
24+ property bool btRegistered: false
25+ function btReply(ok, value) {
26+ if (bt && bt.id) btProc.write(JSON.stringify({ id: bt.id, ok: ok, value: value || "" }) + "\n")
27+ bt = null
28+ }
29+ function btLine(l) {
30+ var m
31+ try { m = JSON.parse(l) } catch (e) { return }
32+ if (m.event === "registered") { btRegistered = true; backoff = 2000 }
33+ else if (m.event === "cancel") bt = null
34+ else if (m.event === "error") console.warn("btagent:", m.msg)
35+ else if (m.method) {
36+ if (bt && bt.id) btReply(false) // a new request replaces an unanswered one
37+ bt = m
38+ if (!m.id) displayTimeout.restart()
39+ }
40+ }
41+ property int backoff: 2000
42+ Process {
43+ id: btProc
44+ command: ["python3", "-I", Quickshell.shellPath("btagent.py")]
45+ running: true
46+ stdinEnabled: true
47+ stdout: SplitParser { onRead: l => root.btLine(l) }
48+ stderr: SplitParser { onRead: l => console.warn("btagent:", l) }
49+ onExited: {
50+ root.bt = null
51+ root.btRegistered = false
52+ restartBt.interval = root.backoff
53+ root.backoff = Math.min(root.backoff * 2, 60000)
54+ restartBt.start()
55+ }
56+ }
57+ Timer { id: restartBt; onTriggered: btProc.running = true }
58+ // a shown passkey/PIN has no answer: drop it after a while (pairing is done or failed by then)
59+ Timer { id: displayTimeout; interval: 30000; onTriggered: if (root.bt && !root.bt.id) root.bt = null }
60+
61+ Timer {
62+ interval: 5000
63+ running: true
64+ onTriggered: {
65+ if (agent.isRegistered) return
66+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "dialog-password", "polkit agent",
67+ "aurora couldn't register as the polkit agent, starting hyprpolkitagent instead"])
68+ Quickshell.execDetached(["systemctl", "--user", "start", "hyprpolkitagent"])
69+ }
70+ }
71+}
A .config/quickshell/aurora/AuthPrompt.qml View
@@ -0,0 +1,231 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell
4+import Quickshell.Wayland
5+
6+// Prompt for the auth agents (Agents): a polkit password (Agents.flow) or a bluetooth pairing request
7+// (Agents.bt: confirm a passkey, type a PIN/passkey, or show one to type on the device). Polkit wins
8+// if both are pending. Overlay layer with exclusive keyboard on the focused monitor; Enter accepts,
9+// Esc cancels. Typed text is cleared after every attempt, never logged.
10+PanelWindow {
11+ id: win
12+ readonly property var flow: Agents.flow
13+ readonly property var bt: Agents.bt
14+ readonly property string mode: Agents.polkitActive ? "polkit" : (Agents.btActive ? "bt" : "")
15+ readonly property bool opened: mode !== ""
16+ readonly property string btMethod: bt ? bt.method : ""
17+ // bluetooth: what the field / big code shows
18+ readonly property bool btInput: btMethod === "RequestPinCode" || btMethod === "RequestPasskey"
19+ readonly property string btCode: !bt ? "" : (bt.passkey ? bt.passkey.slice(0, 3) + " " + bt.passkey.slice(3) : (bt.pincode || ""))
20+ readonly property bool btDisplay: btMethod === "DisplayPasskey" || btMethod === "DisplayPinCode"
21+ readonly property bool needsInput: mode === "polkit" || btInput
22+
23+ visible: opened
24+ implicitWidth: 420
25+ implicitHeight: col.implicitHeight + 32
26+ exclusiveZone: 0
27+ color: "transparent"
28+ WlrLayershell.layer: WlrLayer.Overlay
29+ WlrLayershell.namespace: "aurora-auth"
30+ WlrLayershell.keyboardFocus: opened ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
31+
32+ property bool waiting: false // submitted, polkit is checking
33+ property string error: "" // Quickshell doesn't put failures in supplementaryMessage
34+ function reset() { pass.text = ""; waiting = false; if (needsInput) pass.forceActiveFocus(); else keys.forceActiveFocus() }
35+ function accept() {
36+ if (mode === "polkit") {
37+ if (!flow || !flow.isResponseRequired || waiting) return
38+ waiting = true
39+ error = ""
40+ flow.submit(pass.text)
41+ pass.text = ""
42+ } else if (mode === "bt") {
43+ if (btInput && pass.text === "") return
44+ Agents.btReply(true, pass.text)
45+ pass.text = ""
46+ }
47+ }
48+ function cancel() {
49+ if (mode === "polkit") { if (flow) flow.cancelAuthenticationRequest() }
50+ else if (mode === "bt") Agents.btReply(false)
51+ }
52+
53+ onModeChanged: if (opened) { error = ""; reset() }
54+ onBtChanged: if (bt) reset()
55+ Connections {
56+ target: win.flow
57+ function onIsResponseRequiredChanged() { if (win.flow.isResponseRequired) win.reset() }
58+ // polkit asks again right after a failure (isResponseRequired goes true with failed set)
59+ function onAuthenticationFailed() { win.error = "wrong password, try again"; win.reset() }
60+ }
61+
62+ // keyboard for the button-only bluetooth prompts
63+ Item {
64+ id: keys
65+ Keys.onReturnPressed: win.accept()
66+ Keys.onEnterPressed: win.accept()
67+ Keys.onEscapePressed: win.cancel()
68+ }
69+
70+ Rectangle {
71+ anchors.fill: parent
72+ color: Theme.bgA
73+ border.width: 2
74+ border.color: win.error !== "" || (win.mode === "polkit" && win.flow && win.flow.supplementaryIsError) ? Theme.err : Theme.accent
75+
76+ ColumnLayout {
77+ id: col
78+ anchors.left: parent.left
79+ anchors.right: parent.right
80+ anchors.top: parent.top
81+ anchors.margins: 16
82+ spacing: 10
83+
84+ RowLayout {
85+ Layout.fillWidth: true
86+ spacing: 10
87+ Text {
88+ text: win.mode === "bt" ? Theme.iBt : Theme.iLock
89+ color: Theme.accent
90+ font.family: Theme.font
91+ font.pixelSize: Theme.fontSize + 8
92+ }
93+ SectionLabel {
94+ Layout.fillWidth: true
95+ text: win.mode === "bt" ? "bluetooth pairing" : "authentication required"
96+ }
97+ }
98+ Text {
99+ Layout.fillWidth: true
100+ color: Theme.fg
101+ wrapMode: Text.Wrap
102+ font.family: Theme.font
103+ font.pixelSize: Theme.fontSize
104+ text: {
105+ if (win.mode === "polkit") return win.flow ? win.flow.message : ""
106+ if (!win.bt) return ""
107+ var a = win.bt.alias
108+ switch (win.btMethod) {
109+ case "RequestConfirmation": return "pair with " + a + "? check that it shows the same code:"
110+ case "RequestPinCode": return "enter the PIN for " + a + " (often 0000 or 1234)"
111+ case "RequestPasskey": return "enter the 6-digit passkey shown on " + a
112+ case "DisplayPasskey":
113+ case "DisplayPinCode": return "type this code on " + a + ", then press Enter there:"
114+ case "RequestAuthorization": return "allow " + a + " to pair with this computer?"
115+ case "AuthorizeService": return "allow " + a + " to use a service?"
116+ }
117+ return a
118+ }
119+ }
120+ Text {
121+ Layout.fillWidth: true
122+ visible: text !== ""
123+ text: win.mode === "polkit" ? (win.flow ? win.flow.actionId : "")
124+ : (win.btMethod === "AuthorizeService" ? win.bt.uuid : "")
125+ color: Theme.dim
126+ elide: Text.ElideMiddle
127+ font.family: Theme.font
128+ font.pixelSize: Theme.fontSize - 2
129+ }
130+
131+ // bluetooth passkey / PIN to compare or type on the device
132+ Text {
133+ Layout.fillWidth: true
134+ visible: win.mode === "bt" && win.btCode !== "" && !win.btInput
135+ horizontalAlignment: Text.AlignHCenter
136+ text: win.btCode
137+ color: Theme.accent
138+ font.family: Theme.font
139+ font.pixelSize: Theme.fontSize * 3
140+ font.bold: true
141+ }
142+
143+ // more than one admin: pick who authenticates
144+ Flow {
145+ Layout.fillWidth: true
146+ visible: win.mode === "polkit" && win.flow !== null && win.flow.identities.length > 1
147+ spacing: 4
148+ Repeater {
149+ model: win.mode === "polkit" && win.flow ? win.flow.identities : []
150+ delegate: IconBtn {
151+ required property var modelData
152+ implicitHeight: 24
153+ label: modelData.displayName || String(modelData)
154+ active: win.flow && win.flow.selectedIdentity === modelData
155+ onClicked: win.flow.selectedIdentity = modelData
156+ }
157+ }
158+ }
159+
160+ Rectangle {
161+ Layout.fillWidth: true
162+ visible: win.needsInput
163+ implicitHeight: 32
164+ color: Theme.surface
165+ border.width: 1
166+ border.color: pass.activeFocus ? Theme.accent : Theme.line
167+ Text {
168+ id: promptTxt
169+ anchors.left: parent.left
170+ anchors.leftMargin: 10
171+ anchors.verticalCenter: parent.verticalCenter
172+ text: win.mode === "bt" ? (win.btMethod === "RequestPinCode" ? "PIN" : "passkey")
173+ : (win.flow && win.flow.inputPrompt !== "" ? win.flow.inputPrompt.replace(/:\s*$/, "") : "password")
174+ color: Theme.dim
175+ font.family: Theme.font
176+ font.pixelSize: Theme.fontSize
177+ }
178+ TextInput {
179+ id: pass
180+ anchors.fill: parent
181+ anchors.leftMargin: promptTxt.implicitWidth + 22
182+ anchors.rightMargin: 10
183+ verticalAlignment: TextInput.AlignVCenter
184+ echoMode: win.mode === "bt" || (win.flow && win.flow.responseVisible) ? TextInput.Normal : TextInput.Password
185+ passwordCharacter: "•"
186+ maximumLength: win.btMethod === "RequestPasskey" ? 6 : 256
187+ validator: RegularExpressionValidator {
188+ regularExpression: win.btMethod === "RequestPasskey" ? /[0-9]*/ : /.*/
189+ }
190+ enabled: !win.waiting
191+ clip: true
192+ color: Theme.fg
193+ selectionColor: Theme.accent
194+ selectedTextColor: Theme.bg
195+ font.family: Theme.font
196+ font.pixelSize: Theme.fontSize
197+ Keys.onReturnPressed: win.accept()
198+ Keys.onEnterPressed: win.accept()
199+ Keys.onEscapePressed: win.cancel()
200+ }
201+ }
202+ Text {
203+ Layout.fillWidth: true
204+ visible: win.mode === "polkit" && text !== ""
205+ text: win.waiting ? "checking…" : (win.error !== "" ? win.error : (win.flow ? win.flow.supplementaryMessage : ""))
206+ color: !win.waiting && (win.error !== "" || (win.flow && win.flow.supplementaryIsError)) ? Theme.err : Theme.dim
207+ wrapMode: Text.Wrap
208+ font.family: Theme.font
209+ font.pixelSize: Theme.fontSize - 1
210+ }
211+
212+ RowLayout {
213+ Layout.fillWidth: true
214+ spacing: 6
215+ Item { Layout.fillWidth: true }
216+ IconBtn {
217+ visible: !win.btDisplay
218+ label: win.mode === "bt" ? "reject" : "cancel"
219+ implicitHeight: 26
220+ onClicked: win.cancel()
221+ }
222+ IconBtn {
223+ label: win.btDisplay ? "close" : (win.mode === "bt" ? (win.btInput ? "pair" : "allow") : "authenticate")
224+ implicitHeight: 26
225+ active: true
226+ onClicked: win.accept()
227+ }
228+ }
229+ }
230+ }
231+}
A .config/quickshell/aurora/btagent.py View
@@ -0,0 +1,145 @@
1+#!/usr/bin/env python3
2+"""BlueZ pairing agent for aurora (Agents.qml runs it; Quickshell's Bluetooth module has none).
3+
4+Exports org.bluez.Agent1 at /aurora/agent (capability KeyboardDisplay) and makes it the default
5+agent, again whenever bluetoothd restarts. Talks JSON lines:
6+ stdout {"id": n, "method": "RequestConfirmation", "device": path, "alias": name, "passkey": "012345"}
7+ id 0 = display only (DisplayPasskey/DisplayPinCode), no reply expected
8+ {"event": "registered" | "cancel" | "error", "msg": ...}
9+ stdin {"id": n, "ok": true|false, "value": "1234"} (value for RequestPinCode/RequestPasskey)
10+Python stdlib + python-gobject only.
11+"""
12+import json
13+import sys
14+
15+from gi.repository import Gio, GLib
16+
17+PATH = "/aurora/agent"
18+XML = """<node><interface name="org.bluez.Agent1">
19+<method name="Release"/>
20+<method name="RequestPinCode"><arg type="o" direction="in"/><arg type="s" direction="out"/></method>
21+<method name="DisplayPinCode"><arg type="o" direction="in"/><arg type="s" direction="in"/></method>
22+<method name="RequestPasskey"><arg type="o" direction="in"/><arg type="u" direction="out"/></method>
23+<method name="DisplayPasskey"><arg type="o" direction="in"/><arg type="u" direction="in"/><arg type="q" direction="in"/></method>
24+<method name="RequestConfirmation"><arg type="o" direction="in"/><arg type="u" direction="in"/></method>
25+<method name="RequestAuthorization"><arg type="o" direction="in"/></method>
26+<method name="AuthorizeService"><arg type="o" direction="in"/><arg type="s" direction="in"/></method>
27+<method name="Cancel"/>
28+</interface></node>"""
29+
30+bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
31+loop = GLib.MainLoop()
32+pending = {} # id -> (invocation, method)
33+last_id = 0
34+
35+
36+def emit(**kw):
37+ print(json.dumps(kw), flush=True)
38+
39+
40+def dev_prop(path, name):
41+ try:
42+ v = bus.call_sync("org.bluez", path, "org.freedesktop.DBus.Properties", "Get",
43+ GLib.Variant("(ss)", ("org.bluez.Device1", name)), GLib.VariantType("(v)"),
44+ Gio.DBusCallFlags.NONE, 2000, None)
45+ return v.unpack()[0]
46+ except GLib.Error:
47+ return None
48+
49+
50+def alias(path):
51+ return dev_prop(path, "Alias") or path.rsplit("/", 1)[-1]
52+
53+
54+def ask(inv, method, dev, **extra):
55+ global last_id
56+ last_id += 1
57+ pending[last_id] = (inv, method)
58+ emit(id=last_id, method=method, device=dev, alias=alias(dev), **extra)
59+
60+
61+def on_call(conn, sender, path, iface, method, params, inv):
62+ a = params.unpack()
63+ if method == "Release":
64+ inv.return_value(None)
65+ elif method == "Cancel":
66+ for i, _ in pending.values():
67+ i.return_dbus_error("org.bluez.Error.Canceled", "cancelled")
68+ pending.clear()
69+ inv.return_value(None)
70+ emit(event="cancel")
71+ elif method == "DisplayPinCode":
72+ inv.return_value(None)
73+ emit(id=0, method=method, device=a[0], alias=alias(a[0]), pincode=a[1])
74+ elif method == "DisplayPasskey":
75+ inv.return_value(None)
76+ emit(id=0, method=method, device=a[0], alias=alias(a[0]), passkey="%06d" % a[1], entered=a[2])
77+ elif method == "AuthorizeService":
78+ # a device we already paired with / trust may use its services; anything else asks
79+ if dev_prop(a[0], "Paired") or dev_prop(a[0], "Trusted"):
80+ inv.return_value(None)
81+ else:
82+ ask(inv, method, a[0], uuid=a[1])
83+ elif method == "RequestConfirmation":
84+ ask(inv, method, a[0], passkey="%06d" % a[1])
85+ else: # RequestPinCode, RequestPasskey, RequestAuthorization
86+ ask(inv, method, a[0])
87+
88+
89+def on_stdin(ch, cond):
90+ if cond & (GLib.IO_HUP | GLib.IO_ERR):
91+ loop.quit()
92+ return False
93+ status, line, _, _ = ch.read_line()
94+ if status == GLib.IOStatus.EOF:
95+ loop.quit()
96+ return False
97+ try:
98+ r = json.loads(line)
99+ except (TypeError, ValueError):
100+ return True
101+ item = pending.pop(r.get("id"), None)
102+ if not item:
103+ return True
104+ inv, method = item
105+ if not r.get("ok"):
106+ inv.return_dbus_error("org.bluez.Error.Rejected", "rejected")
107+ return True
108+ v = r.get("value", "")
109+ try:
110+ if method == "RequestPinCode":
111+ inv.return_value(GLib.Variant("(s)", (str(v),)))
112+ elif method == "RequestPasskey":
113+ n = int(v)
114+ if not 0 <= n <= 999999:
115+ raise ValueError
116+ inv.return_value(GLib.Variant("(u)", (n,)))
117+ else:
118+ inv.return_value(None)
119+ except ValueError:
120+ inv.return_dbus_error("org.bluez.Error.Rejected", "bad value")
121+ return True
122+
123+
124+def register(*_):
125+ try:
126+ for m, args in (("RegisterAgent", GLib.Variant("(os)", (PATH, "KeyboardDisplay"))),
127+ ("RequestDefaultAgent", GLib.Variant("(o)", (PATH,)))):
128+ bus.call_sync("org.bluez", "/org/bluez", "org.bluez.AgentManager1", m, args, None,
129+ Gio.DBusCallFlags.NONE, 5000, None)
130+ emit(event="registered")
131+ except GLib.Error as e:
132+ emit(event="error", msg=e.message)
133+
134+
135+def main():
136+ node = Gio.DBusNodeInfo.new_for_xml(XML)
137+ bus.register_object_with_closures2(PATH, node.interfaces[0], on_call, None, None)
138+ Gio.bus_watch_name_on_connection(bus, "org.bluez", Gio.BusNameWatcherFlags.NONE, register, None)
139+ ch = GLib.IOChannel.unix_new(sys.stdin.fileno())
140+ GLib.io_add_watch(ch, GLib.PRIORITY_DEFAULT, GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR, on_stdin)
141+ loop.run()
142+
143+
144+if __name__ == "__main__":
145+ main()
M .config/quickshell/aurora/shell.qml View
@@ -148,6 +148,9 @@ ShellRoot {
148148 onCloseRequested: root.closeOverlay()
149149 }
150150
151+ // polkit password prompt (Agents), on the focused monitor
152+ AuthPrompt { screen: root.focusedScr }
153+
151154 PowerMenu {
152155 screen: root.overlayScreen
153156 opened: root.overlay === "power"
M packages/desktop.txt View
@@ -24,6 +24,8 @@ wl-clipboard
2424 cliphist
2525 playerctl
2626 gnome-keyring
27+libnotify
28+python-gobject
2729
2830 # Audio
2931 pipewire
M packages/utils.txt View
@@ -79,7 +79,6 @@ qpdf
7979 # Desktop bits
8080 alacritty
8181 gammastep
82−libnotify
8382 wtype
8483 nwg-look
8584 fastfetch
@@ -92,7 +91,6 @@ cmake
9291 meson
9392 lua
9493 bc
95−python-gobject
9694 gobject-introspection
9795 python-pip
9896 python-virtualenv