diff --git a/Modules/Settings/WidgetsTab.qml b/Modules/Settings/WidgetsTab.qml index fc3a8b65..4d5cfdd3 100644 --- a/Modules/Settings/WidgetsTab.qml +++ b/Modules/Settings/WidgetsTab.qml @@ -60,6 +60,12 @@ ScrollView { "description": "System notification area icons", "icon": "notifications", "enabled": true + }, { + "id": "privacyIndicator", + "text": "Privacy Indicator", + "description": "Shows when microphone, camera, or screen sharing is active", + "icon": "privacy_tip", + "enabled": true }, { "id": "controlCenterButton", "text": "Control Center", @@ -112,6 +118,9 @@ ScrollView { "enabled": true }] property var defaultRightWidgets: [{ + "id": "privacyIndicator", + "enabled": true + }, { "id": "systemTray", "enabled": true }, { @@ -619,7 +628,7 @@ ScrollView { border.width: 1 visible: resetArea.containsMouse opacity: resetArea.containsMouse ? 1 : 0 - y: column.y + 48 // Position above the reset button in the header + y: column.y + 48 x: parent.width - width - Theme.spacingM z: 100 diff --git a/Modules/TopBar/PrivacyIndicator.qml b/Modules/TopBar/PrivacyIndicator.qml new file mode 100644 index 00000000..8238ada6 --- /dev/null +++ b/Modules/TopBar/PrivacyIndicator.qml @@ -0,0 +1,173 @@ +import QtQuick +import QtQuick.Controls +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + property string section: "right" + property var popupTarget: null + property var parentScreen: null + + readonly property bool hasActivePrivacy: PrivacyService.anyPrivacyActive + readonly property int activeCount: (PrivacyService.microphoneActive ? 1 : 0) + (PrivacyService.cameraActive ? 1 : 0) + (PrivacyService.screensharingActive ? 1 : 0) + + width: hasActivePrivacy ? (activeCount > 1 ? 80 : 60) : 0 + height: hasActivePrivacy ? 30 : 0 + radius: Theme.cornerRadius + visible: hasActivePrivacy + opacity: hasActivePrivacy ? 1 : 0 + + color: { + const baseColor = privacyArea.containsMouse ? Theme.errorPressed : Theme.errorHover; + return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency); + } + + MouseArea { + id: privacyArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + } + } + + Row { + anchors.centerIn: parent + spacing: Theme.spacingXS + visible: hasActivePrivacy + + Item { + width: 18 + height: 18 + visible: PrivacyService.microphoneActive + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + name: "mic" + size: Theme.iconSizeSmall + color: Theme.error + filled: true + anchors.centerIn: parent + } + + } + + Item { + width: 18 + height: 18 + visible: PrivacyService.cameraActive + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + name: "camera_video" + size: Theme.iconSizeSmall + color: Theme.surfaceText + filled: true + anchors.centerIn: parent + } + + Rectangle { + width: 6 + height: 6 + radius: 3 + color: Theme.error + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: -2 + anchors.topMargin: -1 + + SequentialAnimation on opacity { + running: parent.visible + loops: Animation.Infinite + + NumberAnimation { + to: 0.3 + duration: Theme.longDuration + } + NumberAnimation { + to: 1.0 + duration: Theme.longDuration + } + } + } + } + + Item { + width: 18 + height: 18 + visible: PrivacyService.screensharingActive + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + name: "screen_share" + size: Theme.iconSizeSmall + color: Theme.warning + filled: true + anchors.centerIn: parent + } + } + } + + Behavior on width { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Behavior on opacity { + enabled: !hasActivePrivacy + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + + Rectangle { + id: tooltip + + width: tooltipText.contentWidth + Theme.spacingM * 2 + height: tooltipText.contentHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.popupBackground() + border.color: Theme.outlineMedium + border.width: 1 + visible: privacyArea.containsMouse && hasActivePrivacy + opacity: visible ? 1 : 0 + z: 100 + + x: (parent.width - width) / 2 + y: -height - Theme.spacingXS + + StyledText { + id: tooltipText + anchors.centerIn: parent + text: PrivacyService.getPrivacySummary() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Rectangle { + width: 8 + height: 8 + color: parent.color + border.color: parent.border.color + border.width: parent.border.width + rotation: 45 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.bottom + anchors.topMargin: -4 + } + } +} diff --git a/Modules/TopBar/TopBar.qml b/Modules/TopBar/TopBar.qml index f7aa1a82..0d0095af 100644 --- a/Modules/TopBar/TopBar.qml +++ b/Modules/TopBar/TopBar.qml @@ -176,6 +176,8 @@ PanelWindow { return true; case "systemTray": return true; + case "privacyIndicator": + return true; case "clipboard": return true; case "systemResources": @@ -211,6 +213,8 @@ PanelWindow { return weatherComponent; case "systemTray": return systemTrayComponent; + case "privacyIndicator": + return privacyIndicatorComponent; case "clipboard": return clipboardComponent; case "systemResources": @@ -552,6 +556,21 @@ PanelWindow { } + Component { + id: privacyIndicatorComponent + + PrivacyIndicator { + section: { + if (parent && parent.parent === leftSection) return "left"; + if (parent && parent.parent === rightSection) return "right"; + if (parent && parent.parent === centerSection) return "center"; + return "right"; + } + parentScreen: root.screen + } + + } + Component { id: clipboardComponent diff --git a/Services/PrivacyService.qml b/Services/PrivacyService.qml new file mode 100644 index 00000000..2d047694 --- /dev/null +++ b/Services/PrivacyService.qml @@ -0,0 +1,161 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire + +Singleton { + id: root + + property bool microphoneActive: false + property bool cameraActive: false + property bool screensharingActive: false + + readonly property bool anyPrivacyActive: microphoneActive || cameraActive || screensharingActive + + readonly property var micSource: AudioService.source + property var activeMicSources: [] + property var activeCameraSources: [] + property var activeScreenSources: [] + + function resetPrivacyStates() { + root.cameraActive = false; + root.screensharingActive = false; + root.microphoneActive = false; + } + + Process { + id: portalMonitor + command: ["bash", "-c", ` + screencast_count=0 + camera_count=0 + microphone_count=0 + + if command -v lsof >/dev/null 2>&1; then + video_device_users=$(lsof /dev/video* 2>/dev/null | wc -l || echo "0") + if [ "$video_device_users" -gt 0 ]; then + camera_count=1 + fi + + if command -v busctl >/dev/null 2>&1; then + mic_access_count=$(busctl --user call org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop org.freedesktop.DBus.Properties Get ss "org.freedesktop.portal.Inhibit" "Inhibited" 2>/dev/null | grep -c "microphone" || echo "0") + if [ "$mic_access_count" -gt 0 ]; then + microphone_count=1 + fi + fi + + if [ "$microphone_count" -eq 0 ] && command -v pactl >/dev/null 2>&1; then + total_outputs=$(pactl list short source-outputs | wc -l || echo "0") + system_outputs=$(pactl list source-outputs | grep -c "media\\.name.*cava" || echo "0") + user_outputs=$((total_outputs - system_outputs)) + if [ "$user_outputs" -gt 0 ]; then + microphone_count=1 + fi + fi + fi + + if command -v busctl >/dev/null 2>&1; then + screencast_sessions=$(busctl --user list | grep "org.freedesktop.portal.Session" | wc -l || echo "0") + if [ "$screencast_sessions" -gt 0 ]; then + screencast_count=1 + fi + fi + + if command -v pw-dump >/dev/null 2>&1; then + active_video_streams=$(pw-dump 2>/dev/null | grep -i "video" | grep -i "state.*running" | wc -l || echo "0") + if [ "$active_video_streams" -gt 0 ]; then + camera_count=1 + fi + + active_screen_streams=$(pw-dump 2>/dev/null | grep -i "screen" | grep -i "state.*running" | wc -l || echo "0") + if [ "$active_screen_streams" -gt 0 ]; then + screencast_count=1 + fi + fi + + echo "screencast:$screencast_count" + echo "camera:$camera_count" + echo "microphone:$microphone_count" + `] + + stdout: StdioCollector { + onStreamFinished: { + root.resetPrivacyStates(); + + if (text && text.length > 0) { + const lines = text.trim().split('\n'); + let foundScreencast = false; + let foundCamera = false; + let foundMicrophone = false; + + for (const line of lines) { + if (line.startsWith('screencast:')) { + const count = parseInt(line.split(':')[1]) || 0; + foundScreencast = foundScreencast || (count > 0); + } else if (line.startsWith('camera:')) { + const count = parseInt(line.split(':')[1]) || 0; + foundCamera = foundCamera || (count > 0); + } else if (line.startsWith('microphone:')) { + const count = parseInt(line.split(':')[1]) || 0; + foundMicrophone = foundMicrophone || (count > 0); + } + } + + root.screensharingActive = foundScreencast; + root.cameraActive = foundCamera; + root.microphoneActive = foundMicrophone; + } else { + root.screensharingActive = false; + root.cameraActive = false; + root.microphoneActive = false; + } + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + console.warn("PrivacyService: Portal monitor process failed with exit code:", exitCode); + } + } + } + + Timer { + id: privacyMonitor + interval: 3000 + running: true + repeat: true + onTriggered: { + if (!portalMonitor.running) { + portalMonitor.running = true; + } + } + } + + Component.onCompleted: { + } + + function getMicrophoneStatus() { + return microphoneActive ? "active" : "inactive"; + } + + function getCameraStatus() { + return cameraActive ? "active" : "inactive"; + } + + function getScreensharingStatus() { + return screensharingActive ? "active" : "inactive"; + } + + function getPrivacySummary() { + const active = []; + if (microphoneActive) active.push("microphone"); + if (cameraActive) active.push("camera"); + if (screensharingActive) active.push("screensharing"); + + return active.length > 0 ? + "Privacy active: " + active.join(", ") : + "No privacy concerns detected"; + } +} \ No newline at end of file