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.
This commit is contained in:
1 parent
dc2a8fc883
commit
f7551006b3
3 files changed
+74
-1
No files matched your search
@@ -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
|
`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.
|
player, the CPU/MEM/GPU readout and the clock on the right.
|
||||||
- **rail** — network, bluetooth, audio, mic, backlight, battery, tray,
|
- **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
|
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
|
is *content inside* the hull, not a surface of its own — and marks hover with
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ PanelWindow {
|
|||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
spacing: 0
|
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 {
|
RailIcon {
|
||||||
id: netIcon
|
id: netIcon
|
||||||
icon: Net.icon
|
icon: Net.icon
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user