diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml new file mode 100644 index 0000000..594964c --- /dev/null +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -0,0 +1,103 @@ +// Privacy & Security. +// +// GNOME's Privacy panel covers screen lock, camera and microphone access, file +// history, trash, and device security. Panama covers the parts it genuinely +// owns and is explicit about the parts it does not. +// +// The file-history and trash settings are the notable omission, and the reason +// is worth stating: those are GNOME preferences enforced by gsd-housekeeping, +// which is not running in a Hyprland session. Offering switches for them would +// store a preference, change nothing, and give no sign of it -- the exact +// failure this codebase keeps designing against. So they are delegated by name +// rather than reimplemented as controls that lie. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Privacy & Security" + lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0 + ? "Screen lock, device access, and a machine whose security settings all check out." + : "Screen lock, which applications can see you, and how this machine is protected." + + Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh() + + SettingsCard { + title: "Screen lock" + subtitle: "The same settings as Power & Lock, which is where the idle timings live." + + SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } + ToggleRow { setting: "lockOnSleep"; divider: false } + } + + SettingsCard { + title: "Camera & microphone" + subtitle: PrivacyState.anyActive + ? "In use right now — the bar shows an indicator whenever this is true." + : "Nothing is using your camera or microphone." + + TextRow { + label: "Camera" + detail: PrivacyState.cameraActive + ? "In use by " + (PrivacyState.cameraApp || "an application") + : "Not in use" + value: PrivacyState.cameraActive ? "Active" : "Idle" + } + + TextRow { + label: "Microphone" + detail: PrivacyState.microphoneActive + ? "In use by " + (PrivacyState.microphoneApp || "an application") + : "Not in use" + value: PrivacyState.microphoneActive ? "Active" : "Idle" + } + + TextRow { + label: "Screen sharing" + detail: PrivacyState.screenSharingActive + ? "Being shared by " + (PrivacyState.screenSharingApp || "an application") + : "Not being shared" + value: PrivacyState.screenSharingActive ? "Active" : "Idle" + divider: false + } + } + + SettingsCard { + title: "Device security" + subtitle: DeviceSecurity.attentionCount === 0 + ? "Everything below is in its recommended state." + : DeviceSecurity.attentionCount + " item" + + (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention." + + Repeater { + model: DeviceSecurity.facts + + TextRow { + id: factRow + required property var modelData + required property int index + + label: factRow.modelData.label + detail: factRow.modelData.detail + value: factRow.modelData.value + divider: factRow.index < DeviceSecurity.facts.length - 1 + } + } + } + + SettingsCard { + title: "Owned by Fedora" + subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. Panama does not offer them as switches, because storing that preference here would change nothing." + + ActionRow { + label: "File history & trash" + detail: "Opens GNOME Settings, which owns these" + action: "Open privacy" + divider: false + onTriggered: SystemSettings.openGnomePanel("privacy") + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index def527a..033cdf6 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -100,6 +100,7 @@ Rectangle { case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; case "mouse": return mousePage; + case "privacy": return privacyPage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -155,6 +156,7 @@ Rectangle { Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } Component { id: mousePage; MousePage {} } + Component { id: privacyPage; PrivacyPage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index bfe4d36..d82a99c 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -33,6 +33,7 @@ Rectangle { { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" }, { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, + { page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index bf3768f..464b268 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -48,3 +48,4 @@ LocationPicker 1.0 LocationPicker.qml FontPicker 1.0 FontPicker.qml MousePage 1.0 MousePage.qml TextEntryRow 1.0 TextEntryRow.qml +PrivacyPage 1.0 PrivacyPage.qml diff --git a/config/dot/quickshell/scripts/panama-security b/config/dot/quickshell/scripts/panama-security new file mode 100755 index 0000000..6dcfcb8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-security @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Device security facts, as JSON. +# +# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence, +# disk encryption, SELinux mode and the firewall are set in firmware, at install +# time, or by system policy -- none of them is a desktop preference, and a +# settings app that offered to toggle them would either fail or do something +# far-reaching from a switch that looks like any other. +# +# What it is for is answering "is this machine set up the way I think it is", +# which is the question GNOME's Device Security panel exists to answer and which +# otherwise needs five commands and root. +# +# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so +# the UI can highlight what deserves attention without hard-coding the meaning +# of each string. Anything that cannot be determined reports "Unknown" with +# ok:false rather than guessing, because a security readout that quietly reports +# "fine" when it failed to look is worse than no readout. + +set -uo pipefail + +fact() { + jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \ + '{label: $label, value: $value, ok: $ok, detail: $detail}' +} + +facts=() + +# ── Secure Boot ────────────────────────────────────────────────────────────── +if command -v mokutil >/dev/null 2>&1; then + case "$(mokutil --sb-state 2>/dev/null)" in + *"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;; + *"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;; + *) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;; + esac +elif [[ -d /sys/firmware/efi ]]; then + facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")") +else + facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")") +fi + +# ── TPM ────────────────────────────────────────────────────────────────────── +tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)" +if [[ -n "$tpm_major" ]]; then + facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")") +elif [[ -e /sys/class/tpm/tpm0 ]]; then + facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")") +else + facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")") +fi + +# ── Disk encryption ────────────────────────────────────────────────────────── +# Counts LUKS mappings rather than naming them: which volume is encrypted is +# more detail than this readout needs, and device names are not meaningful here. +crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)" +[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0 +if (( crypt_count > 0 )); then + facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")") +else + facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")") +fi + +# ── SELinux ────────────────────────────────────────────────────────────────── +if command -v getenforce >/dev/null 2>&1; then + case "$(getenforce 2>/dev/null)" in + Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;; + Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;; + Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;; + *) facts+=("$(fact "SELinux" "Unknown" false "")") ;; + esac +fi + +# ── Firewall ───────────────────────────────────────────────────────────────── +if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then + if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then + facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")") + else + facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")") + fi +fi + +printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")" diff --git a/config/dot/quickshell/services/DeviceSecurity.qml b/config/dot/quickshell/services/DeviceSecurity.qml new file mode 100644 index 0000000..500d9ea --- /dev/null +++ b/config/dot/quickshell/services/DeviceSecurity.qml @@ -0,0 +1,54 @@ +pragma Singleton + +// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux, +// firewall. +// +// Nothing here is a preference. These are set in firmware, at install time, or +// by system policy, and a settings app that offered to change them from a +// switch would either fail or do something far-reaching from a control that +// looks like every other control. What this answers is "is this machine set up +// the way I think it is", which otherwise takes five commands and root. +// +// Read on demand. None of these can change while the desktop is running, +// short of a reboot. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security" + + // [{ label, value, ok, detail }] + property var facts: [] + property bool scanned: false + + // The facts that are not in their reassuring state. The page leads with the + // count so a machine that is entirely fine says so in one line instead of + // making the user read five rows to find out. + readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length + + function refresh(): void { + if (!query.running) + query.running = true; + } + + Process { + id: query + command: [root.helperPath] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.facts = Array.isArray(parsed) ? parsed : []; + } catch (error) { + root.facts = []; + console.warn("DeviceSecurity: could not parse helper output:", error); + } + root.scanned = true; + } + } + } +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 1b6c9f8..5a84298 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true;