179 lines
6.3 KiB
QML
179 lines
6.3 KiB
QML
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;
|
|
}
|
|
}
|
|
}
|
|
}
|