inital commit

This commit is contained in:
2025-12-28 10:41:44 -06:00
commit e7426264e7
119 changed files with 4953 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Commons
import qs.Modules.Bar.Extras
import qs.Services.UI
import qs.Widgets
Rectangle {
id: root
property var pluginApi: null
property ShellScreen screen
property string widgetId: ""
property string section: ""
readonly property string barPosition: Settings.data.bar.position
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
property bool micActive: false
property bool camActive: false
property bool scrActive: false
property var micApps: []
property var camApps: []
property var scrApps: []
property var cfg: pluginApi?.pluginSettings || ({})
property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
property bool hideInactive: cfg.hideInactive ?? defaults.hideInactive
property bool removeMargins: cfg.removeMargins ?? defaults.removeMargins
property int iconSpacing: cfg.iconSpacing || Style.marginXS
readonly property color activeColor: Color.mPrimary
readonly property color inactiveColor: Qt.alpha(Color.mOnSurfaceVariant, 0.3)
readonly property color micColor: micActive ? activeColor : inactiveColor
readonly property color camColor: camActive ? activeColor : inactiveColor
readonly property color scrColor: scrActive ? activeColor : inactiveColor
readonly property bool isVisible: !hideInactive || micActive || camActive || scrActive
property real margins: removeMargins ? 0 : Style.marginM * 2
implicitWidth: isVertical ? Style.capsuleHeight : Math.round(layout.implicitWidth + margins)
implicitHeight: isVertical ? Math.round(layout.implicitHeight + margins) : Style.capsuleHeight
Layout.alignment: Qt.AlignVCenter
radius: Style.radiusM
color: Style.capsuleColor
visible: root.isVisible
opacity: root.isVisible ? 1.0 : 0.0
PwObjectTracker {
objects: Pipewire.ready ? Pipewire.nodes.values : []
}
Process {
id: cameraCheckProcess
running: false
command: ["sh", "-c", "for dev in /dev/video*; do [ -e \"$dev\" ] && [ -n \"$(find /proc/[0-9]*/fd/ -lname \"$dev\" 2>/dev/null | head -n1)\" ] && echo \"active\" && exit 0; done; exit 1"]
onExited: (code, status) => {
var isActive = code === 0;
root.camActive = isActive;
if (isActive) {
cameraAppsProcess.running = true;
} else {
root.camApps = [];
}
}
}
Process {
id: cameraAppsProcess
running: false
command: ["sh", "-c", "for dev in /dev/video*; do [ -e \"$dev\" ] && for fd in /proc/[0-9]*/fd/*; do [ -L \"$fd\" ] && [ \"$(readlink \"$fd\" 2>/dev/null)\" = \"$dev\" ] && ps -p \"$(echo \"$fd\" | cut -d/ -f3)\" -o comm= 2>/dev/null; done; done | sort -u | tr '\\n' ',' | sed 's/,$//'"]
onExited: (code, status) => {
if (stdout) {
var appsString = stdout.trim();
var apps = appsString.length > 0 ? appsString.split(',') : [];
root.camApps = apps;
} else {
root.camApps = [];
}
}
}
Timer {
interval: 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: updatePrivacyState()
}
function hasNodeLinks(node, links) {
for (var i = 0; i < links.length; i++) {
var link = links[i];
if (link && (link.source === node || link.target === node)) {
return true;
}
}
return false;
}
function getAppName(node) {
return node.properties["application.name"] || node.nickname || node.name || "";
}
function updateMicrophoneState(nodes, links) {
var appNames = [];
var isActive = false;
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (!node || !node.isStream || !node.audio || node.isSink) continue;
if (!hasNodeLinks(node, links) || !node.properties) continue;
var mediaClass = node.properties["media.class"] || "";
if (mediaClass === "Stream/Input/Audio") {
if (node.properties["stream.capture.sink"] === "true") {
continue;
}
isActive = true;
var appName = getAppName(node);
if (appName && appNames.indexOf(appName) === -1) {
appNames.push(appName);
}
}
}
root.micActive = isActive;
root.micApps = appNames;
}
function updateCameraState() {
cameraCheckProcess.running = true;
}
function isScreenShareNode(node) {
if (!node.properties) {
return false;
}
var mediaClass = node.properties["media.class"] || "";
if (mediaClass.indexOf("Audio") >= 0) {
return false;
}
if (mediaClass.indexOf("Video") === -1) {
return false;
}
var mediaName = (node.properties["media.name"] || "").toLowerCase();
if (mediaName.match(/^(xdph-streaming|gsr-default|game capture|screen|desktop|display|cast|webrtc|v4l2)/) ||
mediaName === "gsr-default_output" ||
mediaName.match(/screen-cast|screen-capture|desktop-capture|monitor-capture|window-capture|game-capture/i)) {
return true;
}
return false;
}
function updateScreenShareState(nodes, links) {
var appNames = [];
var isActive = false;
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (!node || !hasNodeLinks(node, links) || !node.properties) continue;
if (isScreenShareNode(node)) {
isActive = true;
var appName = getAppName(node);
if (appName && appNames.indexOf(appName) === -1) {
appNames.push(appName);
}
}
}
root.scrActive = isActive;
root.scrApps = appNames;
}
function updatePrivacyState() {
if (!Pipewire.ready) return;
var nodes = Pipewire.nodes.values || [];
var links = Pipewire.links.values || [];
updateMicrophoneState(nodes, links);
updateCameraState();
updateScreenShareState(nodes, links);
}
function buildTooltip() {
var parts = [];
if (micActive && micApps.length > 0) {
parts.push("Mic: " + micApps.join(", "));
}
if (camActive && camApps.length > 0) {
parts.push("Cam: " + camApps.join(", "));
}
if (scrActive && scrApps.length > 0) {
parts.push("Screen sharing: " + scrApps.join(", "));
}
return parts.length > 0 ? parts.join("\n") : "";
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
hoverEnabled: true
onEntered: {
var tooltipText = buildTooltip();
if (tooltipText) {
TooltipService.show(root, tooltipText, BarService.getTooltipDirection());
}
}
onExited: TooltipService.hide()
}
Item {
id: layout
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: iconsLayout.implicitWidth
implicitHeight: iconsLayout.implicitHeight
GridLayout {
id: iconsLayout
columns: root.isVertical ? 1 : 3
rows: root.isVertical ? 3 : 1
rowSpacing: root.iconSpacing
columnSpacing: root.iconSpacing
NIcon {
visible: micActive || !root.hideInactive
icon: micActive ? "microphone" : "microphone-off"
color: root.micColor
}
NIcon {
visible: camActive || !root.hideInactive
icon: camActive ? "camera" : "camera-off"
color: root.camColor
}
NIcon {
visible: scrActive || !root.hideInactive
icon: scrActive ? "screen-share" : "screen-share-off"
color: root.scrColor
}
}
}
}

View File

@@ -0,0 +1,45 @@
# Privacy Indicator Plugin
A privacy indicator widget that monitors and displays when microphone, camera, or screen sharing is active on your system.
## Features
- **Microphone Monitoring**: Detects active microphone usage via Pipewire
- **Camera Monitoring**: Detects active camera usage by checking `/dev/video*` devices
- **Screen Sharing Detection**: Monitors screen sharing sessions via Pipewire
- **Visual Indicators**: Shows icons that change color based on active state
- Active: Primary color
- Inactive: Semi-transparent variant color
- **App Information**: Tooltip displays which applications are using each resource
- **Adaptive Layout**: Automatically adjusts layout for horizontal or vertical bar positions
## Configuration
Access the plugin settings in Noctalia to configure the following options:
- **Hide Inactive States**: If enabled, microphone, camera, and screen icons are hidden whenever they are inactive. Only active states are shown.
- **RemoveMargins**: If enabled, removes all outer margins of the widget.
- **Icon Spacing**: Controls the horizontal/vertical spacing between the icons.
## Usage
The widget displays three icons in the bar:
- **Microphone**: Shows when any app is using the microphone
- **Camera**: Shows when any app is accessing the camera
- **Screen Share**: Shows when screen sharing is active
Hover over the widget to see a tooltip listing which applications are using each resource.
## Requirements
- Noctalia Shell 3.6.0 or higher
- Pipewire (for microphone and screen sharing detection)
- Access to `/dev/video*` devices (for camera detection)
## Technical Details
- Updates privacy state every second
- Uses Pipewire API to monitor audio/video streams
- Checks `/proc/[0-9]*/fd/` for camera device access
- Detects screen sharing by analyzing Pipewire node properties and media class

View File

@@ -0,0 +1,88 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
property var pluginApi: null
property var cfg: pluginApi?.pluginSettings || ({})
property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
property bool hideInactive: cfg.hideInactive ?? defaults.hideInactive
property bool removeMargins: cfg.removeMargins ?? defaults.removeMargins
property int iconSpacing: cfg.iconSpacing || Style.marginXS
spacing: Style.marginL
Component.onCompleted: {
Logger.i("PrivacyIndicator", "Settings UI loaded");
}
ColumnLayout {
spacing: Style.marginM
Layout.fillWidth: true
NToggle {
label: pluginApi?.tr("settings.hideInactive.label")
description: pluginApi?.tr("settings.hideInactive.desc")
checked: root.hideInactive
onToggled: function (checked) {
root.hideInactive = checked;
}
}
NToggle {
label: pluginApi?.tr("settings.removeMargins.label")
description: pluginApi?.tr("settings.removeMargins.desc")
checked: root.removeMargins
onToggled: function (checked) {
root.removeMargins = checked;
}
}
NComboBox {
label: pluginApi?.tr("settings.iconSpacing.label")
description: pluginApi?.tr("settings.iconSpacing.desc")
model: {
const labels = ["XXS", "XS", "S", "M", "L", "XL"];
const values = [Style.marginXXS, Style.marginXS, Style.marginS, Style.marginM, Style.marginL, Style.marginXL];
const result = [];
for (var i = 0; i < labels.length; ++i) {
const v = values[i];
result.push({
key: v.toFixed(0),
name: `${labels[i]} (${v}px)`
});
}
return result;
}
// INFO: From my understanding, the toFixed(0) shouldn't be needed here and there, but without the
// current key does not show when opening the settings window.
currentKey: root.iconSpacing.toFixed(0)
onSelected: key => root.iconSpacing = key
}
}
function saveSettings() {
if (!pluginApi) {
Logger.e("PrivacyIndicator", "Cannot save settings: pluginApi is null");
return;
}
pluginApi.pluginSettings.hideInactive = root.hideInactive;
pluginApi.pluginSettings.iconSpacing = root.iconSpacing;
pluginApi.pluginSettings.removeMargins = root.removeMargins;
pluginApi.saveSettings();
Logger.i("PrivacyIndicator", "Settings saved successfully");
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Mikrofon-, Kamera- und Bildschirmsymbole ausblenden, wenn sie inaktiv sind.",
"label": "Inaktive Zustände ausblenden"
},
"iconSpacing": {
"desc": "Den Abstand zwischen den Symbolen festlegen.",
"label": "Symbolabstand"
},
"removeMargins": {
"desc": "Alle äußeren Ränder des Widgets entfernen.",
"label": "Ränder entfernen"
}
},
"tooltip": {
"cam-on": "Kamera: {apps}",
"mic-on": "Mikrofon: {apps}",
"screen-on": "Bildschirmfreigabe: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Hide microphone, camera, and screen icons when there are inactive.",
"label": "Hide inactive states"
},
"iconSpacing": {
"desc": "Set the spacing between the icons.",
"label": "Icon spacing"
},
"removeMargins": {
"desc": "Remove all outer margins of the widget.",
"label": "Remove margins"
}
},
"tooltip": {
"cam-on": "Camera: {apps}",
"mic-on": "Microphone: {apps}",
"screen-on": "Screen sharing: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Ocultar los iconos de micrófono, cámara y pantalla cuando estén inactivos.",
"label": "Ocultar estados inactivos"
},
"iconSpacing": {
"desc": "Configurar el espacio entre los iconos.",
"label": "Espaciado de iconos"
},
"removeMargins": {
"desc": "Quita todos los márgenes externos del widget.",
"label": "Quitar márgenes"
}
},
"tooltip": {
"cam-on": "Cámara: {apps}",
"mic-on": "Micrófono: {apps}",
"screen-on": "Compartir pantalla: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Masquer les icônes du micro, de la caméra et de lécran lorsquils sont inactifs.",
"label": "Masquer les états inactifs"
},
"iconSpacing": {
"desc": "Définir lespacement entre les icônes.",
"label": "Espacement des icônes"
},
"removeMargins": {
"desc": "Supprime toutes les marges extérieures du widget.",
"label": "Supprimer les marges"
}
},
"tooltip": {
"cam-on": "Caméra: {apps}",
"mic-on": "Microphone: {apps}",
"screen-on": "Partage d'écran: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Nascondi le icone di microfono, fotocamera e schermo quando sono inattive.",
"label": "Nascondi stati inattivi"
},
"iconSpacing": {
"desc": "Imposta la distanza tra le icone.",
"label": "Spaziatura delle icone"
},
"removeMargins": {
"desc": "Rimuove tutti i margini esterni del widget.",
"label": "Rimuovi margini"
}
},
"tooltip": {
"cam-on": "Kamera: {apps}",
"mic-on": "Mikrofonoa: {apps}",
"screen-on": "Ekran-partaĝado: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "マイク・カメラ・画面共有のアイコンを、非アクティブなときは非表示にします。",
"label": "非アクティブ状態を非表示"
},
"iconSpacing": {
"desc": "アイコン同士の間隔を設定します。",
"label": "アイコン間隔"
},
"removeMargins": {
"desc": "ウィジェットの外側の余白をすべて削除します。",
"label": "余白を削除"
}
},
"tooltip": {
"cam-on": "カメラ: {apps}",
"mic-on": "マイク: {apps}",
"screen-on": "画面共有: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Verberg de pictogrammen voor microfoon, camera en scherm wanneer ze inactief zijn.",
"label": "Inactieve status verbergen"
},
"iconSpacing": {
"desc": "Stel de afstand tussen de pictogrammen in.",
"label": "Pictogramafstand"
},
"removeMargins": {
"desc": "Verwijdert alle buitenste marges van de widget.",
"label": "Marges verwijderen"
}
},
"tooltip": {
"cam-on": "Camera: {apps}",
"mic-on": "Microfoon: {apps}",
"screen-on": "Schermdeling: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Oculta os ícones de microfone, câmera e tela quando estiverem inativos.",
"label": "Ocultar estados inativos"
},
"iconSpacing": {
"desc": "Define o espaçamento entre os ícones.",
"label": "Espaçamento dos ícones"
},
"removeMargins": {
"desc": "Remove todas as margens externas do widget.",
"label": "Remover margens"
}
},
"tooltip": {
"cam-on": "Câmera: {apps}",
"mic-on": "Microfone: {apps}",
"screen-on": "Compartilhamento de tela: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Скрывать значки микрофона, камеры и экрана, когда они неактивны.",
"label": "Скрывать неактивные состояния"
},
"iconSpacing": {
"desc": "Задать расстояние между значками.",
"label": "Интервал между значками"
},
"removeMargins": {
"desc": "Удаляет все внешние отступы виджета.",
"label": "Убрать отступы"
}
},
"tooltip": {
"cam-on": "Камера: {apps}",
"mic-on": "Микрофон: {apps}",
"screen-on": "Демонстрация экрана: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Mikrofon, kamera ve ekran simgelerini pasif olduklarında gizle.",
"label": "Pasif durumları gizle"
},
"iconSpacing": {
"desc": "Simgeler arasındaki boşluğu ayarla.",
"label": "Simge aralığı"
},
"removeMargins": {
"desc": "Widgetın tüm dış kenar boşluklarını kaldırır.",
"label": "Kenarlıkları kaldır"
}
},
"tooltip": {
"cam-on": "Kamera: {apps}",
"mic-on": "Mikrofon: {apps}",
"screen-on": "Ekran paylaşımı: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "Приховувати значки мікрофона, камери та екрана, коли вони неактивні.",
"label": "Приховувати неактивні стани"
},
"iconSpacing": {
"desc": "Встановити відстань між значками.",
"label": "Інтервал між значками"
},
"removeMargins": {
"desc": "Видаляє всі зовнішні відступи віджета.",
"label": "Прибрати відступи"
}
},
"tooltip": {
"cam-on": "Камера: {apps}",
"mic-on": "Мікрофон: {apps}",
"screen-on": "Демонстрація екрана: {apps}"
}
}

View File

@@ -0,0 +1,21 @@
{
"settings": {
"hideInactive": {
"desc": "在麦克风、摄像头和屏幕图标处于非活动状态时将其隐藏。",
"label": "隐藏非活动状态"
},
"iconSpacing": {
"desc": "设置图标之间的间距。",
"label": "图标间距"
},
"removeMargins": {
"desc": "移除小部件所有外部边距。",
"label": "移除边距"
}
},
"tooltip": {
"cam-on": "摄像头: {apps}",
"mic-on": "麦克风: {apps}",
"screen-on": "屏幕共享: {apps}"
}
}

View File

@@ -0,0 +1,23 @@
{
"id": "privacy-indicator",
"name": "Privacy Indicator",
"version": "1.0.10",
"minNoctaliaVersion": "3.6.0",
"author": "Noctalia Team <team@noctalia.dev>",
"license": "MIT",
"repository": "https://github.com/noctalia-dev/noctalia-plugins",
"description": "A privacy indicator widget that shows when microphone, camera or screen sharing is active.",
"entryPoints": {
"barWidget": "BarWidget.qml",
"settings": "Settings.qml"
},
"dependencies": {
"plugins": []
},
"metadata": {
"defaultSettings": {
"hideInactive": false,
"removeMargins": false
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,5 @@
{
"hideInactive": true,
"iconSpacing": 4,
"removeMargins": false
}