diff --git a/CLAUDE.md b/CLAUDE.md index eb1523e..2fe4662 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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//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 <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 ` (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 ` (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). diff --git a/DECISIONS.md b/DECISIONS.md index 9b0a976..7850dd9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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/` 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. diff --git a/README.md b/README.md index 3de2d80..fdc9622 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/defaults/bin/moonarch-camera-apply b/defaults/bin/moonarch-camera-apply new file mode 100755 index 0000000..1ca6334 --- /dev/null +++ b/defaults/bin/moonarch-camera-apply @@ -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 <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/ 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" diff --git a/defaults/bin/moonarch-camera-boot b/defaults/bin/moonarch-camera-boot new file mode 100755 index 0000000..d396928 --- /dev/null +++ b/defaults/bin/moonarch-camera-boot @@ -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 diff --git a/defaults/bin/moonarch-camera-list b/defaults/bin/moonarch-camera-list new file mode 100755 index 0000000..5ea65c3 --- /dev/null +++ b/defaults/bin/moonarch-camera-list @@ -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 diff --git a/defaults/bin/moonarch-camera-toggle b/defaults/bin/moonarch-camera-toggle new file mode 100755 index 0000000..30f4034 --- /dev/null +++ b/defaults/bin/moonarch-camera-toggle @@ -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 <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" diff --git a/defaults/etc/systemd/system/moonarch-camera.service b/defaults/etc/systemd/system/moonarch-camera.service new file mode 100644 index 0000000..67ee0b8 --- /dev/null +++ b/defaults/etc/systemd/system/moonarch-camera.service @@ -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 diff --git a/defaults/xdg/quickshell/moonarch/CameraIndicator.qml b/defaults/xdg/quickshell/moonarch/CameraIndicator.qml new file mode 100644 index 0000000..da77149 --- /dev/null +++ b/defaults/xdg/quickshell/moonarch/CameraIndicator.qml @@ -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(", "); + } + } +} diff --git a/defaults/xdg/quickshell/moonarch/CameraPopout.qml b/defaults/xdg/quickshell/moonarch/CameraPopout.qml new file mode 100644 index 0000000..53f991b --- /dev/null +++ b/defaults/xdg/quickshell/moonarch/CameraPopout.qml @@ -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 + } +} diff --git a/defaults/xdg/quickshell/moonarch/CameraService.qml b/defaults/xdg/quickshell/moonarch/CameraService.qml new file mode 100644 index 0000000..aa3aef0 --- /dev/null +++ b/defaults/xdg/quickshell/moonarch/CameraService.qml @@ -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() + } +} diff --git a/defaults/xdg/quickshell/moonarch/Tooltip.qml b/defaults/xdg/quickshell/moonarch/Tooltip.qml index 6d9b010..9269f8c 100644 --- a/defaults/xdg/quickshell/moonarch/Tooltip.qml +++ b/defaults/xdg/quickshell/moonarch/Tooltip.qml @@ -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 } diff --git a/defaults/xdg/quickshell/moonarch/qmldir b/defaults/xdg/quickshell/moonarch/qmldir index 1ab74c5..9a58946 100644 --- a/defaults/xdg/quickshell/moonarch/qmldir +++ b/defaults/xdg/quickshell/moonarch/qmldir @@ -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 diff --git a/defaults/xdg/quickshell/moonarch/shell.qml b/defaults/xdg/quickshell/moonarch/shell.qml index 04a9111..b12852e 100644 --- a/defaults/xdg/quickshell/moonarch/shell.qml +++ b/defaults/xdg/quickshell/moonarch/shell.qml @@ -69,6 +69,9 @@ ShellRoot { BluetoothIndicator { popouts: bar.popouts } + CameraIndicator { + popouts: bar.popouts + } } }, // Output levels — volume + brightness. diff --git a/scripts/moonarch-doctor b/scripts/moonarch-doctor index 47c48c3..063162a 100755 --- a/scripts/moonarch-doctor +++ b/scripts/moonarch-doctor @@ -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"