feat(quickshell): add camera switch module
Update PKGBUILD version / update-pkgver (push) Successful in 6s

Cameras are off after every boot and get switched on per camera from the
bar. This inverts the privacy problem instead of chasing it: the previous
entry point, detecting camera *use*, cannot see raw V4L2 access at all,
and inotify on /dev/video* cannot tell a real capture from a device query.
An unauthorized camera cannot be opened in the first place.

Mechanism is `authorized` on the USB device. Measured: writing 0 drops the
/dev/videoN nodes and the PipeWire node, writing 1 brings them back and
uvcvideo rebinds itself.

A camera is identified by USB interface class 0x0e in the descriptor blob,
which stays complete while the device is deauthorized (the interface
directories do not) — so no state of our own is needed to keep a
switched-off camera listable and switchable.

The privileged helper re-derives every precondition from kernel data: id
shape, resolved path under /sys/devices, DEVTYPE=usb_device, and a video
interface class. It cannot be used to deauthorize a hub, keyboard or disk.

Switching a camera on authorizes the whole USB device, so the popout names
every other function it carries and flags in red what does not belong on a
camera. Shown rather than blocked: the internal camera carries a DFU
interface and multimedia devices commonly expose HID for their buttons, so
a class filter would lock out legitimate hardware.

Device-supplied labels are rendered as plain text and stripped of the
invisible and directional Unicode range, so a name cannot masquerade as
another device's.

A hot-plugged camera stays on — plugging one in is a deliberate act.
This commit is contained in:
nevaforget committed 2026-07-29 16:46:56 +02:00
1 parent 6b345258a4
commit 7568fe223d
15 files changed
+602 -3

No files matched your search

+15 -1
View File
@@ -18,6 +18,20 @@ Laptops with `charge_control_end_threshold` support (ThinkPad, Framework, etc.)
- Toggle flow: `moonarch-batsaver-toggle` (user script) reads sysfs, decides 80↔100, calls `pkexec /usr/bin/moonarch-batsaver-apply $NEW` for the privileged sysfs+state write step. Standard pkexec prompt (password once per session cache)
- On desktops without battery support the feature is hidden entirely
## Camera Switch
Cameras are **off after every boot** and get switched on deliberately, per camera, from the quickshell bar. That inverts the privacy problem instead of chasing it: a camera that is not authorized cannot be opened at all, so no usage heuristic is needed (`PrivacyIndicator` can only see PipeWire capture — see its own section and `DECISIONS.md` 2026-07-29).
- **Mechanism:** `authorized` on the USB device (`/sys/bus/usb/devices/<id>/authorized`). Writing `0` removes the `/dev/videoN` nodes and the PipeWire node; writing `1` brings them back and `uvcvideo` rebinds itself. USB-only by construction — a MIPI/IPU6 built-in camera has no `authorized` attribute, so the module self-hides there and the feature does not exist on such machines.
- **Identification:** a camera is a USB device whose descriptor blob carries interface class `0x0e` (video). Read from `/sys/bus/usb/devices/<id>/descriptors`, which is world-readable and — measured — stays complete and unchanged while the device is deauthorized (1420 bytes, same interface classes, on and off). The interface directories are the opposite: they drop to zero while off, which is why they cannot be the source. `bDeviceClass` is useless here too (every composite camera reports `0xef`). **`wc -c` on that file lies** (reports 65553 for every device); walk the TLV and stop on `bLength == 0`.
- **Scripts:** `moonarch-camera-list` (unprivileged, prints `id|vid:pid|label|on/off|video[+audio]`), `moonarch-camera-toggle <id> <0|1>` (unprivileged, dispatches via pkexec), `moonarch-camera-apply` (privileged, re-derives every precondition from kernel data), `moonarch-camera-boot` (run by `moonarch-camera.service` at boot).
- **No state file.** The absence of persisted state *is* the feature — nothing to restore, nothing to tamper with. An earlier draft kept a registry of known cameras in `/var/lib/moonarch/`; that directory is group-writable by `wheel`, so a privileged helper validating against it would be trusting a caller-writable allowlist. The helper now trusts only `descriptors`, `uevent` (`DEVTYPE=usb_device`) and the resolved path under `/sys/devices/`.
- **A hot-plugged camera stays on.** No udev rule: plugging a camera in is a deliberate act, and a rule on `add` would leave a fresh webcam dead until a password prompt. The boot service is the only enforcement point. Known gap: `udevadm settle` only waits for already-queued events, so a camera that enumerates very late (behind a dock) can stay on after boot until switched.
- **A switch cuts the whole USB device**, in both directions: a webcam's built-in microphone goes down with its camera (and on the internal ThinkPad camera, its DFU interface — fwupd cannot update it while off), and switching one **on** authorizes every other function that device carries. `moonarch-camera-list` therefore reports all notable interface classes, and the popout names them per row ("also audio"), in `Theme.red` when a device brings something that has no business on a camera — input, storage, network, smartcard, wireless, printer. Deliberately shown, not blocked: a strict allow-list would reject legitimate hardware (the internal camera carries a DFU interface, and multimedia devices commonly expose HID for their buttons), while the deception this defends against needs someone to physically plug a device in — so the honest fix is making the device's real capabilities visible at the moment of arming it.
- **Widget:** `CameraIndicator` sits in the connectivity `ModuleBox`; `md-webcam` in `Theme.accent` when any camera is armed, `md-webcam-off` in `Theme.subtext0` when all are off. Deliberately **not** `Theme.red` — red is the `PrivacyIndicator`'s colour for actual capture, and armed-but-idle is the weaker statement. `CameraPopout` (registered as `camera`) lists one `Toggle` per camera; a switch in flight dims the row via `opacity` rather than `enabled: false`, which would drop the control out of the Tab chain. Only one `pkexec` runs at a time so prompts cannot stack.
Rationale + measurements: `DECISIONS.md` (2026-07-29).
## Nightlight (Blue Light Filter)
Waybar toggle for wlsunset (Wayland-native blue light filter), persistent state via systemd:
@@ -78,7 +92,7 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
- **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray). The host anchors the popout body to the trigger's on-screen box and picks the side from it: **left-aligned** when the trigger sits in the left half of the screen (so left-cluster popouts grow rightward and stay on-screen), **right-aligned** otherwise (`PopoutHost.qml` `frame.leftAnchored`). The anchor rect carries the trigger's left edge + width; both `openByName` and Tray's direct `open()` pass that box.
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher`. Tray is not keybind-addressable.
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera`. Tray is not keybind-addressable.
- **Launcher:** `Launcher.qml` is the first widget in the left cluster (an apps glyph) and owns `LauncherPopout.qml` — an application launcher over `DesktopEntries.applications` (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via `entry.execute()`, Esc close; app icons via `Quickshell.iconPath`). It registers under `launcher` (so it is IPC-addressable) and is bound to `Mod+Space` (`qs -c moonarch ipc call popout toggle launcher`, in `config.kdl`) in addition to icon-click. Scope is app-launch (drun) only; `walker` stays installed and keeps the other modes — `Super+C` still opens walker's clipboard, and walker remains the run-command/calc launcher (no longer on a keybind).
+7
View File
@@ -261,3 +261,10 @@
- **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.
## 2026-07-29 – Camera switch: USB authorization instead of usage detection
- **Who**: Dominik, ClaudeCode
- **Why**: The privacy indicator can only see PipeWire capture, so raw V4L2 access stays invisible (entry above). Detecting it was rejected: `inotify` fires `IN_OPEN` for a pure device query (`v4l2-ctl --list-formats`) exactly as for a real capture, so device enumeration by a browser would flash the glyph. Switching cameras off by default inverts the problem — an unauthorized camera cannot be opened at all, which is a stronger guarantee than any heuristic and is exactly answerable.
- **Tradeoffs**: (1) **Mechanism** — `authorized` on the USB device beats `modprobe -r uvcvideo` (per device rather than all cameras at once, and no reboot after 59 toggles as the Codeberg camera-control tool reports for the module path) and beats interface-level tricks (there is no per-interface authorize for non-wireless USB). Measured: `authorized=0` drops the `/dev/videoN` nodes immediately, udev emits `remove` per node, the PipeWire node disappears; `authorized=1` brings the nodes back, `uvcvideo` rebinds itself, capture works, and PipeWire re-exposes the camera a few seconds later under a **new** node id. Cost: the switch cuts the whole USB function, so a webcam's built-in microphone dies with it (`3-1` carries `01/01,01/02`) and the internal camera's DFU interface too (`5-1` carries `fe/01`, so fwupd cannot update it while off) — surfaced in the popout. USB-only by construction: MIPI/IPU6 cameras have no `authorized`, the widget self-hides there. (2) **Identification** — `descriptors` (world-readable, `-r--r--r--`) carries every interface class and is the only source that works in **both** states. Measured across a full deauthorize cycle of the internal camera: the blob stays at 1420 bytes with identical interface classes while `authorized=0`, whereas the interface directories drop from 5 to 0 — so they cannot be the gate. `bDeviceClass` is useless as well (`0xef` on every composite camera). `moonarch-camera-list` correctly reported the switched-off camera as `off`, i.e. it stays listable and switchable with no state of our own. Verified across 21 USB devices that interface class `0x0e` selects exactly the two cameras (hubs `09`, HID `03`, audio `01`, AV adapter `11`). Gotcha: `wc -c` reports 65553 for that file on every device — the real length is 1420 / 1332 bytes here, so the TLV walk must stop on `bLength == 0`. (3) **No state file** — an earlier draft kept a registry of known cameras under `/var/lib/moonarch/`; rejected because that directory is group-writable by `wheel`, so the privileged helper would have been validating its target against a caller-writable allowlist. It now trusts only kernel data: `descriptors`, `DEVTYPE=usb_device` from `uevent`, and the `authorized` symlink resolved into `/sys/devices/`. Note `/sys/bus/usb/devices/<id>` is itself a symlink, so confinement cannot be checked against that prefix. (4) **Boot enforcement** — a systemd oneshot, not a udev rule. A rule on `add` would also disable **hot-plugged** cameras, and plugging one in is a deliberate act; a fresh webcam that is dead until a password prompt is the wrong default. Accepted gap: `udevadm settle` only waits for already-queued events, so a camera enumerating very late behind a dock can stay on until switched. (5) **Auth** — plain `pkexec` with the standard password prompt, as `moonarch-batsaver-*` (2026-05-04); no polkit rule, since a no-password rule would let any process in the session re-arm the camera unnoticed and defeat the switch. Popout-spawned prompts are typeable thanks to the `OnDemand` keyboard focus fix (2026-07-09).
- **How**: Four scripts in `defaults/bin/` — `moonarch-camera-list` (unprivileged; prints `id|vid:pid|label|on/off|video[+audio]`, sanitizes device-supplied product strings so they cannot forge columns), `moonarch-camera-toggle` (unprivileged pkexec dispatch, passes the exit code through), `moonarch-camera-apply` (privileged; validation ladder = value ∈ {0,1}, bus-port id shape, device exists, `authorized` is a real file, resolved path under `/sys/devices/`, `DEVTYPE=usb_device`, video interface class present, then an idempotence skip before the write), `moonarch-camera-boot` (enumerates via `-list`, writes 0 to each). New unit `defaults/etc/systemd/system/moonarch-camera.service` (`Type=oneshot`, `ConditionPathExistsGlob=/sys/class/video4linux/video*`, batsaver's hardening block, enabled from `moonarch.install`; `Type=oneshot` also satisfies `moonarch-doctor`'s oneshot handling). QML trio `CameraService` / `CameraIndicator` / `CameraPopout`: the service keeps a `probed` flag so a failed list never empties the bar widget, an optimistic `pending` map so a row reacts before the password prompt returns, a single in-flight `pkexec` so prompts cannot stack, and treats exit 126 as a silent cancel. Its `udevadm monitor` watches the **usb** subsystem, not `video4linux` — unplugging an already switched-off camera emits no video4linux event, and the widget would keep listing a device that is gone.
- **Follow-up (same day, after audit)**: A security audit of the finished code found that the camera gate accepts a device as soon as *any* interface reports class `0x0e`, while `authorized=1` re-authorizes the **whole** device — so a composite device with an alibi video interface plus a hidden keyboard would appear in the popout as a camera and go live on the first switch-on. Rejected blocking it by allow-list or deny-list: the internal camera itself carries a DFU interface (`0xfe`) and multimedia devices commonly expose HID for their buttons (measured: the USB audio device at `3-2.2` reports `01,01,01,01,01,03`), so a class filter would lock out legitimate hardware, while the attack needs physical access to a port. Instead `moonarch-camera-list` now reports every notable interface class and the popout names them per row, in red for classes that have no business on a camera. Also fixed from the same audit: `textFormat: Text.PlainText` on the device-supplied label and in the shared `Tooltip` (matching `NotifToast`/`NotifCenter`, which already guard foreign text), and the label sanitizer now strips the invisible/directional Unicode range (`U+200B..U+200F`, `U+202A..U+202E`, `U+2066..U+2069`) under `LC_ALL=C`, so a device cannot make its name render as another one's — verified that `|`, control characters, RLO and zero-width characters are removed while umlauts survive. Left as known and accepted: a TOCTOU window between the checks and the write that would need dedicated switching hardware inside a millisecond gap after authentication, and the absence of `CapabilityBoundingSet`/`SystemCallFilter` in the unit, which is inherited from `moonarch-batsaver.service` and would be a shared change.
+4 -2
View File
@@ -118,13 +118,15 @@ defaults/
moonarch-batsaver-toggle Toggle battery charge limit (80% ↔ 100%)
moonarch-nightlight Toggle wlsunset blue light filter on/off
moonarch-waybar-nightlight Waybar module: nightlight status
moonarch-camera-list List cameras with their on/off state
moonarch-camera-toggle Switch one camera on or off (via pkexec)
moonarch-camera-boot Switch all cameras off (run at boot by moonarch-camera.service)
shell/zshrc Zsh config: prompt, aliases, FZF, completion
etc/greetd/ greetd daemon + greeter Niri config
etc/moongreet/ moongreet configuration
etc/systemd/user/ Systemd user services (cliphist, kanshi, walker, nautilus)
etc/systemd/system/ System service (battery conservation restore on boot)
etc/udev/rules.d/ udev rules (battery threshold permissions)
etc/systemd/system/ System services (battery conservation restore, camera switch-off on boot)
backgrounds/wallpaper.jpg Default wallpaper (shared by desktop, greeter, lock screen)
```
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
# ABOUTME: Privileged helper invoked via pkexec from moonarch-camera-toggle; writes USB authorized.
# ABOUTME: Every precondition is re-derived from kernel data — nothing the caller says is trusted.
set -eu
PATH=/usr/bin:/bin
export PATH
ID="${1:-}"
VAL="${2:-}"
die() {
echo "$1" >&2
exit 1
}
[ "$#" -eq 2 ] || die "Usage: moonarch-camera-apply <usb-id> <0|1>"
case "$VAL" in
0 | 1) ;;
*) die "Invalid value: '$VAL' (expected 0 or 1)" ;;
esac
# A USB device id is a bus-port path: digits, then dash, then dot-separated port numbers.
# Rejects '/', '..', leading or trailing separators, whitespace and glob characters, so the id
# cannot address anything outside the device directory. DECISIONS.md (2026-03-31) records a past
# command injection from an unquoted expansion behind pkexec — hence the strict shape first.
case "$ID" in
"" | *[!0-9.-]* | *..* | *.. | .* | -* | *. | *-) die "Invalid device id: '$ID'" ;;
esac
case "$ID" in
*-*) ;;
*) die "Invalid device id: '$ID'" ;;
esac
case "$ID" in
*-*-*) die "Invalid device id: '$ID'" ;;
esac
DEV="/sys/bus/usb/devices/$ID"
[ -d "$DEV" ] || die "No such USB device: $ID"
[ -f "$DEV/authorized" ] || die "Device cannot be authorized: $ID"
if [ -L "$DEV/authorized" ]; then
die "Unexpected symlink: $DEV/authorized"
fi
# /sys/bus/usb/devices/<id> is itself a symlink into /sys/devices, so confinement is checked
# against the resolved target rather than the bus path.
REAL=$(readlink -f "$DEV/authorized")
case "$REAL" in
/sys/devices/*/authorized) ;;
*) die "Refusing to write outside /sys/devices: $REAL" ;;
esac
# Reject interfaces and anything that is not a USB device proper.
grep -qx "DEVTYPE=usb_device" "$DEV/uevent" || die "Not a USB device: $ID"
# Camera gate: the descriptor blob must carry a video interface (class 0x0e). This is what makes
# the helper structurally unable to deauthorize a hub, keyboard or disk, and it works whether the
# device is currently on or off.
is_camera=$(od -An -v -tu1 "$DEV/descriptors" 2>/dev/null | awk '
{ for (k = 1; k <= NF; k++) b[n++] = $k }
END {
p = 0
while (p < n) {
l = b[p] + 0
if (l == 0)
break
if (b[p + 1] == 4 && b[p + 5] + 0 == 14) {
print "yes"
exit
}
p += l
}
}')
[ "$is_camera" = "yes" ] || die "Not a camera: $ID"
# Writing the value it already has would tear the device down and rebuild it for nothing.
if [ "$(cat "$DEV/authorized" 2>/dev/null)" = "$VAL" ]; then
exit 0
fi
printf %s "$VAL" > "$DEV/authorized" || die "Failed to write $DEV/authorized"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# ABOUTME: Switches every camera off at boot — the privacy default is "off until asked for".
# ABOUTME: Run by moonarch-camera.service; holds no state, the absence of state is the feature.
set -eu
PATH=/usr/bin:/bin
export PATH
# Cameras enumerate early, but a device behind a hub or dock can still be in flight. settle only
# waits for events already queued, so a very late arrival can stay on until the user switches it —
# accepted, since it is the same class of gap as plugging one in later.
if command -v udevadm >/dev/null 2>&1; then
udevadm settle --timeout=10 || true
fi
# Reuse the enumeration rather than carrying a second copy of the descriptor walk.
LINES=$(/usr/bin/moonarch-camera-list || true)
[ -n "$LINES" ] || exit 0
printf '%s\n' "$LINES" | while IFS='|' read -r id hw label state kind; do
[ -n "$id" ] || continue
auth="/sys/bus/usb/devices/$id/authorized"
if [ -f "$auth" ] && [ "$state" = "on" ]; then
printf 0 > "$auth" || echo "Failed to switch off camera $id" >&2
fi
done
+93
View File
@@ -0,0 +1,93 @@
#!/bin/sh
# ABOUTME: Lists cameras as `id|vid:pid|label|on/off|video[+audio]`, one line each, for the camera module.
# ABOUTME: Identifies a camera by USB interface class 0x0e in the kernel's descriptor blob.
# The interface *directories* only exist while the device is authorized, so they cannot answer
# "is this a camera" for a switched-off one. The raw descriptor blob can: it is allocated at
# enumeration, stays world-readable, and carries every interface class.
# Prints "video" plus every other notable function the same physical device carries. Switching a
# camera on authorizes the *whole* device, so anything else on it becomes active too — the popout
# shows this so an innocuous-looking "camera" that also brings a keyboard is visible as such.
classes_of() {
od -An -v -tu1 "$1/descriptors" 2>/dev/null | awk '
{ for (k = 1; k <= NF; k++) b[n++] = $k }
END {
p = 0
while (p < n) {
l = b[p] + 0
if (l == 0) # malformed or short read — stop rather than walk off
break
if (b[p + 1] == 4) { # interface descriptor
c = b[p + 5] + 0
if (c == 14)
seen["video"] = 1
else if (c == 1)
seen["audio"] = 1
else if (c == 3)
seen["input"] = 1
else if (c == 8)
seen["storage"] = 1
else if (c == 2 || c == 10)
seen["network"] = 1
else if (c == 11)
seen["smartcard"] = 1
else if (c == 224)
seen["wireless"] = 1
else if (c == 7)
seen["printer"] = 1
}
p += l
}
if (!seen["video"])
exit
out = "video"
# Fixed order so the field is stable across runs.
split("audio input storage network smartcard wireless printer", order, " ")
for (i = 1; i <= 7; i++)
if (seen[order[i]])
out = out "+" order[i]
printf "%s", out
}'
}
# Product strings come from the device, i.e. from whoever plugs it in. Strip the field separator and
# control characters so a hostile string cannot forge extra columns, and the invisible/directional
# Unicode range (U+200B..U+200F, U+202A..U+202E, U+2066..U+2069) so a name cannot be made to *look*
# like a different device. LC_ALL=C keeps this byte-oriented, which is what the \x patterns need.
sanitize() {
LC_ALL=C sed 's/[|[:cntrl:]]//g
s/\xe2\x80[\x8b-\x8f\xaa-\xae]//g
s/\xe2\x81[\xa6-\xa9]//g
s/ */ /g
s/^ //
s/ $//' | cut -c1-64
}
for dev in /sys/bus/usb/devices/*-*; do
[ -d "$dev" ] || continue
id=${dev##*/}
case "$id" in
*:*) continue ;; # interface, not a device
esac
kind=$(classes_of "$dev")
[ -n "$kind" ] || continue
label=$(cat "$dev/product" 2>/dev/null | sanitize)
[ -n "$label" ] || label=$(cat "$dev/manufacturer" 2>/dev/null | sanitize)
[ -n "$label" ] || label="USB camera"
if [ "$(cat "$dev/authorized" 2>/dev/null)" = "0" ]; then
state=off
else
state=on
fi
printf '%s|%s:%s|%s|%s|%s\n' \
"$id" \
"$(cat "$dev/idVendor" 2>/dev/null)" \
"$(cat "$dev/idProduct" 2>/dev/null)" \
"$label" \
"$state" \
"$kind"
done | sort -t'|' -k1,1
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# ABOUTME: Switches one camera on or off; dispatches the privileged sysfs write via pkexec.
# ABOUTME: Usage: moonarch-camera-toggle <usb-id> <0|1>. Exit code is pkexec's (126 = prompt cancelled).
ID="${1:-}"
VAL="${2:-}"
# Shape check only — moonarch-camera-apply re-validates everything as the privileged side.
case "$ID" in
""|*[!0-9.-]*) exit 1 ;;
esac
case "$VAL" in
0 | 1) ;;
*) exit 1 ;;
esac
pkexec /usr/bin/moonarch-camera-apply "$ID" "$VAL"
@@ -0,0 +1,22 @@
# ABOUTME: Switches all cameras off on boot — the privacy default is "off until asked for".
# ABOUTME: Only runs on machines that have a video4linux device at all.
[Unit]
Description=Switch off all cameras on boot
After=sysinit.target
ConditionPathExistsGlob=/sys/class/video4linux/video*
[Service]
Type=oneshot
ExecStart=/usr/bin/moonarch-camera-boot
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,68 @@
// ABOUTME: Camera bar widget — shows whether any camera is armed; click opens the switch popout.
// ABOUTME: Backed by the CameraService singleton; hidden on machines without a camera.
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "camera"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
CameraPopout {
popouts: root.popouts
}
}
visible: CameraService.hasCameras
implicitWidth: visible ? iconText.implicitWidth : 0
implicitHeight: Theme.barHeight
Text {
id: iconText
height: parent.height
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 1
// Accent means armed, not recording — Theme.red stays reserved for the PrivacyIndicator's
// "something is capturing right now".
color: CameraService.anyOn ? Theme.accent : Theme.subtext0
// md-webcam / md-webcam-off
text: CameraService.anyOn ? String.fromCodePoint(0xf05a0) : String.fromCodePoint(0xf1737)
}
MouseArea {
anchors.fill: parent
onClicked: {
if (!root.popouts)
return;
root.popouts.toggleByName(popoutName);
}
}
HoverHandler {
id: hover
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: {
const on = CameraService.cameras.filter(c => c.on);
if (on.length === 0)
return qsTr("All cameras off");
return qsTr("Camera armed: ") + on.map(c => c.label).join(", ");
}
}
}
@@ -0,0 +1,113 @@
// ABOUTME: Camera popout — one switch per camera, switched off by cutting USB authorization.
// ABOUTME: Backed by the CameraService singleton. Registered as the "camera" popout.
pragma ComponentBehavior: Bound
import QtQuick
PopoutPanel {
id: root
property var popouts: null
title: qsTr("Cameras")
panelWidth: 320
// The popout instance survives reopens for the slide animation, so re-read on show.
readonly property bool shown: root.popouts && root.popouts.hasCurrent
onShownChanged: if (root.shown)
CameraService.refresh()
Component.onCompleted: CameraService.refresh()
// Reachable by keybind even while the indicator is hidden, so it needs an empty state.
Text {
width: root.contentWidth
visible: !CameraService.hasCameras
text: qsTr("No cameras")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Column {
width: root.contentWidth
spacing: 2
Repeater {
model: CameraService.cameras
delegate: ListRow {
id: camRow
required property var modelData
readonly property bool waiting: camRow.modelData.id in CameraService.pending
width: parent.width
implicitHeight: 40
// Dim while the password prompt is up — deliberately not `enabled: false`, which
// would drop the row and its Toggle out of the Tab chain.
opacity: camRow.waiting ? 0.5 : 1.0
// Whole row is a hit target; the Toggle sits above it and consumes its own clicks.
onClicked: CameraService.toggle(camRow.modelData)
Column {
anchors.left: parent.left
anchors.right: camToggle.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
width: parent.width
text: camRow.modelData.label
color: Theme.text
// The label is a USB string descriptor, i.e. supplied by the device.
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
// The switch authorizes the whole USB device, so every other function on it
// goes live too. Naming them beats a device that merely looks like a camera.
text: {
const f = camRow.modelData.functions;
if (f.length === 0)
return camRow.modelData.id;
return camRow.modelData.id + " · " + qsTr("also ") + f.join(", ");
}
// Audio on a webcam is normal; a keyboard or a disk is not.
color: camRow.modelData.unexpected.length > 0 ? Theme.red : Theme.subtext0
textFormat: Text.PlainText
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
}
}
Toggle {
id: camToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: CameraService.stateOf(camRow.modelData)
onToggled: CameraService.toggle(camRow.modelData)
}
}
}
}
Text {
width: root.contentWidth
visible: CameraService.lastError.length > 0
wrapMode: Text.WordWrap
text: CameraService.lastError
color: Theme.red
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 1
}
Text {
width: root.contentWidth
visible: CameraService.hasCameras
wrapMode: Text.WordWrap
text: qsTr("Cameras are switched off at every boot. Switching one on asks for your password.")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
}
}
@@ -0,0 +1,137 @@
// ABOUTME: Camera service — camera list and on/off switching via the moonarch-camera-* helpers.
// ABOUTME: Event-driven through `udevadm monitor` on the usb subsystem, slow fallback poll.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property var cameras: [] // [{ id, hw, label, on, mic }]
property bool probed: false // true once a list ran successfully
property var pending: ({}) // id -> target state while a switch is in flight
property string lastError: ""
// Only claim "no cameras" once something was actually enumerated — a widget that vanishes
// from the bar on a transient failure is worse than one that shows a stale state.
readonly property bool hasCameras: root.probed && root.cameras.length > 0
readonly property bool anyOn: root.cameras.some(c => c.on)
readonly property bool busy: applyProc.running
// The optimistic target wins while a switch is in flight, so the row reacts to the click
// instead of waiting for the password prompt.
function stateOf(cam) {
if (cam && cam.id in root.pending)
return root.pending[cam.id];
return cam ? cam.on : false;
}
// Functions that have no business on a camera. Switching the camera on authorizes the whole
// USB device, so these become active too — worth showing rather than silently allowing.
readonly property var unexpectedFunctions: ["input", "storage", "network", "smartcard", "wireless", "printer"]
// moonarch-camera-list prints `id|vid:pid|label|on/off|video[+more]` per camera.
function parse(txt) {
const out = [];
for (const line of txt.split("\n")) {
if (!line)
continue;
const f = line.split("|");
if (f.length < 5)
continue;
const funcs = f[4].split("+").filter(x => x !== "video");
out.push({
id: f[0],
hw: f[1],
label: f[2],
on: f[3] === "on",
functions: funcs,
unexpected: funcs.filter(x => root.unexpectedFunctions.indexOf(x) >= 0)
});
}
root.cameras = out;
root.probed = true;
}
Process {
id: listProc
command: ["moonarch-camera-list"]
stdout: StdioCollector {
id: listOut
}
onExited: (code, status) => {
if (code === 0)
root.parse(listOut.text);
}
}
function refresh() {
if (!listProc.running)
listProc.running = true;
}
// The helper raises a pkexec password prompt, so this process can stay alive for a while.
// Only one switch at a time — two concurrent calls would stack two password dialogs.
Process {
id: applyProc
property string target: ""
onExited: (code, status) => {
// Must be a *fresh* object: assigning back the same reference is a no-op for QML
// bindings (verified — mutating in place or reassigning the identical object does not
// re-evaluate anything that reads `pending`).
const p = Object.assign({}, root.pending);
delete p[applyProc.target];
root.pending = p;
// 126 is pkexec's "authorization dismissed" — cancelling is deliberate, so it is not
// an error worth reporting. Anything else non-zero is.
if (code === 0)
root.lastError = "";
else if (code !== 126)
root.lastError = qsTr("Switching failed (exit ") + code + ")";
root.refresh();
}
}
function setCamera(cam, on) {
if (!cam || root.busy || cam.id in root.pending)
return;
const p = Object.assign({}, root.pending);
p[cam.id] = on;
root.pending = p;
applyProc.target = cam.id;
applyProc.command = ["moonarch-camera-toggle", cam.id, on ? "1" : "0"];
applyProc.running = true;
}
function toggle(cam) {
if (cam)
root.setCamera(cam, !root.stateOf(cam));
}
// Watch the usb subsystem, not video4linux: unplugging an already switched-off camera emits
// no video4linux event at all, and the widget would keep listing a device that is gone.
Process {
id: monitorProc
command: ["udevadm", "monitor", "--udev", "--subsystem-match=usb"]
running: true
stdout: SplitParser {
splitMarker: "\n"
onRead: () => debounce.restart()
}
}
Timer {
id: debounce
interval: 300
onTriggered: root.refresh()
}
// Fallback poll — catches a died monitor process, and a re-enabled camera whose driver binds
// a moment after the event.
Timer {
interval: 60000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
}
@@ -51,6 +51,9 @@ PopupWindow {
anchors.centerIn: parent
text: root.text
color: Theme.text
// Tooltips carry foreign strings (window titles, USB device names), and AutoText would
// render markup-looking content as rich text — same guard as the notification widgets.
textFormat: Text.PlainText
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
+3
View File
@@ -7,6 +7,8 @@ Battery Battery.qml
BatteryPopout BatteryPopout.qml
BluetoothIndicator BluetoothIndicator.qml
BluetoothPopout BluetoothPopout.qml
CameraIndicator CameraIndicator.qml
CameraPopout CameraPopout.qml
Clock Clock.qml
CpuGovIndicator CpuGovIndicator.qml
CpuGovPopout CpuGovPopout.qml
@@ -33,6 +35,7 @@ PopoutPanel PopoutPanel.qml
PopoutState PopoutState.qml
PrivacyIndicator PrivacyIndicator.qml
singleton Backlight Backlight.qml
singleton CameraService CameraService.qml
singleton CpuGovService CpuGovService.qml
singleton IdleInhibit IdleInhibit.qml
singleton NetworkService NetworkService.qml
@@ -69,6 +69,9 @@ ShellRoot {
BluetoothIndicator {
popouts: bar.popouts
}
CameraIndicator {
popouts: bar.popouts
}
}
},
// Output levels — volume + brightness.
+7
View File
@@ -174,6 +174,13 @@ else
pass "moonarch-batsaver (skipped — no battery threshold support)"
fi
# Camera switch-off service (only on machines with a camera)
if compgen -G "/sys/class/video4linux/video*" >/dev/null; then
check_system_service moonarch-camera
else
pass "moonarch-camera (skipped — no camera)"
fi
# --- 3. User Services ---
section "User Services"