aurora8be0991
aurora: privacy indicators for mic, camera and screen sharing
+123 −1
5 files changed
- M .config/quickshell/aurora/Bar.qml +24 −0
- A .config/quickshell/aurora/Privacy.qml +91 −0
- M .config/quickshell/aurora/SetShell.qml +2 −0
- M .config/quickshell/aurora/Settings.qml +4 −1
- M .config/quickshell/aurora/Theme.qml +2 −0
| @@ -104,6 +104,30 @@ PanelWindow { | |||
| 104 | 104 | onActivated: Rec.stop() | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 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 | + | ||
| 107 | 131 | BarPill { | |
| 108 | 132 | visible: Toggles.caffeine || Toggles.airplane | |
| 109 | 133 | icon: (Toggles.caffeine ? Theme.iCoffee : "") + (Toggles.caffeine && Toggles.airplane ? " " : "") | |
| @@ -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 | + | } | |
| @@ -49,6 +49,8 @@ Flickable { | |||
| 49 | 49 | SectionLabel { text: "network" } | |
| 50 | 50 | 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 } | |
| 51 | 51 | ||
| 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 | + | ||
| 52 | 54 | SectionLabel { text: "notifications" } | |
| 53 | 55 | Switch { label: "do not disturb"; hint: "hide popups and mute the sound (critical ones still show)"; checked: Notifs.dnd; onToggled: on => Notifs.dnd = on } | |
| 54 | 56 | Switch { label: "sound"; checked: Settings.notifSound; onToggled: on => Settings.notifSound = on } | |
| @@ -16,7 +16,8 @@ Singleton { | |||
| 16 | 16 | dock: true, dockPinned: ["foot", "librewolf", "pcmanfm", "obsidian"], wallFolder: "dusky-dark", | |
| 17 | 17 | notifSound: true, notifVolume: 0.7, | |
| 18 | 18 | 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 | |
| 20 | 21 | }) | |
| 21 | 22 | ||
| 22 | 23 | property alias fontSize: a.fontSize | |
| @@ -44,6 +45,7 @@ Singleton { | |||
| 44 | 45 | property alias batLow: a.batLow | |
| 45 | 46 | property alias batCritical: a.batCritical | |
| 46 | 47 | property alias batCriticalAction: a.batCriticalAction | |
| 48 | + | property alias privacyIndicators: a.privacyIndicators | |
| 47 | 49 | ||
| 48 | 50 | function reset() { | |
| 49 | 51 | for (var k in defaults) a[k] = defaults[k] | |
| @@ -93,6 +95,7 @@ Singleton { | |||
| 93 | 95 | property int batLow: 15 // low battery warning, % | |
| 94 | 96 | property int batCritical: 5 | |
| 95 | 97 | property string batCriticalAction: "none" // none | suspend | poweroff | |
| 98 | + | property bool privacyIndicators: true // mic/camera/screen-share icons in the bar (Privacy.qml) | |
| 96 | 99 | } | |
| 97 | 100 | } | |
| 98 | 101 | // first run: create the file once the directory exists | |
| @@ -159,6 +159,8 @@ Singleton { | |||
| 159 | 159 | ||
| 160 | 160 | readonly property string iHeadset: cp(0xF02CE) | |
| 161 | 161 | readonly property string iCamera: cp(0xF0100) | |
| 162 | + | readonly property string iWebcam: cp(0xF05A0) | |
| 163 | + | readonly property string iCast: cp(0xF0118) | |
| 162 | 164 | readonly property string iCrop: cp(0xF019E) | |
| 163 | 165 | readonly property string iWindow: cp(0xF05B0) | |
| 164 | 166 | readonly property string iMonitor: cp(0xF0379) | |