diff --git a/config/dot/quickshell/modules/settings/HealthCheckRow.qml b/config/dot/quickshell/modules/settings/HealthCheckRow.qml new file mode 100644 index 0000000..eafd193 --- /dev/null +++ b/config/dot/quickshell/modules/settings/HealthCheckRow.qml @@ -0,0 +1,113 @@ +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + required property var check + property bool issue: false + property bool divider: true + signal actionRequested(var check) + + objectName: `health-check-row:${root.check.id}:${root.issue ? "issue" : "ledger"}` + implicitHeight: 62 + + function statusLabel(status: string): string { + if (status === "ok") return "Healthy"; + if (status === "warning") return "Needs attention"; + if (status === "error") return "Action required"; + return "Not set up"; + } + + function statusColor(status: string): color { + if (status === "ok") return Theme.ok; + if (status === "warning") return Theme.warn; + if (status === "error") return Theme.danger; + return Theme.fgMuted; + } + + Rectangle { + id: issueMark + + visible: root.issue + width: 7 + height: 7 + radius: 4 + anchors.left: parent.left + anchors.leftMargin: 1 + anchors.verticalCenter: parent.verticalCenter + color: root.statusColor(root.check.status) + border.width: 1 + border.color: Theme.alpha(Theme.bgPanel, 0.9) + } + + Column { + anchors.left: parent.left + anchors.leftMargin: root.issue ? 22 : 0 + anchors.right: trailing.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.check.title + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.check.detail + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + Row { + id: trailing + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Text { + objectName: `health-status-text:${root.check.id}:${root.issue ? "issue" : "ledger"}` + anchors.verticalCenter: parent.verticalCenter + text: root.statusLabel(root.check.status) + color: root.statusColor(root.check.status) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + SettingsButton { + id: actionButton + + objectName: `health-row-action:${root.check.id}:${root.issue ? "issue" : "ledger"}` + visible: root.check.action !== undefined + text: Health.repairingId === root.check.id ? "Working…" : (root.check.action?.label ?? "") + enabled: visible && Health.repairingId !== root.check.id && !Health.busy + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: root.actionRequested(root.check) + Keys.onReturnPressed: if (enabled) root.actionRequested(root.check) + Keys.onSpacePressed: if (enabled) root.actionRequested(root.check) + } + } + + Rectangle { + visible: root.divider + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.alpha(Theme.fg, 0.07) + } +} diff --git a/config/dot/quickshell/modules/settings/HealthPage.qml b/config/dot/quickshell/modules/settings/HealthPage.qml new file mode 100644 index 0000000..0e22505 --- /dev/null +++ b/config/dot/quickshell/modules/settings/HealthPage.qml @@ -0,0 +1,362 @@ +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + objectName: "system-health-page" + + title: "System Health" + lede: "Panama checks the parts of your desktop it owns and explains what needs attention." + + property var pendingConfirmation: null + property string instructionTarget: "" + + readonly property var issueChecks: Health.checks.filter(check => check.status === "warning" || check.status === "error") + readonly property var groups: [ + { + group: "desktop-foundation", + title: "Desktop foundation", + subtitle: "Compositor, shell, portals, wallpaper, idle policy, and launcher." + }, + { + group: "input-media", + title: "Input & media", + subtitle: "Sound, clipboard, capture, OCR, and display controls." + }, + { + group: "integrations", + title: "Integrations", + subtitle: "Only configured integrations affect health." + }, + { + group: "panama-tools", + title: "Panama tools", + subtitle: "Tracked links, launcher commands, apps, and inhibitors." + } + ] + readonly property var applicationTargets: ({ + "integration.nextcloud": "nextcloud", + "integration.rustdesk": "rustdesk", + "integration.kdeconnect": "kdeconnect", + "integration.bluebubbles": "bluebubbles" + }) + + function statusLabel(status: string): string { + if (status === "ok") return "Healthy"; + if (status === "warning") return "Needs attention"; + if (status === "error") return "Action required"; + return "Not set up"; + } + + function checksForGroup(group: string): var { + return Health.checks.filter(check => check.group === group); + } + + function handleAction(check: var): void { + if (!check || !check.action) + return; + + if (check.action.kind === "open") { + if (check.action.target) { + ShellState.openSettings(check.action.target); + return; + } + const application = root.applicationTargets[check.id]; + if (application) + SystemSettings.openApplication(application); + return; + } + + if (check.action.kind === "instructions") { + root.instructionTarget = check.action.target || ""; + return; + } + + if (check.action.kind !== "repair") + return; + if (check.action.confirm) { + root.pendingConfirmation = check; + return; + } + Health.repair(check.id, false); + } + + function confirmRepair(): void { + const check = root.pendingConfirmation; + root.pendingConfirmation = null; + if (check) + Health.repair(check.id, false); + } + + function descendants(item: var, prefix: string): var { + let matches = []; + if (!item) + return matches; + if (String(item.objectName || "").indexOf(prefix) === 0) + matches.push(item); + for (const child of item.children || []) + matches = matches.concat(root.descendants(child, prefix)); + return matches; + } + + // Deterministic, read-only fixture seam used by the offscreen contract. + function uiDiagnostics(): var { + const summaries = root.descendants(root, "health-summary"); + const rows = root.descendants(root, "health-check-row:").filter(row => row.visible); + const copyButtons = root.descendants(root, "health-copy-report-button"); + const refreshButtons = root.descendants(root, "health-refresh-button"); + const rowActions = root.descendants(root, "health-row-action:").filter(button => button.visible); + const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible); + return { + issueIds: root.issueChecks.map(check => check.id), + desktopIds: root.checksForGroup("desktop-foundation").map(check => check.id), + integrationIds: root.checksForGroup("integrations").map(check => check.id), + statusLabels: Health.checks.map(check => root.statusLabel(check.status)), + summaryHeight: summaries.length > 0 ? summaries[0].height : 0, + rowHeights: rows.map(row => row.height), + checking: Health.busy, + checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "", + copyFocusable: copyButtons.length > 0 && copyButtons[0].activeFocusOnTab, + refreshFocusable: refreshButtons.length > 0 && refreshButtons[0].activeFocusOnTab, + rowActionFocusable: rowActions.length > 0 && rowActions[0].activeFocusOnTab, + confirmationVisible: root.pendingConfirmation !== null, + confirmationId: root.pendingConfirmation ? root.pendingConfirmation.id : "" + }; + } + + Component.onCompleted: Health.refresh() + + header: Component { + Rectangle { + objectName: "health-confirmation-sheet" + visible: root.pendingConfirmation !== null + implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0 + radius: Theme.cardRadius + color: Theme.mix(Theme.bgPanel, Theme.warn, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.warn, 0.34) + + Row { + id: confirmRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 16 + spacing: 14 + + Column { + width: parent.width - cancelButton.width - repairButton.width - 28 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.pendingConfirmation + ? `${root.pendingConfirmation.action.label}?` + : "Restart Panama?" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: "The desktop chrome will disappear briefly and return when Quickshell restarts." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + SettingsButton { + id: cancelButton + text: "Cancel" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: root.pendingConfirmation = null + Keys.onReturnPressed: root.pendingConfirmation = null + Keys.onSpacePressed: root.pendingConfirmation = null + } + + SettingsButton { + id: repairButton + text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart Panama" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: root.confirmRepair() + Keys.onReturnPressed: root.confirmRepair() + Keys.onSpacePressed: root.confirmRepair() + } + } + } + } + + HealthSummary {} + + SettingsCard { + visible: root.issueChecks.length > 0 + title: Health.status === "error" ? "Action required" : "Needs attention" + subtitle: Health.status === "error" + ? "Resolve these items first. Healthy systems remain listed below." + : "Nothing here prevents you from using the desktop." + + Item { + width: parent.width + implicitHeight: issueRows.implicitHeight + + Rectangle { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.top: parent.top + anchors.topMargin: 25 + anchors.bottom: parent.bottom + anchors.bottomMargin: 25 + width: 1 + visible: root.issueChecks.length > 1 + color: Theme.alpha(Health.status === "error" ? Theme.danger : Theme.warn, 0.44) + } + + Column { + id: issueRows + width: parent.width + + Repeater { + id: issueRepeater + model: root.issueChecks + + HealthCheckRow { + required property var modelData + required property int index + + width: issueRows.width + check: modelData + issue: true + divider: index < issueRepeater.count - 1 + onActionRequested: check => root.handleAction(check) + } + } + } + } + } + + SettingsCard { + visible: root.instructionTarget === "ddc-permissions" + title: "External monitor brightness" + subtitle: "Panama can see DDC/CI support, but this session cannot access the monitor bus." + + Item { + width: parent.width + implicitHeight: Math.max(instructionCopy.implicitHeight, doneButton.implicitHeight) + 8 + + Text { + id: instructionCopy + anchors.left: parent.left + anchors.right: doneButton.left + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + text: "Reload the installed udev rules and trigger the i2c-dev and DRM devices. Sign out and back in if monitor access is still unavailable." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + SettingsButton { + id: doneButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Done" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: root.instructionTarget = "" + Keys.onReturnPressed: root.instructionTarget = "" + Keys.onSpacePressed: root.instructionTarget = "" + } + } + } + + Grid { + id: groupGrid + + width: parent.width + columns: width >= 700 ? 2 : 1 + columnSpacing: 16 + rowSpacing: 16 + + Repeater { + model: root.groups + + SettingsCard { + required property var modelData + + width: groupGrid.columns === 2 + ? (groupGrid.width - groupGrid.columnSpacing) / 2 + : groupGrid.width + title: modelData.title + subtitle: modelData.subtitle + + Column { + id: groupRows + width: parent.width + + Repeater { + id: groupRepeater + model: root.checksForGroup(modelData.group) + + HealthCheckRow { + required property var modelData + required property int index + + width: groupRows.width + check: modelData + divider: index < groupRepeater.count - 1 + onActionRequested: check => root.handleAction(check) + } + } + } + } + } + } + + SettingsCard { + title: "Fedora system settings" + subtitle: "Network accounts, printers, users, and Fedora updates remain owned by system tools." + + Item { + width: parent.width + implicitHeight: 42 + + Text { + anchors.left: parent.left + anchors.right: gnomeSettingsButton.left + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + text: "Use GNOME Settings for the parts of the system Panama does not manage." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + SettingsButton { + id: gnomeSettingsButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Open GNOME Settings" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: SystemSettings.openGnomePanel("network") + Keys.onReturnPressed: SystemSettings.openGnomePanel("network") + Keys.onSpacePressed: SystemSettings.openGnomePanel("network") + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/HealthSummary.qml b/config/dot/quickshell/modules/settings/HealthSummary.qml new file mode 100644 index 0000000..d2d332d --- /dev/null +++ b/config/dot/quickshell/modules/settings/HealthSummary.qml @@ -0,0 +1,169 @@ +import QtQuick +import qs.config +import qs.services + +SettingsCard { + id: root + + objectName: "health-summary" + implicitHeight: 126 + + readonly property int observationCount: Health.summary.warnings + Health.summary.errors + readonly property string heroTitle: { + if (Health.diagnosticUnavailable) + return "Health check unavailable"; + if (Health.checks.length === 0) + return "Checking Panama desktop"; + if (Health.status === "error") + return "Action required"; + if (Health.status === "warning") + return "Needs attention"; + return "Healthy"; + } + readonly property string heroDetail: { + if (Health.diagnosticUnavailable) + return Health.lastError || "Panama could not complete the latest health check."; + if (Health.checks.length === 0) + return "Panama is checking the desktop services, tools, and integrations it owns."; + if (Health.status === "error") + return root.observationCount === 1 + ? "One part of the desktop needs action." + : `${root.observationCount} parts of the desktop need action.`; + if (Health.status === "warning") + return root.observationCount === 1 + ? "Your desktop is working. One feature needs a decision." + : `Your desktop is working. ${root.observationCount} features need a decision.`; + return "Panama-owned desktop services and tools are working normally."; + } + readonly property color statusColor: { + if (Health.diagnosticUnavailable || Health.status === "error") + return Theme.danger; + if (Health.status === "warning") + return Theme.warn; + if (Health.checks.length === 0) + return Theme.fgMuted; + return Theme.ok; + } + + function scanTime(): string { + const generatedAt = String(Health.snapshot.generatedAt || ""); + if (generatedAt === "") + return "No completed check yet"; + const date = new Date(generatedAt); + if (Number.isNaN(date.getTime())) + return "Last check completed"; + return `Last checked ${date.toLocaleTimeString(Qt.locale(), Locale.ShortFormat)}`; + } + + Item { + width: parent.width + implicitHeight: 96 + + Column { + anchors.left: parent.left + anchors.right: actions.left + anchors.rightMargin: 22 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + width: parent.width + text: Health.diagnosticUnavailable + ? "DIAGNOSTICS" + : root.observationCount > 0 + ? root.observationCount + (root.observationCount === 1 ? " OBSERVATION" : " OBSERVATIONS") + : "PANAMA DESKTOP" + color: root.statusColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.letterSpacing: 1.15 + } + + Text { + width: parent.width + text: root.heroTitle + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: 24 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.heroDetail + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + Row { + spacing: 10 + + Text { + text: root.scanTime() + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + objectName: "health-checking-label" + visible: Health.busy + text: "Checking…" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + visible: Health.lastCopyResult !== "" + text: Health.lastCopyResult + color: Health.lastCopyResult === "Report copied." ? Theme.ok : Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } + + Row { + id: actions + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + id: copyButton + + objectName: "health-copy-report-button" + visible: !Health.diagnosticUnavailable + text: "Copy report" + enabled: Health.checks.length > 0 + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: Health.copyReport() + Keys.onReturnPressed: if (enabled) Health.copyReport() + Keys.onSpacePressed: if (enabled) Health.copyReport() + } + + SettingsButton { + id: refreshButton + + objectName: "health-refresh-button" + text: Health.diagnosticUnavailable ? "Retry" : "Refresh" + tone: Health.diagnosticUnavailable ? "normal" : "accent" + enabled: !Health.busy + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1) + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: Health.refresh() + Keys.onReturnPressed: if (enabled) Health.refresh() + Keys.onSpacePressed: if (enabled) Health.refresh() + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/ServicesPage.qml b/config/dot/quickshell/modules/settings/ServicesPage.qml deleted file mode 100644 index c6e9bd4..0000000 --- a/config/dot/quickshell/modules/settings/ServicesPage.qml +++ /dev/null @@ -1,133 +0,0 @@ -import QtQuick -import qs.config -import qs.services - -SettingsPage { - title: "Startup & Services" - lede: "A clear view of the background tools that make the desktop feel complete." - - function status(active: bool): string { - return active ? "Running" : "Stopped"; - } - - Item { - width: parent.width - implicitHeight: refresh.implicitHeight - - SettingsButton { - id: refresh - anchors.right: parent.right - text: SystemSettings.busy ? "Refreshing…" : "Refresh" - enabled: !SystemSettings.busy - onClicked: SystemSettings.refresh() - } - } - - SettingsCard { - title: "Your services" - - SettingRow { - label: "Nextcloud" - detail: "File synchronization and tray status" - controlWidth: 190 - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 10 - Text { - anchors.verticalCenter: parent.verticalCenter - text: status(SystemSettings.nextcloudActive) - color: Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - SettingsButton { - text: "Open" - onClicked: SystemSettings.openApplication("nextcloud") - } - } - } - - SettingRow { - label: "RustDesk" - detail: "Remote access through the enabled system service" - controlWidth: 190 - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 10 - Text { - anchors.verticalCenter: parent.verticalCenter - text: status(SystemSettings.rustdeskActive) - color: Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - SettingsButton { - text: "Open" - onClicked: SystemSettings.openApplication("rustdesk") - } - } - } - - SettingRow { - label: "KDE Connect" - detail: "Phone pairing, clipboard, files, and remote controls" - divider: false - controlWidth: 190 - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 10 - Text { - anchors.verticalCenter: parent.verticalCenter - text: status(SystemSettings.kdeconnectActive) - color: Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - SettingsButton { - text: "Open" - onClicked: SystemSettings.openApplication("kdeconnect") - } - } - } - } - - SettingsCard { - title: "Desktop foundation" - - TextRow { - label: "Hyprpaper" - detail: "Wallpaper service" - value: status(SystemSettings.hyprpaperActive) - } - TextRow { - label: "Hypridle" - detail: "Idle and lock policy" - value: status(SystemSettings.hypridleActive) - } - TextRow { - label: "Vicinae" - detail: "Spotlight-style launcher daemon" - value: status(SystemSettings.vicinaeActive) - divider: false - } - } - - SettingsCard { - title: "Fedora system settings" - subtitle: "These remain owned by trusted system services and GNOME's mature panels." - - ActionRow { - label: "Network, Bluetooth, printers, users, and accounts" - detail: "GNOME Settings remains searchable from the launcher too" - divider: false - action: "Open network" - onTriggered: SystemSettings.openGnomePanel("network") - } - } -} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index d1ef625..5fae1af 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -11,6 +11,23 @@ Rectangle { && pageLoader.item.objectName === "home-phone-page" ? pageLoader.item.pageDiagnostics : ({}) + readonly property var healthDiagnostics: pageLoader.status === Loader.Ready + && pageLoader.item + && pageLoader.item.objectName === "system-health-page" + ? pageLoader.item.uiDiagnostics() + : ({}) + + function requestHealthAction(id: string): bool { + if (pageLoader.status !== Loader.Ready + || !pageLoader.item + || pageLoader.item.objectName !== "system-health-page") + return false; + const check = Health.checks.find(candidate => candidate.id === id); + if (!check) + return false; + pageLoader.item.handleAction(check); + return true; + } color: Theme.bg radius: 18 @@ -103,7 +120,7 @@ Rectangle { case "power": return powerPage; case "datetime": return dateTimePage; case "applications": return applicationsPage; - case "services": return servicesPage; + case "services": return healthPage; case "about": return aboutPage; default: return homePage; } @@ -153,7 +170,7 @@ Rectangle { Component { id: notificationsPage; NotificationsPage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } - Component { id: servicesPage; ServicesPage {} } + Component { id: healthPage; HealthPage {} } Component { id: aboutPage; AboutPage {} } Shortcut { diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 38e009b..eb2b502 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -36,7 +36,7 @@ Rectangle { { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "applications", label: "Applications", icon: "\u{F003B}" }, - { page: "services", label: "Startup & Services", icon: "\u{F0493}" }, + { page: "services", label: "System Health", icon: "\u{F0493}" }, { page: "about", label: "About Panama", icon: "\u{F02FD}" } ] @@ -297,8 +297,34 @@ Rectangle { anchors.right: parent.right anchors.bottom: parent.bottom height: 54 - color: Theme.alpha(Theme.bg, 0.35) - border.width: 0 + activeFocusOnTab: true + color: footerTap.hovered || activeFocus + ? Theme.alpha(Theme.fg, 0.07) + : Theme.alpha(Theme.bg, 0.35) + border.width: activeFocus ? 2 : 0 + border.color: Theme.accent + + function footerText(): string { + if (Health.checks.length === 0) + return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking Panama desktop"; + if (Health.status === "error") + return "Panama requires attention"; + if (Health.status === "warning") { + const count = Health.summary.warnings + Health.summary.errors; + return count + (count === 1 ? " health observation" : " health observations"); + } + return "Panama desktop is healthy"; + } + + function footerColor(): color { + if (Health.checks.length === 0) + return Health.diagnosticUnavailable ? Theme.danger : Theme.fgMuted; + if (Health.status === "error") + return Theme.danger; + if (Health.status === "warning") + return Theme.warn; + return Theme.ok; + } Rectangle { width: 7 @@ -307,17 +333,28 @@ Rectangle { anchors.left: parent.left anchors.leftMargin: 19 anchors.verticalCenter: parent.verticalCenter - color: Theme.ok + color: healthFooter.footerColor() } Text { anchors.left: parent.left anchors.leftMargin: 36 + anchors.right: parent.right + anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: "Panama desktop is healthy" + text: healthFooter.footerText() color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight } + + TapHandler { + id: footerTap + onTapped: root.pageRequested("services") + } + + Keys.onReturnPressed: root.pageRequested("services") + Keys.onSpacePressed: root.pageRequested("services") } } diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 231ef52..1b2873f 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -10,7 +10,9 @@ DisplaysPage 1.0 DisplaysPage.qml HomePage 1.0 HomePage.qml NotificationsPage 1.0 NotificationsPage.qml ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml -ServicesPage 1.0 ServicesPage.qml +HealthPage 1.0 HealthPage.qml +HealthSummary 1.0 HealthSummary.qml +HealthCheckRow 1.0 HealthCheckRow.qml SettingRow 1.0 SettingRow.qml SettingsCard 1.0 SettingsCard.qml SettingsButton 1.0 SettingsButton.qml diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 943b41b..de1f26f 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -49,7 +49,9 @@ Singleton { { label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" }, { label: "Default applications", detail: "Browser, mail, files", page: "applications" }, { label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" }, - { label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" } + { label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" }, + { label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" }, + { label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" } ] function pageFor(group: string): string { diff --git a/tests/quickshell/health-ui-contract.sh b/tests/quickshell/health-ui-contract.sh new file mode 100755 index 0000000..d8a4704 --- /dev/null +++ b/tests/quickshell/health-ui-contract.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash + +# The approved Diagnostic Ledger is exercised in an isolated Quickshell +# harness. It never maps or reloads the user's production shell. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" + +fail() { + printf 'health UI contract: %s\n' "$1" >&2 + exit 1 +} + +for file in HealthPage.qml HealthSummary.qml HealthCheckRow.qml; do + [[ -f "$settings_dir/$file" ]] || fail "$file is missing" +done +[[ ! -e "$settings_dir/ServicesPage.qml" ]] || fail 'ServicesPage.qml still exists' +rg -Fq 'HealthPage 1.0 HealthPage.qml' "$settings_dir/qmldir" \ + || fail 'HealthPage is not registered in the Settings QML module' +rg -Fq 'HealthSummary 1.0 HealthSummary.qml' "$settings_dir/qmldir" \ + || fail 'HealthSummary is not registered in the Settings QML module' +rg -Fq 'HealthCheckRow 1.0 HealthCheckRow.qml' "$settings_dir/qmldir" \ + || fail 'HealthCheckRow is not registered in the Settings QML module' +! rg -Fq 'ServicesPage 1.0 ServicesPage.qml' "$settings_dir/qmldir" \ + || fail 'retired ServicesPage remains registered in the Settings QML module' + +rg -Fq 'label: "System Health"' "$settings_dir/SettingsSidebar.qml" \ + || fail 'sidebar does not label the stable services route System Health' +rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar.qml" \ + || fail 'health footer does not open the stable services route' +rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \ + || fail 'health footer lost its 54px target' +rg -Fq 'onClicked: Health.copyReport()' "$settings_dir/HealthSummary.qml" \ + || fail 'Copy Report does not use the redacted Health report path' +rg -Fq 'text: "Checking…"' "$settings_dir/HealthSummary.qml" \ + || fail 'refresh state is not expressed in text' +rg -Fq 'implicitHeight: 126' "$settings_dir/HealthSummary.qml" \ + || fail 'summary hero is not the approved stable 126px height' +rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \ + || fail 'health rows are below the approved 62px target' +rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ + || fail 'opening System Health does not request a fresh scan' +rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ + || fail 'Fedora ownership boundary does not open GNOME Settings' +rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \ + || fail 'Settings repair does not stay inline/non-external' +rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \ + || fail 'authored Settings targets are not routed directly' +! rg -n '#[0-9a-fA-F]{3,8}' \ + "$settings_dir/HealthPage.qml" \ + "$settings_dir/HealthSummary.qml" \ + "$settings_dir/HealthCheckRow.qml" >/dev/null \ + || fail 'health UI introduced colors outside Theme' + +python3 - "$settings_dir/HealthPage.qml" "$settings_dir/HealthSummary.qml" \ + "$settings_dir/HealthCheckRow.qml" <<'PY' || fail 'approved health structure or accessibility contract is missing' +import sys + +page, summary, row = [open(path, encoding="utf-8").read() for path in sys.argv[1:]] + +labels = ( + 'if (status === "ok") return "Healthy";', + 'if (status === "warning") return "Needs attention";', + 'if (status === "error") return "Action required";', + 'return "Not set up";', +) +assert all(label in page for label in labels) +assert 'group: "desktop-foundation"' in page +assert 'group: "input-media"' in page +assert 'group: "integrations"' in page +assert 'group: "panama-tools"' in page +assert 'SettingsCard {' in page and 'SettingsCard {' in summary +assert 'activeFocusOnTab: enabled' in summary +assert 'Keys.onReturnPressed' in summary and 'Keys.onSpacePressed' in summary +assert 'activeFocusOnTab: enabled' in row +assert 'Keys.onReturnPressed' in row and 'Keys.onSpacePressed' in row +assert 'border.width: activeFocus ? 2 : 1' in summary +assert 'border.width: activeFocus ? 2 : 1' in row +assert 'Health.diagnosticUnavailable ? "Retry"' in summary +assert 'Health.lastCopyResult' in summary +assert 'pendingConfirmation' in page +assert 'ddc-permissions' in page +PY + +fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":2,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false}},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true}},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}' + +state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)" +config_path="$state_home/quickshell" +harness="$config_path/health-ui-harness.qml" +helper="$state_home/panama-doctor" +shell_log="$state_home/quickshell.log" +fixture_home="$state_home/home" +mkdir -p "$fixture_home" +cp -a "$repo_dir/config/dot/quickshell" "$config_path" + +cat >"$helper" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +sleep 1.5 +printf '%s\n' "$PANAMA_HEALTH_FIXTURE" +EOF +chmod +x "$helper" + +cat >"$harness" <<'QML' +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.settings +import qs.services + +ShellRoot { + id: root + + Component.onCompleted: { + Health.startupScanEnabled = false; + ShellState.settingsPage = "services"; + Health.consumeSnapshot(Quickshell.env("PANAMA_HEALTH_FIXTURE"), 100); + } + + Item { + width: 980 + height: 820 + + SettingsShell { + id: settingsShell + anchors.fill: parent + } + } + + IpcHandler { + target: "health-ui-test" + + function state(): string { + return JSON.stringify(settingsShell.healthDiagnostics); + } + + function request(id: string): bool { + return settingsShell.requestHealthAction(id); + } + } +} +QML + +run() { + PANAMA_HEALTH_FIXTURE="$fixture" PANAMA_HEALTH_HELPER="$helper" \ + HOME="$fixture_home" XDG_STATE_HOME="$state_home" \ + qs -p "$harness" "$@" +} + +harness_pid="" +cleanup() { + if [[ -n "$harness_pid" ]]; then + kill "$harness_pid" >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + kill -0 "$harness_pid" >/dev/null 2>&1 || break + sleep 0.1 + done + else + run kill >/dev/null 2>&1 || true + fi + rm -rf "$state_home" +} +trap cleanup EXIT + +PANAMA_HEALTH_FIXTURE="$fixture" PANAMA_HEALTH_HELPER="$helper" \ + HOME="$fixture_home" XDG_STATE_HOME="$state_home" \ + qs -p "$harness" --daemonize >"$shell_log" 2>&1 +for _ in $(seq 1 40); do + harness_pid="$(qs list --all 2>/dev/null | awk -v path="$harness" ' + /Process ID:/ { pid = $3 } + index($0, "Config path: " path) { print pid; exit } + ')" + [[ "$harness_pid" =~ ^[0-9]+$ ]] && break + sleep 0.1 +done +for _ in $(seq 1 60); do + run ipc show 2>/dev/null | rg -q '^target health-ui-test$' && break + sleep 0.1 +done +run ipc show 2>/dev/null | rg -q '^target health-ui-test$' \ + || { sed -n '1,200p' "$shell_log" >&2; fail 'isolated fixture did not start'; } + +checking_state="$(run ipc call health-ui-test state)" +jq -e ' + .issueIds == ["desktop.vicinae", "desktop.quickshell", "integration.calendar"] + and .desktopIds == ["desktop.vicinae", "desktop.quickshell"] + and .integrationIds == ["integration.bluebubbles", "integration.calendar"] + and .statusLabels == ["Needs attention", "Action required", "Healthy", "Not set up", "Needs attention", "Healthy"] + and .summaryHeight == 126 + and (.rowHeights | length) == 9 + and (.rowHeights | all(. >= 62)) + and .checking == true + and .checkingText == "Checking…" +' >/dev/null <<<"$checking_state" || fail "checking fixture did not render the approved state: $checking_state" + +checking_heights="$(jq -c .rowHeights <<<"$checking_state")" +for _ in $(seq 1 40); do + settled_state="$(run ipc call health-ui-test state)" + [[ "$(jq -r .checking <<<"$settled_state")" == "false" ]] && break + sleep 0.1 +done +[[ "$(jq -c .rowHeights <<<"$settled_state")" == "$checking_heights" ]] \ + || fail 'row geometry changed after refresh settled' +jq -e '.copyFocusable == true and .refreshFocusable == true and .rowActionFocusable == true' \ + >/dev/null <<<"$settled_state" || fail 'keyboard focus does not reach hero and row actions' + +[[ "$(run ipc call health-ui-test request desktop.quickshell)" == "true" ]] \ + || fail 'restart confirmation fixture could not be requested' +confirmation_state="$(run ipc call health-ui-test state)" +jq -e '.confirmationVisible == true and .confirmationId == "desktop.quickshell"' \ + >/dev/null <<<"$confirmation_state" || fail 'Quickshell restart did not open confirmation sheet' + +if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then + fail 'isolated fixture emitted QML errors or warnings' +fi + +trap - EXIT +cleanup +printf 'health UI contract: PASS\n' diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh index bdb3fcb..23f2b01 100755 --- a/tests/quickshell/settings-pages-contract.sh +++ b/tests/quickshell/settings-pages-contract.sh @@ -9,7 +9,7 @@ fail() { exit 1 } -pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Services About) +pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Health About) for page in "${pages[@]}"; do page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing" diff --git a/tests/quickshell/settings-search-contract.sh b/tests/quickshell/settings-search-contract.sh index 63c19f4..2973d8f 100755 --- a/tests/quickshell/settings-search-contract.sh +++ b/tests/quickshell/settings-search-contract.sh @@ -58,8 +58,13 @@ wallpaper|Wallpaper|appearance blur|Blur|appearance timezone|Timezone|datetime repeat delay|Repeat delay|shortcuts +system health|System Health|services +doctor|Copy health report|services CASES +! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \ + || fail 'search index still uses the retired Startup & Services name' + # ── Shortcuts are searchable by what they do ───────────────────────────────── [[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \ || fail 'searching a shortcut description did not route to the shortcuts page'