Merge codex's System Health and recovery work
Brings in panama-doctor (a 25-check diagnostic with fixture-backed
contracts), a Health service, a System Health page replacing Startup &
Services, and a bar indicator that stays absent until something is
actually degraded. All seven of its contracts pass on the merge.
Three things needed resolving rather than accepting:
The branch predates the debranding, so its user-visible strings still
named the product -- "Panama desktop is healthy", "Restart Panama",
"Panama tools". Rewritten to say the same thing without the name, which
is what the rest of the app now does.
Its Fedora hand-off card was a single button calling openGnomePanel
("network") under a subtitle naming five subjects. Main had already
replaced that with a row per subject, each opening the panel that owns
it, so those rows are ported into HealthPage instead. Printers and
online accounts stay on Network & Devices with the rest of the network
hardware.
That broke its own assertion, which matched the literal
openGnomePanel("network") string. Rewritten rather than reverted: it now
checks the boundary card exists and that every panel named in HealthPage
is one openGnomePanel actually allows, since a name outside the
allow-list opens nothing at all. Verified it catches a plausible-looking
wrong name.
SettingsShell and SettingsSidebar conflicted because both sides added
pages; resolved as the union, keeping its System Health page and live
footer alongside main's Mouse & Touchpad, Privacy & Security, Region &
Language and Online Accounts.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
// The production singleton still performs its delayed startup scan. The
|
||||
// harness turns it off before its 2200 ms deadline so each fixture drives
|
||||
// only the state transition it is asserting.
|
||||
Component.onCompleted: Health.startupScanEnabled = false
|
||||
|
||||
IpcHandler {
|
||||
target: "health-test"
|
||||
|
||||
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||
function repair(id: string): bool { return Health.repair(id, false); }
|
||||
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
||||
function copy(): bool { return Health.copyReport(); }
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,10 @@ PanelWindow {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
HealthIndicator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
ActivityIndicator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// A deliberately absent-until-needed health affordance. Healthy and merely
|
||||
// unconfigured systems leave no ornament or layout residue in the bar.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal activated
|
||||
|
||||
readonly property int issueCount: Health.summary.warnings + Health.summary.errors
|
||||
readonly property color tone: Health.status === "error"
|
||||
? Theme.danger
|
||||
: (Health.status === "warning" ? Theme.warn : "transparent")
|
||||
readonly property string statusText: root.issueCount === 1
|
||||
? "1 system health issue"
|
||||
: root.issueCount + " system health issues"
|
||||
readonly property string accessibleLabel: Health.status === "error"
|
||||
? "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue requires action" : " issues require action")
|
||||
: "System Health: " + root.issueCount + (root.issueCount === 1 ? " issue needs attention" : " issues need attention")
|
||||
readonly property string tooltipText: root.accessibleLabel
|
||||
|
||||
visible: Health.actionable
|
||||
implicitWidth: visible ? content.implicitWidth + 16 : 0
|
||||
implicitHeight: visible ? 24 : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
radius: 9
|
||||
color: visible ? Theme.alpha(root.tone, 0.09) : "transparent"
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: visible ? Theme.alpha(root.tone, activeFocus ? 0.72 : 0.20) : "transparent"
|
||||
|
||||
activeFocusOnTab: visible
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.accessibleLabel
|
||||
Accessible.description: "Open System Health"
|
||||
Accessible.onPressAction: root.activated()
|
||||
|
||||
onActivated: {
|
||||
ShellState.openSettings("services");
|
||||
Health.refresh();
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: root.activated()
|
||||
Keys.onEnterPressed: root.activated()
|
||||
Keys.onSpacePressed: root.activated()
|
||||
|
||||
Row {
|
||||
id: content
|
||||
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F0ECD}" // md-shield-alert-outline
|
||||
color: root.tone
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(root.issueCount)
|
||||
color: root.tone
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: pointer
|
||||
|
||||
anchors.fill: parent
|
||||
enabled: root.visible
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activated()
|
||||
}
|
||||
|
||||
ToolTip.visible: pointer.containsMouse && root.visible
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: root.tooltipText
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var check
|
||||
property bool issue: false
|
||||
property bool divider: true
|
||||
property int actionActivationCount: 0
|
||||
signal actionRequested(var check)
|
||||
|
||||
readonly property bool repairWorking: Health.repairingId === root.check.id
|
||||
readonly property bool repairFailed: root.check.status !== "ok"
|
||||
&& Health.lastRepair.checkId === root.check.id
|
||||
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
|
||||
|
||||
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||
implicitHeight: 62
|
||||
|
||||
// The isolated contract uses the same signal path as a pointer or keyboard
|
||||
// activation instead of calling HealthPage's action handler directly.
|
||||
function activateAction(): bool {
|
||||
if (!actionButton.visible || !actionButton.enabled)
|
||||
return false;
|
||||
root.actionActivationCount += 1;
|
||||
actionButton.clicked();
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function displayedStatus(): string {
|
||||
if (root.repairWorking)
|
||||
return "Working…";
|
||||
if (root.repairFailed)
|
||||
return "Repair failed";
|
||||
return root.statusLabel(root.check.status);
|
||||
}
|
||||
|
||||
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.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.displayedStatus()
|
||||
color: root.statusColor(root.check.status)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: actionButton
|
||||
|
||||
objectName: `health-row-action:${root.issue ? "issue" : "quiet"}:${root.check.id}`
|
||||
visible: root.check.action !== undefined
|
||||
text: root.check.action?.label ?? ""
|
||||
enabled: visible && !root.repairWorking && !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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "system-health-page"
|
||||
|
||||
title: "System Health"
|
||||
lede: "Checks the parts of the desktop this app 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: "Desktop 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
|
||||
&& (check.status === "ok" || check.status === "unconfigured"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function activateRenderedAction(id: string): bool {
|
||||
const suffix = `:${id}`;
|
||||
const rows = root.descendants(root, "health-check-row:").filter(row =>
|
||||
row.visible && String(row.objectName).endsWith(suffix));
|
||||
return rows.length === 1 && rows[0].activateAction();
|
||||
}
|
||||
|
||||
function renderedFocusChain(): var {
|
||||
// Repeater delegates enter Qt's tab chain lazily after their enabled
|
||||
// binding changes at the end of a scan. Touch each rendered action's
|
||||
// real next-focus link before traversing from the first hero control.
|
||||
const renderedActions = root.descendants(root, "health-row-action:").filter(item =>
|
||||
item.visible && item.enabled && item.activeFocusOnTab);
|
||||
for (const action of renderedActions)
|
||||
action.nextItemInFocusChain(true);
|
||||
|
||||
const starts = root.descendants(root, "health-copy-report-button").filter(item => item.visible && item.enabled);
|
||||
if (starts.length !== 1)
|
||||
return [];
|
||||
|
||||
const names = [];
|
||||
const start = starts[0];
|
||||
let current = start;
|
||||
for (let index = 0; index < 128; index++) {
|
||||
const name = String(current.objectName || "");
|
||||
if (name !== "" && names.indexOf(name) < 0)
|
||||
names.push(name);
|
||||
current = current.nextItemInFocusChain(true);
|
||||
if (!current || current === start)
|
||||
break;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// 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 checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible);
|
||||
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
|
||||
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
|
||||
return {
|
||||
renderedRows: rows.map(row => {
|
||||
const objectName = String(row.objectName);
|
||||
const parts = objectName.split(":");
|
||||
const statusTexts = root.descendants(row, "health-status-text:").filter(text => text.visible);
|
||||
return {
|
||||
objectName: objectName,
|
||||
id: parts.slice(2).join(":"),
|
||||
section: parts[1],
|
||||
statusText: statusTexts.length === 1 ? statusTexts[0].text : ""
|
||||
};
|
||||
}),
|
||||
summaryHeight: summaries.length > 0 ? summaries[0].height : 0,
|
||||
rowHeights: rows.map(row => row.height),
|
||||
checking: Health.busy,
|
||||
checkingText: checkingLabels.length > 0 ? checkingLabels[0].text : "",
|
||||
focusChain: root.renderedFocusChain(),
|
||||
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
|
||||
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
|
||||
confirmationVisible: confirmationSheets.length === 1,
|
||||
confirmationId: confirmationSheets.length === 1
|
||||
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
|
||||
: ""
|
||||
};
|
||||
}
|
||||
|
||||
Component.onCompleted: Health.refresh()
|
||||
|
||||
header: Component {
|
||||
Rectangle {
|
||||
objectName: `health-confirmation-sheet:${root.pendingConfirmation ? root.pendingConfirmation.id : ""}`
|
||||
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 the shell?"
|
||||
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 the shell"
|
||||
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: "The monitor reports DDC/CI support, but this session cannot reach 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
|
||||
readonly property var quietChecks: root.checksForGroup(modelData.group)
|
||||
|
||||
width: groupGrid.columns === 2
|
||||
? (groupGrid.width - groupGrid.columnSpacing) / 2
|
||||
: groupGrid.width
|
||||
title: modelData.title
|
||||
subtitle: modelData.subtitle
|
||||
|
||||
Column {
|
||||
id: groupRows
|
||||
width: parent.width
|
||||
|
||||
Text {
|
||||
objectName: `health-empty-group:${modelData.group}`
|
||||
width: parent.width
|
||||
visible: quietChecks.length === 0
|
||||
text: "Items needing attention are listed above."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
topPadding: 8
|
||||
bottomPadding: 8
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: groupRepeater
|
||||
model: quietChecks
|
||||
|
||||
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: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
|
||||
|
||||
// One row per subject rather than a single "Open GNOME Settings"
|
||||
// button. Naming five things and then opening the network panel
|
||||
// regardless reads as a broken button rather than a deliberate
|
||||
// hand-off, and left someone looking for printers to navigate once
|
||||
// GNOME Settings appeared on the wrong page.
|
||||
ActionRow {
|
||||
label: "Users"
|
||||
detail: "Accounts, passwords, and automatic login"
|
||||
action: "Open users"
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Sharing"
|
||||
detail: "Remote desktop, media sharing, and remote login"
|
||||
action: "Open sharing"
|
||||
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
action: "Open colour"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Digital wellbeing"
|
||||
detail: "Screen time and break reminders"
|
||||
action: "Open wellbeing"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 the 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 || "The latest health check could not be completed.";
|
||||
if (Health.checks.length === 0)
|
||||
return "Checking the desktop services, tools, and integrations this app 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 "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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,159 +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: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
|
||||
|
||||
// This was one row listing five subjects and opening the network panel
|
||||
// regardless. Naming a panel and then not opening it is worse than not
|
||||
// offering it: it looks like a broken button rather than a deliberate
|
||||
// hand-off, and someone looking for printers had to know to navigate
|
||||
// once GNOME Settings appeared on the wrong page.
|
||||
ActionRow {
|
||||
label: "Users"
|
||||
detail: "Accounts, passwords, and automatic login"
|
||||
action: "Open users"
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Sharing"
|
||||
detail: "Remote desktop, media sharing, and remote login"
|
||||
action: "Open sharing"
|
||||
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
action: "Open colour"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Digital wellbeing"
|
||||
detail: "Screen time and break reminders"
|
||||
action: "Open wellbeing"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,19 @@ 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;
|
||||
return pageLoader.item.activateRenderedAction(id);
|
||||
}
|
||||
|
||||
color: Theme.bg
|
||||
radius: 18
|
||||
@@ -107,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;
|
||||
}
|
||||
@@ -161,7 +174,7 @@ Rectangle {
|
||||
Component { id: privacyPage; PrivacyPage {} }
|
||||
Component { id: regionPage; RegionPage {} }
|
||||
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||
Component { id: servicesPage; ServicesPage {} }
|
||||
Component { id: healthPage; HealthPage {} }
|
||||
Component { id: aboutPage; AboutPage {} }
|
||||
|
||||
Shortcut {
|
||||
|
||||
@@ -40,7 +40,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", icon: "\u{F02FD}" }
|
||||
]
|
||||
|
||||
@@ -292,8 +292,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 the desktop";
|
||||
if (Health.status === "error")
|
||||
return "Desktop needs attention";
|
||||
if (Health.status === "warning") {
|
||||
const count = Health.summary.warnings + Health.summary.errors;
|
||||
return count + (count === 1 ? " health observation" : " health observations");
|
||||
}
|
||||
return "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
|
||||
@@ -302,17 +328,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: "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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,6 +109,7 @@ case "$action" in
|
||||
clipboard) qs ipc call clipboard open ;;
|
||||
overview) qs ipc call overview open ;;
|
||||
settings) qs ipc call settings open ;;
|
||||
health) qs ipc call health open ;;
|
||||
|
||||
dnd)
|
||||
state="$(qs ipc call notifications dnd)"
|
||||
@@ -148,7 +149,7 @@ case "$action" in
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: panama-action {%s}\n' \
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
Executable
+656
@@ -0,0 +1,656 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Redacted diagnostics and bounded repairs for Panama-owned functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Callable, Literal
|
||||
|
||||
Status = Literal["ok", "warning", "error", "unconfigured"]
|
||||
Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
ActionKind = Literal["repair", "open", "instructions"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
kind: ActionKind
|
||||
label: str
|
||||
confirm: bool = False
|
||||
# Only authored Settings page IDs and instruction IDs are allowed here.
|
||||
# Repair commands never receive a caller-controlled target.
|
||||
target: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Check:
|
||||
id: str
|
||||
group: Group
|
||||
title: str
|
||||
status: Status
|
||||
detail: str
|
||||
action: Action | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DoctorConfig:
|
||||
root: Path
|
||||
home: Path
|
||||
config_home: Path
|
||||
state_home: Path
|
||||
runtime_dir: Path
|
||||
path: str
|
||||
timeout: float
|
||||
|
||||
@property
|
||||
def command_env(self) -> dict[str, str]:
|
||||
environment = {
|
||||
"PATH": self.path,
|
||||
"HOME": str(self.home),
|
||||
"XDG_CONFIG_HOME": str(self.config_home),
|
||||
"XDG_STATE_HOME": str(self.state_home),
|
||||
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
||||
}
|
||||
for name in PROBE_ENVIRONMENT_KEYS:
|
||||
if value := os.environ.get(name):
|
||||
environment[name] = value
|
||||
return environment
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
state: Literal["ok", "missing", "timeout", "failed", "unavailable"]
|
||||
stdout: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepairResult:
|
||||
check_id: str
|
||||
accepted: bool
|
||||
exit_code: int
|
||||
message: str
|
||||
|
||||
def as_json(self) -> dict[str, object]:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"checkId": self.check_id,
|
||||
"accepted": self.accepted,
|
||||
"exitCode": self.exit_code,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
CHECK_ORDER = (
|
||||
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
|
||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
|
||||
)
|
||||
|
||||
SYSTEMCTL_COMMANDS = {
|
||||
"hyprpaper": ("systemctl", "--user", "is-active", "--quiet", "hyprpaper.service"),
|
||||
"hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"),
|
||||
"vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"),
|
||||
"pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"),
|
||||
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
|
||||
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"),
|
||||
}
|
||||
REPAIR_COMMANDS = MappingProxyType({
|
||||
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
|
||||
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
|
||||
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
|
||||
"desktop.quickshell": ("panama-action", "restart-shell"),
|
||||
})
|
||||
RUNTIME_LINK_TARGETS = (
|
||||
("hypr", Path("config/dot/hypr")),
|
||||
("quickshell", Path("config/dot/quickshell")),
|
||||
("uwsm", Path("config/dot/uwsm")),
|
||||
("vicinae", Path("config/dot/vicinae")),
|
||||
)
|
||||
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
||||
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
||||
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||
PROBE_ENVIRONMENT_KEYS = (
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"WAYLAND_DISPLAY",
|
||||
"DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"PANAMA_DOCTOR_FIXTURE_STOPPED",
|
||||
"PANAMA_DOCTOR_FIXTURE_PROCESSES",
|
||||
"PANAMA_DOCTOR_FIXTURE_BUS",
|
||||
"PANAMA_DOCTOR_FIXTURE_QS",
|
||||
"PANAMA_DOCTOR_FIXTURE_QS_VERSION",
|
||||
"PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES",
|
||||
"PANAMA_DOCTOR_FIXTURE_CALENDAR",
|
||||
"PANAMA_DOCTOR_FIXTURE_BRIGHTNESS",
|
||||
"PANAMA_DOCTOR_FIXTURE_CAFFEINE",
|
||||
)
|
||||
CHECK_TITLES = {
|
||||
"desktop.hyprland": "Hyprland",
|
||||
"desktop.quickshell": "Quickshell",
|
||||
"desktop.notifications": "Notifications",
|
||||
"desktop.portals": "Desktop portals",
|
||||
"desktop.hyprpaper": "Hyprpaper",
|
||||
"desktop.hypridle": "Hypridle",
|
||||
"desktop.vicinae": "Vicinae",
|
||||
"input.pipewire": "PipeWire",
|
||||
"input.clipboard": "Clipboard",
|
||||
"input.wallpaper": "Wallpaper",
|
||||
"input.capture": "Capture",
|
||||
"input.ocr": "OCR",
|
||||
"input.brightness": "External monitor brightness",
|
||||
"integration.nextcloud": "Nextcloud",
|
||||
"integration.rustdesk": "RustDesk",
|
||||
"integration.kdeconnect": "KDE Connect",
|
||||
"integration.bluebubbles": "BlueBubbles",
|
||||
"integration.home-assistant": "Home Assistant",
|
||||
"integration.calendar": "Calendar",
|
||||
"panama.runtime-links": "Panama runtime links",
|
||||
"panama.vicinae-commands": "Panama commands",
|
||||
"panama.selected-terminal": "Selected terminal",
|
||||
"panama.selected-launcher": "Selected launcher",
|
||||
"panama.processes": "Panama processes",
|
||||
"panama.caffeine": "Caffeine inhibitor",
|
||||
}
|
||||
|
||||
|
||||
def environment_path(name: str, default: Path) -> Path:
|
||||
value = os.environ.get(name)
|
||||
return Path(value).expanduser() if value else default
|
||||
|
||||
|
||||
def config_from_environment() -> DoctorConfig:
|
||||
home = environment_path("PANAMA_DOCTOR_HOME", Path.home())
|
||||
config_home = environment_path("PANAMA_DOCTOR_CONFIG_HOME", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")))
|
||||
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
|
||||
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
|
||||
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
|
||||
try:
|
||||
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||
except ValueError:
|
||||
timeout = 3.0
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
|
||||
|
||||
|
||||
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||
"""Run an authored read-only command without reporting its unparsed output."""
|
||||
try:
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=config.timeout, check=False, env=config.command_env, cwd=cwd)
|
||||
except FileNotFoundError:
|
||||
return CommandResult("missing")
|
||||
except subprocess.TimeoutExpired:
|
||||
return CommandResult("timeout")
|
||||
except OSError:
|
||||
return CommandResult("unavailable")
|
||||
if completed.returncode != 0:
|
||||
return CommandResult("failed")
|
||||
return CommandResult("ok", completed.stdout)
|
||||
|
||||
|
||||
def run_repair_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> tuple[int, str]:
|
||||
"""Execute one authored repair argv and retain output only for strict parsing."""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=config.timeout,
|
||||
check=False,
|
||||
env=config.command_env,
|
||||
cwd=cwd,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return 127, ""
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, ""
|
||||
except OSError:
|
||||
return 126, ""
|
||||
exit_code = completed.returncode if 0 <= completed.returncode <= 255 else 1
|
||||
return exit_code, completed.stdout
|
||||
|
||||
|
||||
def executable_exists(name: str, config: DoctorConfig) -> bool:
|
||||
return shutil.which(name, path=config.path) is not None
|
||||
|
||||
|
||||
def action_json(action: Action) -> dict[str, object]:
|
||||
result: dict[str, object] = {"kind": action.kind, "label": action.label, "confirm": action.confirm}
|
||||
if action.target is not None:
|
||||
result["target"] = action.target
|
||||
return result
|
||||
|
||||
|
||||
def check_json(check: Check) -> dict[str, object]:
|
||||
result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail}
|
||||
if check.action is not None:
|
||||
result["action"] = action_json(check.action)
|
||||
return result
|
||||
|
||||
|
||||
def group_for(check_id: str) -> Group:
|
||||
if check_id.startswith("desktop."):
|
||||
return "desktop-foundation"
|
||||
if check_id.startswith("input."):
|
||||
return "input-media"
|
||||
if check_id.startswith("integration."):
|
||||
return "integrations"
|
||||
return "panama-tools"
|
||||
|
||||
|
||||
def service_check(check_id: str, title: str, service: str, config: DoctorConfig, action: Action | None = None) -> Check:
|
||||
result = run_command(SYSTEMCTL_COMMANDS[service], config)
|
||||
if result.state == "ok":
|
||||
return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
|
||||
if result.state in {"missing", "unavailable"}:
|
||||
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
|
||||
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
|
||||
|
||||
|
||||
def ipc_target(config: DoctorConfig, target: str) -> CommandResult:
|
||||
result = run_command(("qs", "ipc", "show"), config)
|
||||
if result.state != "ok":
|
||||
return result
|
||||
return CommandResult("ok") if f"target {target}" in result.stdout.splitlines() else CommandResult("failed")
|
||||
|
||||
|
||||
def simple_ipc_check(check_id: str, title: str, target: str, config: DoctorConfig) -> Check:
|
||||
result = ipc_target(config, target)
|
||||
if result.state == "ok":
|
||||
return Check(check_id, "input-media", title, "ok", "Panama IPC target is available.")
|
||||
if result.state == "missing":
|
||||
return Check(check_id, "input-media", title, "error", "Required Quickshell executable is unavailable.")
|
||||
if result.state == "timeout":
|
||||
return Check(check_id, "input-media", title, "warning", "Panama IPC probe timed out.")
|
||||
return Check(check_id, "input-media", title, "warning", "Panama IPC target is unavailable.")
|
||||
|
||||
|
||||
def check_hyprland(config: DoctorConfig) -> Check:
|
||||
if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold():
|
||||
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "ok", "Hyprland session detected.")
|
||||
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "error", "Hyprland session is not active.")
|
||||
|
||||
|
||||
def check_quickshell(config: DoctorConfig) -> Check:
|
||||
result = run_command(("qs", "--version"), config)
|
||||
repair = Action("repair", "Restart Panama", True)
|
||||
if result.state == "ok" and VERSION_PATTERN.search(result.stdout):
|
||||
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "ok", "Quickshell executable is available.")
|
||||
if result.state == "missing":
|
||||
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "error", "Required Quickshell executable is unavailable.", repair)
|
||||
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "warning", "Quickshell probe returned an invalid result.", repair)
|
||||
|
||||
|
||||
def check_notifications(config: DoctorConfig) -> Check:
|
||||
result = ipc_target(config, "notifications")
|
||||
return Check("desktop.notifications", "desktop-foundation", "Notifications", "ok", "Notification service is available.") if result.state == "ok" else Check("desktop.notifications", "desktop-foundation", "Notifications", "warning", "Notification service is unavailable.")
|
||||
|
||||
|
||||
def check_portals(config: DoctorConfig) -> Check:
|
||||
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
|
||||
if result.state == "ok" and any(line.startswith("org.freedesktop.portal.Desktop ") for line in result.stdout.splitlines()):
|
||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "ok", "Desktop portal service is available.")
|
||||
detail = "Desktop portal probe timed out." if result.state == "timeout" else "Desktop portal probe is unavailable." if result.state == "missing" else "Desktop portal service is unavailable."
|
||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
||||
|
||||
|
||||
def check_brightness(config: DoctorConfig) -> Check:
|
||||
result = run_command(("panama-brightness", "list"), config)
|
||||
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||
if result.state == "timeout":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||
if result.state != "ok":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI support is unavailable.", instructions)
|
||||
try:
|
||||
listing = json.loads(result.stdout)
|
||||
displays, error = listing["displays"], listing["error"]
|
||||
if not isinstance(displays, list) or not isinstance(error, str):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe returned an invalid result.", instructions)
|
||||
if error:
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "No accessible DDC/CI bus.", instructions)
|
||||
if not displays:
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "unconfigured", "No DDC/CI display is configured.")
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "ok", f"{len(displays)} DDC/CI display{'s' if len(displays) != 1 else ''} available.")
|
||||
|
||||
|
||||
def check_nextcloud(config: DoctorConfig) -> Check:
|
||||
if not (config.config_home / "autostart" / "nextcloud.desktop").is_file():
|
||||
return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.")
|
||||
return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
|
||||
|
||||
|
||||
def check_rustdesk(config: DoctorConfig) -> Check:
|
||||
if not executable_exists("rustdesk", config):
|
||||
return Check("integration.rustdesk", "integrations", "RustDesk", "unconfigured", "RustDesk is not installed.")
|
||||
return service_check("integration.rustdesk", "RustDesk", "rustdesk", config, Action("open", "Open RustDesk"))
|
||||
|
||||
|
||||
def check_kdeconnect(config: DoctorConfig) -> Check:
|
||||
if not executable_exists("kdeconnect-cli", config):
|
||||
return Check("integration.kdeconnect", "integrations", "KDE Connect", "unconfigured", "KDE Connect is not installed.")
|
||||
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
|
||||
if result.state == "ok" and any(line.startswith("org.kde.kdeconnect ") for line in result.stdout.splitlines()):
|
||||
return Check("integration.kdeconnect", "integrations", "KDE Connect", "ok", "KDE Connect service is available.")
|
||||
return Check("integration.kdeconnect", "integrations", "KDE Connect", "warning", "KDE Connect service is unavailable.", Action("open", "Open KDE Connect"))
|
||||
|
||||
|
||||
def check_bluebubbles(config: DoctorConfig) -> Check:
|
||||
result = run_command(("flatpak", "info", "app.bluebubbles.BlueBubbles"), config)
|
||||
if result.state == "ok":
|
||||
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "ok", "BlueBubbles is installed.")
|
||||
if result.state in {"missing", "failed"}:
|
||||
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "unconfigured", "BlueBubbles is not installed.")
|
||||
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles"))
|
||||
|
||||
|
||||
def check_home_assistant(config: DoctorConfig) -> Check:
|
||||
configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN"))
|
||||
helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant"
|
||||
if not configured:
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.")
|
||||
if not helper.is_file():
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone"))
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.")
|
||||
|
||||
|
||||
def check_calendar(config: DoctorConfig) -> Check:
|
||||
result = run_command(("calendar-agenda", "probe"), config)
|
||||
action = Action("open", "Open Date & Time", target="datetime")
|
||||
if result.state == "missing":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||
if result.state == "timeout":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe timed out.", action)
|
||||
if result.state != "ok":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe failed.", action)
|
||||
try:
|
||||
enabled_sources = json.loads(result.stdout)["enabledSources"]
|
||||
if not isinstance(enabled_sources, int) or isinstance(enabled_sources, bool):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe returned an invalid result.", action)
|
||||
if enabled_sources <= 0:
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "No enabled calendar source is configured.")
|
||||
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
|
||||
|
||||
|
||||
def check_runtime_links(config: DoctorConfig) -> Check:
|
||||
def valid_link(name: str, relative_source: Path) -> bool:
|
||||
destination = config.config_home / name
|
||||
source = config.root / relative_source
|
||||
try:
|
||||
return source.is_dir() and destination.is_symlink() \
|
||||
and destination.resolve(strict=False) == source.resolve(strict=True)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if any(not valid_link(name, relative_source) for name, relative_source in RUNTIME_LINK_TARGETS):
|
||||
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "warning", "One or more Panama runtime links are unavailable.", Action("repair", "Repair runtime links"))
|
||||
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "ok", "Panama runtime links are available.")
|
||||
|
||||
|
||||
def check_vicinae_commands(config: DoctorConfig) -> Check:
|
||||
source = config.root / "config/local/share/vicinae/scripts"
|
||||
installed = config.home / ".local/share/vicinae/scripts"
|
||||
if source.is_dir() and installed.is_symlink() and installed.exists():
|
||||
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.")
|
||||
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link"))
|
||||
|
||||
|
||||
def executable_check(check_id: str, title: str, executable: str, config: DoctorConfig) -> Check:
|
||||
if executable_exists(executable, config):
|
||||
return Check(check_id, group_for(check_id), title, "ok", f"{title} executable is available.")
|
||||
return Check(check_id, group_for(check_id), title, "warning", f"{title} executable is unavailable.")
|
||||
|
||||
|
||||
def check_processes(config: DoctorConfig) -> Check:
|
||||
counts: list[int] = []
|
||||
for name in PROCESS_NAMES:
|
||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||
if result.state == "ok":
|
||||
pids = result.stdout.splitlines()
|
||||
if not pids or any(not pid.isdecimal() for pid in pids):
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.")
|
||||
counts.append(len(pids))
|
||||
elif result.state == "failed":
|
||||
counts.append(0)
|
||||
else:
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||
if any(count > 1 for count in counts):
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Duplicate Panama desktop processes detected.")
|
||||
if counts[0] == 0:
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "error", "Quickshell process is not running.")
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "ok", "Panama desktop process counts are normal.")
|
||||
|
||||
|
||||
def check_caffeine(config: DoctorConfig) -> Check:
|
||||
result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config)
|
||||
if result.state != "ok":
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
|
||||
uid = str(os.getuid())
|
||||
inhibitors = 0
|
||||
malformed = False
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts
|
||||
if not relevant:
|
||||
continue
|
||||
if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]:
|
||||
inhibitors += 1
|
||||
else:
|
||||
malformed = True
|
||||
if malformed:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
|
||||
if inhibitors > 1:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
|
||||
if inhibitors == 1:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "One Panama Caffeine inhibitor is active.")
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "No Panama Caffeine inhibitor is active.")
|
||||
|
||||
|
||||
def parse_version(result: CommandResult, pattern: re.Pattern[str] = VERSION_PATTERN) -> str:
|
||||
match = pattern.search(result.stdout) if result.state == "ok" else None
|
||||
return match.group(0) if match else "unavailable"
|
||||
|
||||
|
||||
def context_versions(config: DoctorConfig) -> list[dict[str, str]]:
|
||||
hyprland = run_command(("hyprctl", "version"), config)
|
||||
quickshell = run_command(("qs", "--version"), config)
|
||||
revision = run_command(("git", "rev-parse", "--short", "HEAD"), config, config.root)
|
||||
fedora = "unavailable"
|
||||
try:
|
||||
match = re.search(r"^VERSION_ID=\"?([^\n\"]+)", Path("/etc/os-release").read_text(encoding="utf-8"), re.MULTILINE)
|
||||
if match and re.fullmatch(r"[0-9.]+", match.group(1)):
|
||||
fedora = match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
|
||||
|
||||
|
||||
def unavailable_check(check_id: str) -> Check:
|
||||
return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.")
|
||||
|
||||
|
||||
def unavailable_versions() -> list[dict[str, str]]:
|
||||
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
|
||||
|
||||
|
||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||
probes: dict[str, Callable[[], Check]] = {
|
||||
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
||||
checks: list[Check] = []
|
||||
for check_id in CHECK_ORDER:
|
||||
try:
|
||||
checks.append(futures[check_id].result())
|
||||
except Exception:
|
||||
checks.append(unavailable_check(check_id))
|
||||
return checks
|
||||
|
||||
|
||||
def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||
try:
|
||||
checks = collect_checks(config)
|
||||
except Exception:
|
||||
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
|
||||
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
|
||||
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
|
||||
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
|
||||
try:
|
||||
versions = context_versions(config)
|
||||
except Exception:
|
||||
versions = unavailable_versions()
|
||||
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
|
||||
|
||||
|
||||
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||
command = REPAIR_COMMANDS[check_id]
|
||||
exit_code, _ = run_repair_command(command, config)
|
||||
message = "Repair completed. A fresh health check will verify recovery." if exit_code == 0 \
|
||||
else "The authored repair command could not be completed."
|
||||
return RepairResult(check_id, True, exit_code, message)
|
||||
|
||||
|
||||
def repair_runtime_links(config: DoctorConfig) -> RepairResult:
|
||||
sources = [(name, config.root / relative_source) for name, relative_source in RUNTIME_LINK_TARGETS]
|
||||
if any(not source.is_dir() for _, source in sources):
|
||||
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.")
|
||||
|
||||
try:
|
||||
config.config_home.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return RepairResult("panama.runtime-links", True, 1, "Panama runtime links could not be accessed.")
|
||||
|
||||
blocked = False
|
||||
failed = False
|
||||
for name, source in sources:
|
||||
destination = config.config_home / name
|
||||
try:
|
||||
if destination.is_symlink():
|
||||
if destination.resolve(strict=False) == source.resolve(strict=True):
|
||||
continue
|
||||
destination.unlink()
|
||||
destination.symlink_to(source, target_is_directory=True)
|
||||
elif destination.exists():
|
||||
# A regular file or directory is user-owned unless proven
|
||||
# otherwise. Report it, but never replace it.
|
||||
blocked = True
|
||||
else:
|
||||
destination.symlink_to(source, target_is_directory=True)
|
||||
except OSError:
|
||||
failed = True
|
||||
|
||||
if failed:
|
||||
return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.")
|
||||
if blocked:
|
||||
return RepairResult("panama.runtime-links", True, 1, "A user-owned file or directory is blocking a Panama runtime link.")
|
||||
return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.")
|
||||
|
||||
|
||||
def repair_vicinae_commands(config: DoctorConfig) -> RepairResult:
|
||||
helper = config.root / "setup/scripts/link-vicinae-scripts"
|
||||
if not helper.is_file():
|
||||
return RepairResult("panama.vicinae-commands", True, 127, "The authored Vicinae link helper is unavailable.")
|
||||
exit_code, _ = run_repair_command((str(helper),), config, config.root)
|
||||
message = "Panama commands were relinked. A fresh health check will verify them." if exit_code == 0 \
|
||||
else "Panama commands could not be relinked."
|
||||
return RepairResult("panama.vicinae-commands", True, exit_code, message)
|
||||
|
||||
|
||||
def repair_caffeine(config: DoctorConfig) -> RepairResult:
|
||||
list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend")
|
||||
list_exit, output = run_repair_command(list_command, config)
|
||||
if list_exit != 0:
|
||||
return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.")
|
||||
|
||||
uid = str(os.getuid())
|
||||
inhibitor_pids: list[str] = []
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid:
|
||||
continue
|
||||
if len(parts) != 8:
|
||||
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
||||
if parts[6] != "Caffeine" or parts[7] != "block":
|
||||
continue
|
||||
if not parts[3].isdecimal():
|
||||
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
||||
inhibitor_pids.append(parts[3])
|
||||
|
||||
if len(inhibitor_pids) <= 1:
|
||||
return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.")
|
||||
|
||||
for pid in inhibitor_pids[1:]:
|
||||
exit_code, _ = run_repair_command(("kill", "--", pid), config)
|
||||
if exit_code != 0:
|
||||
return RepairResult("panama.caffeine", True, exit_code, "A duplicate Panama Caffeine inhibitor could not be released.")
|
||||
return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.")
|
||||
|
||||
|
||||
def repair(check_id: str, config: DoctorConfig) -> RepairResult:
|
||||
if check_id in REPAIR_COMMANDS:
|
||||
return repair_authored_command(check_id, config)
|
||||
if check_id == "panama.runtime-links":
|
||||
return repair_runtime_links(config)
|
||||
if check_id == "panama.vicinae-commands":
|
||||
return repair_vicinae_commands(config)
|
||||
if check_id == "panama.caffeine":
|
||||
return repair_caffeine(config)
|
||||
return RepairResult(check_id, False, 2, "This health check has no authored repair.")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Panama system diagnostics and bounded repairs")
|
||||
output = parser.add_mutually_exclusive_group()
|
||||
output.add_argument("--json", action="store_true")
|
||||
output.add_argument("--summary", action="store_true")
|
||||
parser.add_argument("--repair", metavar="CHECK_ID")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.repair is not None:
|
||||
if args.repair not in REPAIR_IDS or args.summary:
|
||||
result = RepairResult(args.repair, False, 2, "This health check has no authored repair.")
|
||||
else:
|
||||
try:
|
||||
result = repair(args.repair, config_from_environment())
|
||||
except Exception:
|
||||
result = RepairResult(args.repair, True, 1, "The authored repair could not be completed.")
|
||||
print(json.dumps(result.as_json(), separators=(",", ":"), sort_keys=False))
|
||||
return result.exit_code
|
||||
|
||||
result = snapshot(config_from_environment())
|
||||
if args.summary:
|
||||
summary = result["summary"]
|
||||
assert isinstance(summary, dict)
|
||||
print(f"Panama system health: {summary['status']} ({summary['healthy']} ok, {summary['warnings']} warnings, {summary['errors']} errors, {summary['unconfigured']} unconfigured)")
|
||||
else:
|
||||
print(json.dumps(result, separators=(",", ":"), sort_keys=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,440 @@
|
||||
pragma Singleton
|
||||
|
||||
// The health helper is deliberately not a state owner. This singleton accepts
|
||||
// complete, typed snapshots and keeps the last valid one available while a
|
||||
// later scan or repair fails.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var snapshot: ({})
|
||||
property var checks: []
|
||||
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
|
||||
property string status: "healthy"
|
||||
property bool diagnosticUnavailable: false
|
||||
property bool queuedRefresh: false
|
||||
property int generation: 0
|
||||
property int acceptedGeneration: 0
|
||||
property string lastError: ""
|
||||
property string repairingId: ""
|
||||
property var lastRepair: ({})
|
||||
property string lastCopyResult: ""
|
||||
property bool startupScanEnabled: true
|
||||
property bool postRepairScanPending: false
|
||||
|
||||
readonly property bool actionable: root.status === "warning" || root.status === "error"
|
||||
readonly property bool busy: scanProcess.running || repairProcess.running || root.postRepairScanPending
|
||||
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|
||||
|| Quickshell.shellDir + "/scripts/panama-doctor"
|
||||
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
|
||||
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
readonly property var overallStatuses: ["healthy", "warning", "error"]
|
||||
readonly property var settingsTargets: ["home-phone", "datetime"]
|
||||
readonly property var instructionTargets: ["ddc-permissions"]
|
||||
|
||||
Process {
|
||||
id: scanProcess
|
||||
|
||||
property int scanGeneration: 0
|
||||
property string outputText: ""
|
||||
property int exitCode: -1
|
||||
property bool exited: false
|
||||
property bool streamFinished: false
|
||||
property bool settled: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
id: scanOutput
|
||||
property int generation: 0
|
||||
onStreamFinished: {
|
||||
scanProcess.outputText = this.text;
|
||||
scanProcess.scanGeneration = generation;
|
||||
scanProcess.streamFinished = true;
|
||||
root.settleScan();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
scanProcess.exitCode = exitCode;
|
||||
scanProcess.exited = true;
|
||||
root.settleScan();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: repairProcess
|
||||
|
||||
property string checkId: ""
|
||||
property bool external: false
|
||||
property string outputText: ""
|
||||
property int exitCode: -1
|
||||
property bool exited: false
|
||||
property bool streamFinished: false
|
||||
property bool settled: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
repairProcess.outputText = this.text;
|
||||
repairProcess.streamFinished = true;
|
||||
root.settleRepair();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
repairProcess.exitCode = exitCode;
|
||||
repairProcess.exited = true;
|
||||
root.settleRepair();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: copyProcess
|
||||
|
||||
property string payload: ""
|
||||
|
||||
onStarted: copyProcess.write(copyProcess.payload)
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.lastCopyResult = exitCode === 0
|
||||
? "Report copied."
|
||||
: "Could not copy the health report."
|
||||
copyProcess.payload = ""
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: failureNotification
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: startupScan
|
||||
interval: 2200
|
||||
repeat: false
|
||||
running: root.startupScanEnabled
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
function refresh(): bool {
|
||||
if (scanProcess.running || repairProcess.running) {
|
||||
root.queuedRefresh = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
root.generation += 1;
|
||||
scanProcess.scanGeneration = root.generation;
|
||||
scanProcess.outputText = "";
|
||||
scanProcess.exitCode = -1;
|
||||
scanProcess.exited = false;
|
||||
scanProcess.streamFinished = false;
|
||||
scanProcess.settled = false;
|
||||
scanOutput.generation = root.generation;
|
||||
scanProcess.exec([root.helperPath, "--json"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function settleScan(): void {
|
||||
if (scanProcess.settled || !scanProcess.exited || !scanProcess.streamFinished)
|
||||
return;
|
||||
scanProcess.settled = true;
|
||||
root.finishScan(scanProcess.exitCode, scanProcess.scanGeneration, scanProcess.outputText);
|
||||
}
|
||||
|
||||
function finishScan(exitCode: int, scanGeneration: int, text: string): void {
|
||||
if (exitCode === 0)
|
||||
root.consumeSnapshot(text, scanGeneration);
|
||||
else
|
||||
root.rejectSnapshot("Panama diagnostics could not be read. Try refreshing.");
|
||||
|
||||
if (!root.queuedRefresh)
|
||||
return;
|
||||
root.queuedRefresh = false;
|
||||
root.refresh();
|
||||
}
|
||||
|
||||
function consumeSnapshot(text: string, scanGeneration: int): bool {
|
||||
if (scanGeneration < root.acceptedGeneration)
|
||||
return false;
|
||||
|
||||
let candidate;
|
||||
try {
|
||||
candidate = JSON.parse(text.trim());
|
||||
} catch (error) {
|
||||
root.rejectSnapshot("Panama diagnostics returned an unreadable response.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root.validSnapshot(candidate)) {
|
||||
root.rejectSnapshot("Panama diagnostics returned an invalid response.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const accepted = root.safeSnapshot(candidate);
|
||||
root.snapshot = accepted;
|
||||
root.checks = accepted.checks;
|
||||
root.summary = accepted.summary;
|
||||
root.status = accepted.summary.status;
|
||||
root.acceptedGeneration = scanGeneration;
|
||||
root.diagnosticUnavailable = false;
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectSnapshot(message: string): void {
|
||||
root.diagnosticUnavailable = true;
|
||||
root.lastError = message;
|
||||
}
|
||||
|
||||
function safeSnapshot(candidate: var): var {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: candidate.generatedAt,
|
||||
summary: {
|
||||
status: candidate.summary.status,
|
||||
healthy: candidate.summary.healthy,
|
||||
warnings: candidate.summary.warnings,
|
||||
errors: candidate.summary.errors,
|
||||
unconfigured: candidate.summary.unconfigured
|
||||
},
|
||||
context: {
|
||||
session: candidate.context.session,
|
||||
versions: candidate.context.versions.map(version => ({
|
||||
id: version.id,
|
||||
version: version.version
|
||||
}))
|
||||
},
|
||||
checks: candidate.checks.map(check => root.safeCheck(check))
|
||||
};
|
||||
}
|
||||
|
||||
function safeCheck(candidate: var): var {
|
||||
const check = {
|
||||
id: candidate.id,
|
||||
group: candidate.group,
|
||||
title: candidate.title,
|
||||
status: candidate.status,
|
||||
detail: candidate.detail
|
||||
};
|
||||
if (candidate.action !== undefined)
|
||||
check.action = root.safeAction(candidate.action);
|
||||
return check;
|
||||
}
|
||||
|
||||
function safeAction(candidate: var): var {
|
||||
const action = {
|
||||
kind: candidate.kind,
|
||||
label: candidate.label,
|
||||
confirm: candidate.confirm
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(candidate, "target"))
|
||||
action.target = candidate.target;
|
||||
return action;
|
||||
}
|
||||
|
||||
function repair(id: string, external: bool): bool {
|
||||
if (root.busy)
|
||||
return false;
|
||||
|
||||
const check = root.checks.find(candidate => candidate.id === id);
|
||||
if (!check || !check.action || check.action.kind !== "repair")
|
||||
return false;
|
||||
|
||||
root.repairingId = id;
|
||||
root.lastError = "";
|
||||
repairProcess.checkId = id;
|
||||
repairProcess.external = external;
|
||||
repairProcess.outputText = "";
|
||||
repairProcess.exitCode = -1;
|
||||
repairProcess.exited = false;
|
||||
repairProcess.streamFinished = false;
|
||||
repairProcess.settled = false;
|
||||
repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function settleRepair(): void {
|
||||
if (repairProcess.settled || !repairProcess.exited || !repairProcess.streamFinished)
|
||||
return;
|
||||
repairProcess.settled = true;
|
||||
root.finishRepair(
|
||||
repairProcess.exitCode,
|
||||
repairProcess.checkId,
|
||||
repairProcess.external,
|
||||
repairProcess.outputText
|
||||
);
|
||||
}
|
||||
|
||||
function finishRepair(exitCode: int, id: string, external: bool, text: string): void {
|
||||
let candidate;
|
||||
try {
|
||||
candidate = JSON.parse(text.trim());
|
||||
} catch (error) {
|
||||
candidate = null;
|
||||
}
|
||||
|
||||
const result = root.validRepairResult(candidate, id, exitCode)
|
||||
? {
|
||||
schemaVersion: 1,
|
||||
checkId: candidate.checkId,
|
||||
accepted: candidate.accepted,
|
||||
exitCode: candidate.exitCode,
|
||||
message: candidate.message
|
||||
}
|
||||
: {
|
||||
schemaVersion: 1,
|
||||
checkId: id,
|
||||
accepted: false,
|
||||
exitCode: exitCode,
|
||||
message: "Panama returned an invalid repair response."
|
||||
};
|
||||
const failed = !result.accepted || result.exitCode !== 0;
|
||||
root.repairingId = "";
|
||||
root.lastRepair = result;
|
||||
root.lastError = failed ? result.message : "";
|
||||
if (failed && external && !failureNotification.running) {
|
||||
failureNotification.exec([
|
||||
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
|
||||
"Panama action failed", "The requested health repair could not be completed."
|
||||
]);
|
||||
}
|
||||
|
||||
// Any refresh requested while the repair was running is satisfied by
|
||||
// this one observed post-repair scan. Process exit alone never changes
|
||||
// the accepted check rows.
|
||||
root.queuedRefresh = false;
|
||||
root.postRepairScanPending = true;
|
||||
Qt.callLater(root.startPostRepairScan);
|
||||
}
|
||||
|
||||
function startPostRepairScan(): void {
|
||||
root.postRepairScanPending = false;
|
||||
root.queuedRefresh = false;
|
||||
root.refresh();
|
||||
}
|
||||
|
||||
function validRepairResult(candidate: var, id: string, processExitCode: int): bool {
|
||||
if (!root.plainObject(candidate))
|
||||
return false;
|
||||
const keys = Object.keys(candidate).sort();
|
||||
const expectedKeys = ["accepted", "checkId", "exitCode", "message", "schemaVersion"];
|
||||
if (keys.length !== expectedKeys.length
|
||||
|| !keys.every((key, index) => key === expectedKeys[index]))
|
||||
return false;
|
||||
return candidate.schemaVersion === 1
|
||||
&& candidate.checkId === id
|
||||
&& typeof candidate.accepted === "boolean"
|
||||
&& Number.isInteger(candidate.exitCode)
|
||||
&& candidate.exitCode === processExitCode
|
||||
&& typeof candidate.message === "string"
|
||||
&& candidate.message.length > 0;
|
||||
}
|
||||
|
||||
function copyReport(): bool {
|
||||
if (copyProcess.running)
|
||||
return false;
|
||||
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
||||
root.lastCopyResult = "";
|
||||
copyProcess.exec(["wl-copy"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function diagnostics(): var {
|
||||
return {
|
||||
status: root.status,
|
||||
summary: root.summary,
|
||||
busy: root.busy,
|
||||
diagnosticUnavailable: root.diagnosticUnavailable,
|
||||
queuedRefresh: root.queuedRefresh,
|
||||
generation: root.generation,
|
||||
acceptedGeneration: root.acceptedGeneration,
|
||||
repairingId: root.repairingId,
|
||||
lastRepair: root.lastRepair,
|
||||
lastError: root.lastError,
|
||||
checks: root.checks.map(check => check.id),
|
||||
checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
|
||||
};
|
||||
}
|
||||
|
||||
function validSnapshot(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| candidate.schemaVersion !== 1
|
||||
|| typeof candidate.generatedAt !== "string" || candidate.generatedAt.length === 0
|
||||
|| !root.validSummary(candidate.summary)
|
||||
|| !root.validContext(candidate.context)
|
||||
|| !Array.isArray(candidate.checks) || candidate.checks.length === 0)
|
||||
return false;
|
||||
|
||||
const ids = {};
|
||||
const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
|
||||
for (const check of candidate.checks) {
|
||||
if (!root.validCheck(check) || ids[check.id])
|
||||
return false;
|
||||
ids[check.id] = true;
|
||||
counts[check.status] += 1;
|
||||
}
|
||||
|
||||
const computedStatus = counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy";
|
||||
return candidate.summary.healthy === counts.ok
|
||||
&& candidate.summary.warnings === counts.warning
|
||||
&& candidate.summary.errors === counts.error
|
||||
&& candidate.summary.unconfigured === counts.unconfigured
|
||||
&& candidate.summary.status === computedStatus;
|
||||
}
|
||||
|
||||
function validSummary(candidate: var): bool {
|
||||
if (!root.plainObject(candidate) || root.overallStatuses.indexOf(candidate.status) < 0)
|
||||
return false;
|
||||
for (const key of ["healthy", "warnings", "errors", "unconfigured"]) {
|
||||
if (!Number.isInteger(candidate[key]) || candidate[key] < 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validContext(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| ["hyprland", "other"].indexOf(candidate.session) < 0
|
||||
|| !Array.isArray(candidate.versions))
|
||||
return false;
|
||||
return candidate.versions.every(version => root.plainObject(version)
|
||||
&& typeof version.id === "string" && version.id.length > 0
|
||||
&& typeof version.version === "string" && version.version.length > 0);
|
||||
}
|
||||
|
||||
function validCheck(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(candidate.id)
|
||||
|| root.groups.indexOf(candidate.group) < 0
|
||||
|| root.statuses.indexOf(candidate.status) < 0
|
||||
|| typeof candidate.title !== "string" || candidate.title.length === 0
|
||||
|| typeof candidate.detail !== "string" || candidate.detail.length === 0)
|
||||
return false;
|
||||
return candidate.action === undefined || root.validAction(candidate.action);
|
||||
}
|
||||
|
||||
function validAction(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| ["repair", "open", "instructions"].indexOf(candidate.kind) < 0
|
||||
|| typeof candidate.label !== "string" || candidate.label.length === 0
|
||||
|| typeof candidate.confirm !== "boolean")
|
||||
return false;
|
||||
|
||||
const allowedKeys = ["kind", "label", "confirm", "target"];
|
||||
if (!Object.keys(candidate).every(key => allowedKeys.indexOf(key) >= 0))
|
||||
return false;
|
||||
|
||||
const hasTarget = Object.prototype.hasOwnProperty.call(candidate, "target");
|
||||
if (!hasTarget)
|
||||
return true;
|
||||
if (typeof candidate.target !== "string" || candidate.target.length === 0 || candidate.kind === "repair")
|
||||
return false;
|
||||
return candidate.kind === "open"
|
||||
? root.settingsTargets.indexOf(candidate.target) >= 0
|
||||
: root.instructionTargets.indexOf(candidate.target) >= 0;
|
||||
}
|
||||
|
||||
function plainObject(value: var): bool {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,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 {
|
||||
|
||||
@@ -145,6 +145,26 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "health"
|
||||
|
||||
function refresh(): bool { return Health.refresh(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
summary: Health.summary,
|
||||
busy: Health.busy,
|
||||
generation: Health.generation,
|
||||
acceptedGeneration: Health.acceptedGeneration,
|
||||
checks: Health.checks.map(check => ({ id: check.id, status: check.status }))
|
||||
});
|
||||
}
|
||||
function open(): void {
|
||||
ShellState.openSettings("services");
|
||||
Health.refresh();
|
||||
}
|
||||
function repair(id: string): bool { return Health.repair(id, true); }
|
||||
}
|
||||
|
||||
// A small diagnostics surface doubles as a deterministic contract harness.
|
||||
// Real producers call StatusEvents.publish() directly; fixtures never run
|
||||
// unless explicitly requested over IPC by the test suite.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Check System Health
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Review Panama services, integrations, and recovery actions.
|
||||
# @vicinae.keywords ["health", "doctor", "repair", "services"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" health
|
||||
Reference in New Issue
Block a user