| 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 | } |