Files
nvrl f7551006b3 feat(quickshell): privacy indicator at the head of the rail
A lit icon tops the rail while another program records from a
microphone, uses a camera, or shares the screen. It is read from
PipeWire: capture streams for the mic, links out of video sources for
the camera and screencasts. The tooltip names the recording apps.
2026-10-04 23:31:39 +02:00

59 lines
2.3 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
// Whether anything is listening or watching: an app recording from a
// microphone, a camera in use, or the screen being shared. All three show up
// in PipeWire, so this needs no polling — a capture stream exists only while
// something records, and a video source has links only while something
// consumes it.
Singleton {
id: root
// Capture streams from other programs. The shell's own level meters are
// capture streams too, and do not count.
readonly property var micUsers: Pipewire.nodes.values.filter(n =>
n.properties?.["media.class"] === "Stream/Input/Audio"
&& !root._isShell(n))
readonly property var videoLinks: Pipewire.links.values.filter(l =>
String(l.source?.properties?.["media.class"] ?? "").startsWith("Video/"))
// xdg-desktop-portal-hyprland names its screencast nodes "xdph-…".
readonly property bool screen: root.videoLinks.some(l => root._isScreencast(l.source))
readonly property bool camera: root.videoLinks.some(l => !root._isScreencast(l.source))
readonly property bool mic: root.micUsers.length > 0
readonly property bool active: root.mic || root.camera || root.screen
// Who is doing it, for the tooltip.
readonly property string detail: {
const parts = [];
if (root.screen) parts.push("Screen shared");
if (root.camera) parts.push("Camera on");
if (root.mic) {
const apps = [...new Set(root.micUsers.map(n =>
n.properties?.["application.name"] || n.name || "an app"))];
parts.push("Mic: " + apps.join(", "));
}
return parts.join(" · ");
}
function _isShell(n): bool {
const who = (String(n.properties?.["application.process.binary"] ?? "") + " "
+ String(n.properties?.["application.name"] ?? "")).toLowerCase();
return who.includes("quickshell") || /(^|\s)qs(\s|$)/.test(who);
}
function _isScreencast(n): bool {
const name = String(n?.name ?? n?.properties?.["node.name"] ?? "");
return name.startsWith("xdph");
}
// Node properties fill in only once a node is bound.
PwObjectTracker {
objects: Pipewire.nodes.values.filter(n => n.isStream || !n.isSink)
}
}