From f7551006b3a153c5ec3d3c46c8015c4039febcfb Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 4 Oct 2026 23:31:39 +0200 Subject: [PATCH] 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. --- quickshell/README.md | 4 ++- quickshell/bar/Rail.qml | 13 ++++++++ quickshell/services/Privacy.qml | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 quickshell/services/Privacy.qml diff --git a/quickshell/README.md b/quickshell/README.md index 32655af..1f339ee 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -43,7 +43,9 @@ cost 4.4% of the height on top of what the bar already takes. `Super+S` scratchpad holds windows, filled while it is open); the focused window, the player, the CPU/MEM/GPU readout and the clock on the right. - **rail** — network, bluetooth, audio, mic, backlight, battery, tray, - notifications, power. Popouts grow leftward out of it, into the screen. + notifications, power. While something records the mic, uses a camera or + shares the screen, a lit icon heads the rail (`services/Privacy.qml`, read + from PipeWire). Popouts grow leftward out of it, into the screen. Modules are divided by hairlines. `BarPill` is chromeless by default — a module is *content inside* the hull, not a surface of its own — and marks hover with diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml index ba2fd08..c9e9429 100644 --- a/quickshell/bar/Rail.qml +++ b/quickshell/bar/Rail.qml @@ -47,6 +47,19 @@ PanelWindow { anchors.horizontalCenter: parent.horizontalCenter spacing: 0 + // Something is recording or watching. First on the rail and in the + // light, because it is the one thing here you did not ask to see. + RailIcon { + id: privacyIcon + visible: Privacy.active + icon: Privacy.screen ? "󰹑" : (Privacy.camera ? "󰄀" : "󰍬") + label: "In use" + statusText: Privacy.detail + live: true + } + + Divider { visible: Privacy.active } + RailIcon { id: netIcon icon: Net.icon diff --git a/quickshell/services/Privacy.qml b/quickshell/services/Privacy.qml new file mode 100644 index 0000000..b592f7f --- /dev/null +++ b/quickshell/services/Privacy.qml @@ -0,0 +1,58 @@ +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) + } +}