fix(quickshell): repair privacy indicator detection

The indicator never became visible. Three causes, each measured with
throwaway probe configs against quickshell 0.3.0:

- Link groups report state Unlinked (-1) unless bound by a
  PwObjectTracker, so the "is the link active" guard rejected
  everything. Node properties need the same binding.
- PwNodeType flags share bits between in- and out-streams
  (AudioInStream 13, AudioOutStream 21), so the bitwise test also
  matched playback — browser audio alone set micActive.
- Video streams are left as Untracked with isStream false, so no
  screencast or camera could ever match.

Match media.class of active link endpoints instead, the signal waybar's
privacy module uses. Desktop-audio recording is excluded via the peer
class Audio/Sink, since quickshell does not expose stream.monitor.

Raw V4L2 camera use stays undetectable (no pipewire node exists);
rationale for not observing /dev/video* is in DECISIONS.md.
This commit is contained in:
2026-07-29 14:28:12 +02:00
parent fe1fa43a4d
commit 6b345258a4
3 changed files with 40 additions and 10 deletions
+2
View File
@@ -86,6 +86,8 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
- **Network indicator + VPN:** quickshell's Networking API exposes no VPN, so `NetworkService.qml` (singleton) holds the VPN connection list via nmcli — event-driven through a long-running `nmcli monitor` process (debounced refresh) plus a 60s fallback poll; shared by `NetworkIndicator` and `NetworkPopout`. The indicator shows connecting (cycling strength glyphs), an active VPN (MDI `wifi-strength-N-lock` variants; wired+VPN → `security-network`), and wired (`ethernet` glyph). **Wired wins over wifi** in both indicator and popout: when both a wired and a wifi connection are up, NM routes over ethernet (lower metric) and nm-applet shows the wired icon — so the widget checks `wiredDev` first (also beats wifi-off, so ethernet-only machines don't show a dead wifi icon).
- **Privacy indicator:** `PrivacyIndicator.qml` shows a mic glyph and a video glyph while something captures, both in `Theme.red`, and hides otherwise. Detection matches the `media.class` of the endpoints of **active** link groups (`Stream/Input/Audio` / `Stream/Input/Video`) — the same signal waybar's privacy module uses. quickshell's `PwNodeType` flags are unusable for this: they share bits between in- and out-streams (`AudioInStream = 13`, `AudioOutStream = 21`, so a bitwise test matches playback) and leave video streams as `Untracked`. A `PwObjectTracker` must bind **nodes and groups**: an unbound group reports `state = Unlinked (-1)` forever, an unbound node exposes no `properties`. Gating on `PwLinkState.Active` is what keeps the always-present but idle capture nodes (rnnoise, Focusrite mic splits) quiet. Desktop-audio recording is excluded via the peer's `media.class` (`Audio/Sink`), because pipewire's `stream.monitor` property is not exposed through quickshell. **Blind spot:** camera use through raw V4L2 (`/dev/video*` without the portal, e.g. Firefox without `media.webrtc.camera.allow-pipewire`) creates no pipewire node and is not detected — waybar has the same gap; a `/dev/video*` observer was rejected, see `DECISIONS.md` (2026-07-29).
- **Keyboard navigation:** an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (`Toggle`, `ListRow`, `VolumeSlider`, `IconButton`) set `activeFocusOnTab` and show a focus ring; `PopoutPanel` focuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (`nextItemInFocusChain` + an `activeFocusOnTab` filter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the shared `IconButton` (destructive ones set `activeColor: Theme.red`); Rectangle-shaped buttons (clear-all, notification actions) are made focusable inline.
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls; launcher widget + side-aware popout anchoring).
+7
View File
@@ -254,3 +254,10 @@
- **Why**: Qt apps (concretely VLC 3) ignored the system theme — they rendered in the default Fusion style while everything else is Catppuccin Mocha. moonarch themes GTK system-wide (Colloid) but had no Qt story. Diagnosis found the existing setup half-wired: `QT_QPA_PLATFORMTHEME=qt6ct` was exported but `qt6ct` was never installed, so the variable pointed at nothing.
- **Tradeoffs**: (1) **Approach** — Qt-follows-GTK (`QT_QPA_PLATFORMTHEME=gtk3` / qgnomeplatform) vs. a native Qt Catppuccin theme. GTK-follow was rejected: it fails on the reported app — VLC is Qt5, and the Qt5 GTK-style plugin (`qt5-styleplugins`) is unmaintained and absent from the official repos; GTK-follow is also unreliable on non-GNOME Wayland. Picked **Kvantum** — a maintained Catppuccin theme, best visual match, covers Qt5 + Qt6. (2) **Style wiring**`QT_QPA_PLATFORMTHEME` cannot theme both Qt versions at once (one value selects either the Qt5 `qt5ct` plugin or the Qt6 `qt6ct` plugin, never both). `QT_STYLE_OVERRIDE=kvantum` is honoured by **both** Qt5 and Qt6, so it is the primary mechanism; `qt6ct` is kept only as a Qt6 bonus for icons/palette, and `qt5ct` is skipped (the style override covers the Qt5 look; qt5ct would add only Qt5 icon control). (3) **Env location** — the Qt/GTK/SDL/Clutter vars lived in `defaults/shell/zshrc`. Verified empirically (`/proc/<pid>/environ` of niri and a niri-spawned quickshell) that **niri is systemd-started and its children never inherit zsh exports** — so those vars only ever reached terminal-launched apps, never the app launcher / `spawn`. Rejected keeping them in zshrc (would not fix VLC). Moved all GUI-toolkit vars to niri's `environment {}` block; `TERMINAL`/`EDITOR`/`XDG_*` stay in zshrc. (4) **Theme delivery** — vendor the one Mocha-Lavender theme into `defaults/` vs. the AUR package. Picked the AUR package `kvantum-theme-catppuccin-git` for consistency with how the GTK theme ships (`colloid-catppuccin-theme-git`); it installs to `/usr/share/Kvantum/`, a Kvantum search path.
- **How**: `packages/official.txt` += `kvantum kvantum-qt5 qt6ct`; `packages/aur.txt` += `kvantum-theme-catppuccin-git`. New `environment {}` block in `defaults/xdg/niri/config.kdl` (QT_QPA_PLATFORM, QT_QPA_PLATFORMTHEME=qt6ct, QT_STYLE_OVERRIDE=kvantum, QT_AUTO_SCREEN_SCALE_FACTOR, QT_WAYLAND_DISABLE_WINDOWDECORATION, GDK_BACKEND, SDL_VIDEODRIVER, CLUTTER_BACKEND); the same GUI vars removed from `defaults/shell/zshrc`. New `defaults/xdg/qt6ct/qt6ct.conf` (style=kvantum, icon_theme=Colloid-Grey-Catppuccin-Dark) and `defaults/xdg/Kvantum/kvantum.kvconfig` (theme=catppuccin-mocha-lavender, matching the Lavender accent `#b4befe`). Both deploy to `/etc/xdg/` automatically via the moonarch-git PKGBUILD's `defaults/xdg` glob (no PKGBUILD change); not added to `moonarch-doctor` — like `niri/config.kdl` and the GTK `settings.ini`, owned `/etc/xdg` configs have no `/usr/share/moonarch` reference copy to compare against. **Verified after rebuild** (r152): Kvantum reads `kvantum.kvconfig` from `/etc/xdg/Kvantum/` — fresh-path test (`~/.config/Kvantum` moved aside, a niri-spawned VLC still rendered Catppuccin), so the glob deploy is sufficient for fresh installs; no post-install / `.install` user-home seed needed.
## 2026-07-29 Privacy indicator: media.class over quickshell's node type flags
- **Who**: Dominik, ClaudeCode
- **Why**: The indicator never appeared. Measured with throwaway probe configs (`quickshell -p`) against quickshell 0.3.0: (1) **link state is never populated without a tracker** — every `PwLinkGroup` reports `state = -1` (`PwLinkState.Unlinked`; `Active = 4`), including a link that was demonstrably carrying audio, so the `state !== Active` guard rejected everything; tracking only `defaultAudioSink`/`Source` (what `AudioIndicator` does) does not help, and node properties stay empty when only the groups are tracked. (2) **The type flags are composed bits and cannot express direction**`AudioInStream = 13` (Audio|Stream|Source), `AudioOutStream = 21` (Audio|Stream|Sink), so `type & AudioInStream` is non-zero for any *playback* stream; verified live: browser audio alone set `micActive`. (3) **Video streams are not classified at all** — a pipewire camera consumer reports `type = Untracked(0)`, `isStream = false` while its `media.class` is `Stream/Input/Video`, so both branches of the check (which require `isStream`) can never match a screencast or camera.
- **Tradeoffs**: Matching `media.class` strings is what waybar's privacy module does (`Stream/Input/Audio`, `Stream/Input/Video`, `stream.monitor` are literals in its binary), so this is the established signal rather than an invention — but it needs `PwObjectTracker` over **nodes and groups**, because a bound group gives `state` and only a bound node gives `properties`. Excluding desktop-audio recording could not use pipewire's `stream.monitor`: quickshell does not expose that property (measured `undefined` on a live monitor capture), so the peer's `media.class` (`Audio/Sink`) is the discriminator instead. Exact string equality also drops the `…/Internal` variants (the Focusrite mic-split plumbing), which is intended. Camera use through raw V4L2 (`/dev/video*` without the portal) stays undetectable — no pipewire node appears at all, verified with an ffmpeg capture; a `/dev/video*` observer was scoped out (see below).
- **How**: `PrivacyIndicator.qml``PwObjectTracker { objects: nodes.concat(groups) }`; `capturing(streamClass, ignoredPeer)` walks active link groups and matches either endpoint's `media.class`, so `micActive = capturing("Stream/Input/Audio", "Audio/Sink")` and `screenActive = capturing("Stream/Input/Video", "")`. Tooltip for the video glyph is now "Screen or camera in use" (the class covers both). Verified end-to-end against the repo component in a probe panel: idle → hidden; `pw-record` from the default source → mic glyph (rendered, screenshot-checked) while browser playback ran throughout, i.e. the playback false-positive is gone; `pw-record` from a sink monitor → still hidden; `gst pipewiresrc` on the camera → video glyph; back to hidden. The screencast path shares the `Stream/Input/Video` class but was not exercised (no screen-recording tool installed, and the portal picker needs interaction).
- **Rejected**: Detecting raw V4L2 camera use. `fuser`-style polling was rejected as a 24/7 process spawn for an event that occurs rarely. inotify was measured as a viable event-driven alternative (`IN_OPEN`/`IN_CLOSE_WRITE` do fire on `/dev/video0`), but a pure device *query* without any streaming (`v4l2-ctl --list-formats`) raises identical events, so device enumeration — browsers on a camera permission prompt, conference apps at startup — would flash the glyph; `open()` is not `VIDIOC_STREAMON`. Together with per-node filtering (video0/video2 are capture, video1/video3 metadata), watch re-arming after unplug, and the `video` group requirement, the accuracy was not worth it. waybar has the same blind spot for the same reason.
@@ -1,26 +1,47 @@
// ABOUTME: Privacy bar indicator — shows mic/screenshare glyphs while an app captures audio-in or video.
// ABOUTME: Heuristic over pipewire stream nodes (AudioInStream / Video); hidden when nothing is capturing.
// ABOUTME: Privacy bar indicator — shows mic/video glyphs while an app captures audio-in or video.
// ABOUTME: Heuristic over pipewire media.class of active link endpoints; hidden when nothing is capturing.
import QtQuick
import Quickshell.Services.Pipewire
Item {
id: root
readonly property var groups: Pipewire.linkGroups ? Pipewire.linkGroups.values : []
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
// Both the link groups and the nodes have to be bound: without a tracker a group reports
// state Unlinked (-1) forever, and an unbound node exposes no properties at all.
PwObjectTracker {
objects: root.nodes.concat(root.groups)
}
function mediaClass(node) {
return node && node.properties ? node.properties["media.class"] : "";
}
// quickshell classifies video streams as Untracked and shares bits between AudioInStream
// and AudioOutStream, so its type flags cannot express "is capturing" — match media.class
// instead, the same signal waybar's privacy module uses.
function isCapture(node, peer, streamClass, ignoredPeer) {
return root.mediaClass(node) === streamClass && root.mediaClass(peer) !== ignoredPeer;
}
// A stream node exists whenever an app *opens* an input (e.g. the always-present rnnoise
// capture, or mic splits) — even while idle/suspended. So gate on an ACTIVE link (data
// actually flowing), checking either endpoint to stay direction-agnostic.
readonly property var groups: Pipewire.linkGroups ? Pipewire.linkGroups.values : []
function activeStream(flag) {
function capturing(streamClass, ignoredPeer) {
return root.groups.some(lg => {
if (!lg || lg.state !== PwLinkState.Active)
return false;
const t = lg.target;
const s = lg.source;
return (t && t.isStream && (t.type & flag)) || (s && s.isStream && (s.type & flag));
return root.isCapture(lg.source, lg.target, streamClass, ignoredPeer) || root.isCapture(lg.target, lg.source, streamClass, ignoredPeer);
});
}
readonly property bool micActive: root.activeStream(PwNodeType.AudioInStream)
readonly property bool screenActive: root.activeStream(PwNodeType.Video)
// Recording desktop audio also yields a Stream/Input/Audio node, but linked from a sink
// rather than a source — excluding that peer keeps it out of the microphone signal.
// (pipewire's own stream.monitor property is not exposed through quickshell.)
readonly property bool micActive: root.capturing("Stream/Input/Audio", "Audio/Sink")
readonly property bool screenActive: root.capturing("Stream/Input/Video", "")
visible: (root.micActive || root.screenActive)
implicitWidth: visible ? row.implicitWidth : 0
@@ -60,7 +81,7 @@ Item {
text: {
const parts = [];
if (root.screenActive)
parts.push(qsTr("Screen sharing"));
parts.push(qsTr("Screen or camera in use"));
if (root.micActive)
parts.push(qsTr("Microphone in use"));
return parts.join(" · ");