Krolyxon
Commit 8be0991

Branches

Refs →

aurora8be0991

aurora: privacy indicators for mic, camera and screen sharing

Author
krolyxon
Date
2026-10-25 15:15 +0530
Parent
a9ecc13
Commit
8be0991e07a487c8b69e35d740d9ff78215f53bc

+123 −1

5 files changed

  1. M .config/quickshell/aurora/Bar.qml +24 −0
  2. A .config/quickshell/aurora/Privacy.qml +91 −0
  3. M .config/quickshell/aurora/SetShell.qml +2 −0
  4. M .config/quickshell/aurora/Settings.qml +4 −1
  5. M .config/quickshell/aurora/Theme.qml +2 −0
M .config/quickshell/aurora/Bar.qml View
@@ -104,6 +104,30 @@ PanelWindow {
104104 onActivated: Rec.stop()
105105 }
106106
107+ // privacy: global, so only on the focused monitor's bar. Click the mic one to mute the mic.
108+ BarPill {
109+ visible: bar.monFocused && Privacy.mic
110+ icon: bar.mic && bar.mic.muted ? Theme.iMicOff : Theme.iMic
111+ text: Privacy.micApps.join(", ")
112+ tint: bar.mic && bar.mic.muted ? Theme.dim : Theme.err
113+ underline: Theme.err
114+ onActivated: if (bar.mic) bar.mic.muted = !bar.mic.muted
115+ }
116+ BarPill {
117+ visible: bar.monFocused && Privacy.cam
118+ icon: Theme.iWebcam
119+ text: Privacy.camApps.join(", ")
120+ tint: Theme.err
121+ underline: Theme.err
122+ }
123+ BarPill {
124+ visible: bar.monFocused && Privacy.screen
125+ icon: Theme.iCast
126+ text: Privacy.shareApps.join(", ")
127+ tint: Theme.err
128+ underline: Theme.err
129+ }
130+
107131 BarPill {
108132 visible: Toggles.caffeine || Toggles.airplane
109133 icon: (Toggles.caffeine ? Theme.iCoffee : "") + (Toggles.caffeine && Toggles.airplane ? " " : "")
A .config/quickshell/aurora/Privacy.qml View
@@ -0,0 +1,91 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+import Quickshell.Hyprland
6+import Quickshell.Services.Pipewire
7+
8+// Privacy indicators for the bar: who records the mic, uses the camera or shares the screen.
9+// mic Pipewire input streams linked to an audio source (not a sink monitor, so cava/visualizers don't count)
10+// camera processes holding /dev/video* open (mpv, browsers without the camera portal) + Pipewire
11+// video streams fed by a v4l2/libcamera node
12+// screen Hyprland's `screencast` event (xdg-desktop-portal-hyprland) + the apps consuming its video node.
13+// wf-recorder (Rec) uses screencopy instead and has its own bar pill.
14+Singleton {
15+ id: root
16+ readonly property bool enabled: Settings.privacyIndicators
17+ readonly property var ignore: ["quickshell", "qs", "cava"]
18+
19+ function appName(n) {
20+ var p = n.properties
21+ return p["application.name"] || p["application.process.binary"] || n.name
22+ }
23+ // apps whose `streamCls` streams are linked to a node of class `srcCls`*, filtered by keep(source)
24+ function captures(srcCls, streamCls, keep) {
25+ var out = [], l = Pipewire.linkGroups.values
26+ for (var i = 0; i < l.length; i++) {
27+ var s = l[i].source, t = l[i].target
28+ if (!s || !t) continue
29+ if ((s.properties["media.class"] || "").indexOf(srcCls) !== 0) continue
30+ if ((t.properties["media.class"] || "") !== streamCls) continue
31+ if (ignore.indexOf(t.properties["application.process.binary"] || "") >= 0) continue
32+ if (keep && !keep(s)) continue
33+ var a = appName(t)
34+ if (out.indexOf(a) < 0) out.push(a)
35+ }
36+ return out
37+ }
38+ function isCam(n) {
39+ var api = n.properties["device.api"] || ""
40+ return api === "v4l2" || api === "libcamera"
41+ }
42+
43+ readonly property var micApps: enabled ? captures("Audio/Source", "Stream/Input/Audio", null) : []
44+ readonly property var pwCamApps: enabled ? captures("Video/Source", "Stream/Input/Video", isCam) : []
45+ readonly property var shareApps: enabled ? captures("Video/Source", "Stream/Input/Video", n => !isCam(n)) : []
46+ property var devCamApps: [] // from the /proc scan
47+ property bool casting: false // Hyprland screencast event
48+
49+ readonly property var camApps: {
50+ var out = pwCamApps.slice()
51+ for (var i = 0; i < devCamApps.length; i++) if (out.indexOf(devCamApps[i]) < 0) out.push(devCamApps[i])
52+ return out
53+ }
54+ readonly property bool mic: micApps.length > 0
55+ readonly property bool cam: camApps.length > 0
56+ readonly property bool screen: enabled && (casting || shareApps.length > 0)
57+
58+ Connections {
59+ target: Hyprland
60+ function onRawEvent(ev) {
61+ if (ev.name === "screencast") root.casting = ev.data.split(",")[0] === "1"
62+ }
63+ }
64+
65+ // /dev/video* holders. Only our own processes are readable, which is every app that matters here.
66+ // pipewire/wireplumber holding it means a portal client: that one shows up in pwCamApps instead.
67+ Timer {
68+ interval: 3000
69+ running: root.enabled
70+ repeat: true
71+ triggeredOnStart: true
72+ onTriggered: if (!camProc.running) camProc.running = true
73+ }
74+ Process {
75+ id: camProc
76+ command: ["sh", "-c", "find /proc/[0-9]*/fd -maxdepth 1 -lname '/dev/video*' 2>/dev/null"
77+ + " | cut -d/ -f3 | sort -u | while read -r p; do cat \"/proc/$p/comm\" 2>/dev/null; done"]
78+ stdout: StdioCollector {
79+ onStreamFinished: {
80+ var out = [], l = text.split("\n")
81+ for (var i = 0; i < l.length; i++) {
82+ var c = l[i].trim()
83+ if (c === "" || c === "pipewire" || c === "wireplumber" || out.indexOf(c) >= 0) continue
84+ out.push(c)
85+ }
86+ if (out.join() !== root.devCamApps.join()) root.devCamApps = out
87+ }
88+ }
89+ }
90+ onEnabledChanged: if (!enabled) { devCamApps = []; casting = false }
91+}
M .config/quickshell/aurora/SetShell.qml View
@@ -49,6 +49,8 @@ Flickable {
4949 SectionLabel { text: "network" }
5050 Switch { label: "captive portals"; hint: "open the login page in the browser when a network needs one"; checked: Settings.portalAutoOpen; onToggled: on => Settings.portalAutoOpen = on }
5151
52+ Switch { label: "privacy indicators"; hint: "show in the bar when an app records the mic, uses the camera or shares the screen"; checked: Settings.privacyIndicators; onToggled: on => Settings.privacyIndicators = on }
53+
5254 SectionLabel { text: "notifications" }
5355 Switch { label: "do not disturb"; hint: "hide popups and mute the sound (critical ones still show)"; checked: Notifs.dnd; onToggled: on => Notifs.dnd = on }
5456 Switch { label: "sound"; checked: Settings.notifSound; onToggled: on => Settings.notifSound = on }
M .config/quickshell/aurora/Settings.qml View
@@ -16,7 +16,8 @@ Singleton {
1616 dock: true, dockPinned: ["foot", "librewolf", "pcmanfm", "obsidian"], wallFolder: "dusky-dark",
1717 notifSound: true, notifVolume: 0.7,
1818 idleDim: 150, idleOff: 330, idleLock: 600, idleSuspend: 0, charTone: 0, portalAutoOpen: true,
19− chargeMode: "off", batLow: 15, batCritical: 5, batCriticalAction: "none"
19+ chargeMode: "off", batLow: 15, batCritical: 5, batCriticalAction: "none",
20+ privacyIndicators: true
2021 })
2122
2223 property alias fontSize: a.fontSize
@@ -44,6 +45,7 @@ Singleton {
4445 property alias batLow: a.batLow
4546 property alias batCritical: a.batCritical
4647 property alias batCriticalAction: a.batCriticalAction
48+ property alias privacyIndicators: a.privacyIndicators
4749
4850 function reset() {
4951 for (var k in defaults) a[k] = defaults[k]
@@ -93,6 +95,7 @@ Singleton {
9395 property int batLow: 15 // low battery warning, %
9496 property int batCritical: 5
9597 property string batCriticalAction: "none" // none | suspend | poweroff
98+ property bool privacyIndicators: true // mic/camera/screen-share icons in the bar (Privacy.qml)
9699 }
97100 }
98101 // first run: create the file once the directory exists
M .config/quickshell/aurora/Theme.qml View
@@ -159,6 +159,8 @@ Singleton {
159159
160160 readonly property string iHeadset: cp(0xF02CE)
161161 readonly property string iCamera: cp(0xF0100)
162+ readonly property string iWebcam: cp(0xF05A0)
163+ readonly property string iCast: cp(0xF0118)
162164 readonly property string iCrop: cp(0xF019E)
163165 readonly property string iWindow: cp(0xF05B0)
164166 readonly property string iMonitor: cp(0xF0379)