pragma Singleton // Persistent privacy state is separate from transient Signal Glass events. // The low-frequency PipeWire probe only assigns when state changes, so the bar // remains completely idle when nothing is capturing. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root property bool microphoneActive: false property bool cameraActive: false property bool screenSharingActive: false readonly property bool recordingActive: Capture.recording property string microphoneApp: "" property string cameraApp: "" property string screenSharingApp: "" property bool initialized: false property bool fixtureMode: false property bool warned: false readonly property bool anyActive: root.microphoneActive || root.cameraActive || root.screenSharingActive || root.recordingActive readonly property var activeKinds: { const kinds = []; if (root.recordingActive) kinds.push("Recording"); if (root.screenSharingActive) kinds.push("Screen sharing"); if (root.cameraActive) kinds.push("Camera"); if (root.microphoneActive) kinds.push("Microphone"); return kinds; } function applyStates(microphone: bool, camera: bool, screen: bool, micApp: string, cameraApp: string, screenApp: string, announce: bool): void { root.updateKind("microphone", microphone, micApp, announce); root.updateKind("camera", camera, cameraApp, announce); root.updateKind("screen", screen, screenApp, announce); root.initialized = true; } function updateKind(kind: string, active: bool, app: string, announce: bool): void { let previous = false; let label = ""; if (kind === "microphone") { previous = root.microphoneActive; root.microphoneActive = active; root.microphoneApp = app; label = "Microphone"; } else if (kind === "camera") { previous = root.cameraActive; root.cameraActive = active; root.cameraApp = app; label = "Camera"; } else { previous = root.screenSharingActive; root.screenSharingActive = active; root.screenSharingApp = app; label = "Screen sharing"; } if (!announce || previous === active) return; StatusEvents.publish({ key: "privacy-" + kind, glyph: kind === "microphone" ? "\u{F036C}" : (kind === "camera" ? "\u{F0100}" : "\u{F0379}"), title: active ? label + " in use" : label + " released", detail: active ? (app || "An application is using " + label.toLowerCase()) : "No application is using " + label.toLowerCase(), tone: active ? "danger" : "ok", priority: StatusEvents.criticalPriority, durationMs: active ? 3600 : 2400, actionId: active ? "open-activity" : "" }); } function applyProbe(text: string): void { if (root.fixtureMode) return; let nodes = []; try { nodes = JSON.parse(text); } catch (error) { if (!root.warned) { console.warn("PrivacyState: could not parse PipeWire state"); root.warned = true; } return; } let microphone = false; let camera = false; let screen = false; let micApp = ""; let cameraApp = ""; let screenApp = ""; for (const object of nodes) { if (object?.type !== "PipeWire:Interface:Node" || object?.info?.state !== "running") continue; const props = object.info.props ?? {}; const mediaClass = String(props["media.class"] ?? ""); const nodeName = String(props["node.name"] ?? "").toLowerCase(); const role = String(props["media.role"] ?? "").toLowerCase(); const app = String(props["application.name"] ?? props["node.description"] ?? ""); const looksLikeScreen = role.includes("screen") || nodeName.includes("screencast") || nodeName.includes("screen-cast") || nodeName.includes("xdg-desktop-portal"); if (mediaClass === "Stream/Input/Audio") { microphone = true; micApp = micApp || app; } else if (mediaClass === "Stream/Input/Video" && !looksLikeScreen) { camera = true; cameraApp = cameraApp || app; } else if ((mediaClass === "Stream/Input/Video" || mediaClass === "Stream/Output/Video") && looksLikeScreen) { screen = true; screenApp = screenApp || app; } } root.applyStates(microphone, camera, screen, micApp, cameraApp, screenApp, root.initialized); } function applyFixture(name: string): void { root.fixtureMode = true; if (name === "microphone-on") root.applyStates(true, false, false, "Contract fixture", "", "", true); else if (name === "camera-on") root.applyStates(false, true, false, "", "Contract fixture", "", true); else if (name === "screen-on") root.applyStates(false, false, true, "", "", "Contract fixture", true); else if (name === "all-off") root.applyStates(false, false, false, "", "", "", true); } function clearFixture(): void { root.fixtureMode = false; // The next real probe is discovery, not a transition away from test // data; otherwise contract cleanup would flash a bogus release event. root.initialized = false; probe.running = false; probe.running = true; } Timer { interval: 2500 repeat: true running: true triggeredOnStart: true onTriggered: { if (!root.fixtureMode && !probe.running) probe.running = true; } } Process { id: probe command: ["pw-dump"] stdout: StdioCollector { onStreamFinished: root.applyProbe(this.text) } onExited: (code, status) => { if (code !== 0 && !root.warned) { console.warn("PrivacyState: pw-dump exited with", code); root.warned = true; } } } }