Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc1fdbbbb | ||
|
|
dd8d93c387 | ||
|
|
b8832a0174 | ||
|
|
3f07d25858 | ||
|
|
df1dcdfad5 | ||
|
|
91f7273f41 | ||
|
|
f9eba1e8c5 | ||
|
|
b1edfb6fe4 | ||
|
|
1afa41526a | ||
|
|
08b16fa03f | ||
|
|
ccf46c40ef | ||
|
|
60a321e6f4 | ||
|
|
7d5c65be03 | ||
|
|
f8e7512e01 | ||
|
|
b7ce2c6e43 | ||
|
|
1f62256024 | ||
|
|
4ef2f01baa | ||
|
|
6042c0b1c0 | ||
|
|
faa9a00716 | ||
|
|
8bfae70284 | ||
|
|
2cc109f5e1 | ||
|
|
d855a26bd9 | ||
|
|
8a0a09dbb4 | ||
|
|
3238623934 | ||
|
|
21aa9223df | ||
|
|
e2e03252e4 | ||
|
|
0ca83f74c7 | ||
|
|
d58c431199 | ||
|
|
77f8102148 | ||
|
|
24d8bfd641 | ||
|
|
af0ba13573 |
@@ -37,6 +37,31 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
|
||||
## System health and recovery
|
||||
|
||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||
appears only for actionable warnings or errors and opens the same **System
|
||||
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||
System Health**. The terminal summary is available with:
|
||||
|
||||
```bash
|
||||
~/.config/quickshell/scripts/panama-doctor --summary
|
||||
```
|
||||
|
||||
The doctor reports authored, redacted observations about Panama-owned services,
|
||||
tools, links, and configured integrations. It does not read secrets, clipboard
|
||||
or notification contents, calendar events, SSIDs, or device addresses. Repairs
|
||||
are a small allow-list: Panama user services, Panama-owned links and launcher
|
||||
commands, duplicate Panama Caffeine inhibitors, and a confirmed shell restart.
|
||||
They never install packages, invoke `sudo`, delete user data, or rewrite
|
||||
arbitrary configuration.
|
||||
|
||||
Generic Fedora configuration remains with the system tools that own it. The
|
||||
final System Health card hands network settings, users, sharing, colour
|
||||
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||
than presenting inert Hyprland controls.
|
||||
|
||||
## GNOME extension migration
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
-- instead. They still work here; see the session notes in autostart.lua.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- ── GPU selection ───────────────────────────────────────────────────────────
|
||||
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
|
||||
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
|
||||
@@ -47,7 +49,7 @@ end
|
||||
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
|
||||
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the
|
||||
-- XCursor path, which is what we want.
|
||||
hl.env("XCURSOR_THEME", "oreo_blue_cursors")
|
||||
hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
|
||||
hl.env("XCURSOR_SIZE", "24")
|
||||
|
||||
-- ── Toolkits ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,9 +10,9 @@ local prefs = require("prefs")
|
||||
hl.config({
|
||||
input = {
|
||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||
kb_variant = "",
|
||||
kb_variant = prefs.get("keyboardVariant", ""),
|
||||
kb_model = "",
|
||||
kb_options = "",
|
||||
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
|
||||
kb_rules = "",
|
||||
|
||||
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||
|
||||
+41
-11
@@ -36,7 +36,9 @@ hl.config({
|
||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||
},
|
||||
|
||||
resize_on_border = true,
|
||||
resize_on_border = prefs.get("resizeOnBorder", true),
|
||||
extend_border_grab_area = prefs.getInt("borderGrabArea", 15),
|
||||
hover_icon_on_border = prefs.get("hoverIconOnBorder", true),
|
||||
|
||||
-- Enables the per-window "immediate" rule used for games in rules.lua.
|
||||
-- Harmless on its own; tearing only happens where a rule opts in.
|
||||
@@ -44,16 +46,22 @@ hl.config({
|
||||
|
||||
layout = "dwindle",
|
||||
|
||||
snap = { enabled = true },
|
||||
snap = {
|
||||
enabled = true,
|
||||
window_gap = prefs.getInt("snapWindowGap", 10),
|
||||
monitor_gap = prefs.getInt("snapMonitorGap", 10),
|
||||
respect_gaps = prefs.get("snapRespectGaps", false),
|
||||
},
|
||||
},
|
||||
|
||||
decoration = {
|
||||
-- 18 to match the shell's popover radius, so a window and a panel sitting
|
||||
-- next to each other read as the same object family.
|
||||
rounding = prefs.get("windowRounding", 18),
|
||||
rounding_power = 2,
|
||||
rounding_power = prefs.get("roundingPower", 2),
|
||||
|
||||
active_opacity = 1.0,
|
||||
active_opacity = prefs.get("activeOpacity", 1.0),
|
||||
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
|
||||
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
|
||||
|
||||
blur = {
|
||||
@@ -83,9 +91,13 @@ hl.config({
|
||||
shadow = {
|
||||
enabled = prefs.get("shadowEnabled", true),
|
||||
range = prefs.get("shadowRange", 20),
|
||||
render_power = 3,
|
||||
sharp = false,
|
||||
render_power = prefs.getInt("shadowRenderPower", 3),
|
||||
sharp = prefs.get("shadowSharp", false),
|
||||
color = "rgba(15161eee)",
|
||||
-- Deliberately not a setting: a two-axis offset needs a control we
|
||||
-- do not have, and a slider bound to half a value is worse than
|
||||
-- leaving it alone. SystemSettings understands the vec2 shape
|
||||
-- already, so adding it later is only a matter of the widget.
|
||||
offset = { 0, 4 },
|
||||
scale = 1.0,
|
||||
},
|
||||
@@ -110,14 +122,32 @@ hl.config({
|
||||
dwindle = {
|
||||
-- Keep the split orientation a window was created with. Closest match
|
||||
-- to how the Forge extension behaved on GNOME.
|
||||
preserve_split = true,
|
||||
preserve_split = prefs.get("preserveSplit", true),
|
||||
smart_resizing = true,
|
||||
},
|
||||
|
||||
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
|
||||
-- but Settings offers master as a choice, and a layout you can select and
|
||||
-- cannot configure is barely a choice at all.
|
||||
master = {
|
||||
mfact = prefs.get("masterFactor", 0.55),
|
||||
orientation = prefs.get("masterOrientation", "left"),
|
||||
new_status = prefs.get("masterNewStatus", "slave"),
|
||||
new_on_top = prefs.get("masterNewOnTop", false),
|
||||
},
|
||||
|
||||
misc = {
|
||||
force_default_wallpaper = 0,
|
||||
disable_hyprland_logo = true,
|
||||
disable_splash_rendering = true,
|
||||
-- Stored as "show the logo / show the splash" and written as Hyprland's
|
||||
-- `disable_*`, matching the `invert` flag on these entries in
|
||||
-- PreferenceSchema so both sides agree about which way round they are.
|
||||
disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
|
||||
disable_splash_rendering = not prefs.get("hyprlandSplash", false),
|
||||
|
||||
-- Keep native Wayland selection paste and GTK's matching preference
|
||||
-- in lockstep. DesktopStyle applies the GTK half only after this value
|
||||
-- has been read back and stored by SystemSettings.
|
||||
middle_click_paste = prefs.get("middleClickPaste", true),
|
||||
|
||||
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||
-- followed the preference, the compositor and the shell would disagree
|
||||
@@ -172,8 +202,8 @@ hl.config({
|
||||
},
|
||||
|
||||
ecosystem = {
|
||||
no_update_news = true,
|
||||
no_donation_nag = true,
|
||||
no_update_news = not prefs.get("hyprlandUpdateNews", false),
|
||||
no_donation_nag = not prefs.get("hyprlandDonationNag", false),
|
||||
},
|
||||
|
||||
xwayland = {
|
||||
|
||||
@@ -206,6 +206,147 @@ Singleton {
|
||||
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
||||
},
|
||||
|
||||
{
|
||||
key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||
group: "windows",
|
||||
label: "Focused window opacity",
|
||||
detail: "Fade even the focused window; 1.0 is fully opaque",
|
||||
hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||
group: "windows",
|
||||
label: "Fullscreen opacity",
|
||||
detail: "Applied instead of the focused opacity when a window is fullscreen",
|
||||
hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
|
||||
group: "windows",
|
||||
label: "Corner shape",
|
||||
detail: "2 is a circular corner; higher values approach a squircle",
|
||||
hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
|
||||
},
|
||||
|
||||
// ── Window edges ────────────────────────────────────────────────────
|
||||
// How the pointer interacts with a window's border, and how windows
|
||||
// behave near each other. All shipped by looks.lua with no way to
|
||||
// change any of it.
|
||||
{
|
||||
key: "resizeOnBorder", type: "bool", def: true, group: "edges",
|
||||
label: "Resize by dragging the border",
|
||||
detail: "Drag a window's edge to resize it, instead of only with the keyboard",
|
||||
hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
|
||||
unit: "px",
|
||||
group: "edges",
|
||||
label: "Border grab area",
|
||||
detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
|
||||
hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
|
||||
label: "Show the resize cursor",
|
||||
detail: "Change the pointer when it is over a resizable border",
|
||||
hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||
unit: "px",
|
||||
group: "edges",
|
||||
label: "Snap distance between windows",
|
||||
detail: "How close two floating windows must be before they snap together",
|
||||
hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
|
||||
unit: "px",
|
||||
group: "edges",
|
||||
label: "Snap distance to screen edges",
|
||||
detail: "How close a floating window must be to an edge before it snaps to it",
|
||||
hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "snapRespectGaps", type: "bool", def: false, group: "edges",
|
||||
label: "Snapping respects gaps",
|
||||
detail: "Snapped windows keep the configured gap instead of touching",
|
||||
hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Master layout ───────────────────────────────────────────────────
|
||||
// Only meaningful when the tiling layout is Master and stack. Offering
|
||||
// that layout with none of its options was an omission: it is the one
|
||||
// layout whose whole behaviour is in these settings.
|
||||
{
|
||||
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||
group: "master",
|
||||
label: "Master area size",
|
||||
detail: "How much of the screen the master window takes",
|
||||
hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "masterOrientation", type: "enum", def: "left", group: "master",
|
||||
label: "Master area position",
|
||||
detail: "Which side of the screen the master window occupies",
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "center", label: "Centre" }
|
||||
],
|
||||
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "masterNewStatus", type: "enum", def: "slave", group: "master",
|
||||
label: "New windows become",
|
||||
detail: "Whether a new window takes the master area or joins the stack",
|
||||
options: [
|
||||
{ value: "master", label: "The master window" },
|
||||
{ value: "slave", label: "Part of the stack" },
|
||||
{ value: "inherit", label: "Whatever the focused window is" }
|
||||
],
|
||||
hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "masterNewOnTop", type: "bool", def: false, group: "master",
|
||||
label: "Add new windows at the top",
|
||||
detail: "New stack windows go above the others rather than below",
|
||||
hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Hyprland's own notices ──────────────────────────────────────────
|
||||
// Panama turns all four off on the user's behalf. That is a defensible
|
||||
// default and was not a decision anyone could reverse without editing
|
||||
// looks.lua, which is precisely the kind of thing this app exists to
|
||||
// stop.
|
||||
{
|
||||
key: "hyprlandLogo", type: "bool", def: false, group: "notices",
|
||||
label: "Hyprland wallpaper",
|
||||
detail: "The stock background Hyprland draws when no wallpaper is set",
|
||||
hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
|
||||
},
|
||||
{
|
||||
key: "hyprlandSplash", type: "bool", def: false, group: "notices",
|
||||
label: "Splash text",
|
||||
detail: "The line of text Hyprland renders over the stock background",
|
||||
hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
|
||||
},
|
||||
{
|
||||
key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
|
||||
label: "Update announcements",
|
||||
detail: "The window Hyprland opens after an update to describe what changed",
|
||||
hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
|
||||
},
|
||||
{
|
||||
key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
|
||||
label: "Donation reminders",
|
||||
detail: "The prompt Hyprland shows twice a year asking for support",
|
||||
hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
|
||||
},
|
||||
|
||||
// ── Effects ─────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||
@@ -241,6 +382,19 @@ Singleton {
|
||||
detail: "How far the shadow spreads from the window edge",
|
||||
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "shadowSharp", type: "bool", def: false, group: "effects",
|
||||
label: "Hard-edged shadow",
|
||||
detail: "A crisp shadow instead of a soft falloff",
|
||||
hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
|
||||
group: "effects",
|
||||
label: "Shadow falloff",
|
||||
detail: "How sharply the shadow fades out. Higher is tighter to the window",
|
||||
hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
||||
label: "Focus glow",
|
||||
@@ -282,7 +436,7 @@ Singleton {
|
||||
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "keyboardOptions", type: "string", def: "", group: "input",
|
||||
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
|
||||
// XKB option names are colon-separated pairs in a comma-separated
|
||||
// list, e.g. "compose:ralt,caps:escape".
|
||||
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||
@@ -394,6 +548,12 @@ Singleton {
|
||||
detail: "Swap the primary and secondary buttons",
|
||||
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
|
||||
label: "Middle-click paste",
|
||||
detail: "Paste the primary selection in GTK and native Wayland applications",
|
||||
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Touchpad ────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -652,6 +812,24 @@ Singleton {
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
// choices GNOME applications expose independently of that palette:
|
||||
// their icons and pointer. DesktopStyle only accepts names found in
|
||||
// the read-only XDG catalog before storing them.
|
||||
{
|
||||
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Pointer theme",
|
||||
detail: "The pointer design used by applications and Hyprland"
|
||||
},
|
||||
{
|
||||
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Application icons",
|
||||
detail: "The icon set used by GTK applications"
|
||||
},
|
||||
|
||||
// ── Typography ──────────────────────────────────────────────────────
|
||||
// The single largest thing in this desktop that used to be changeable
|
||||
// only by editing Theme.qml.
|
||||
@@ -681,6 +859,91 @@ Singleton {
|
||||
label: "Interface text size",
|
||||
detail: "The base size the rest of the shell's type scales from"
|
||||
},
|
||||
{
|
||||
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Application font",
|
||||
detail: "Used by menus, controls, and labels in applications"
|
||||
},
|
||||
{
|
||||
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
|
||||
unit: "pt", group: "typography",
|
||||
label: "Application text size",
|
||||
detail: "The base text size used by applications"
|
||||
},
|
||||
{
|
||||
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Document font",
|
||||
detail: "Used for document content when an application follows the system choice"
|
||||
},
|
||||
{
|
||||
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
|
||||
unit: "pt", group: "typography",
|
||||
label: "Document text size",
|
||||
detail: "The default text size for document content"
|
||||
},
|
||||
{
|
||||
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
|
||||
label: "Monospace font",
|
||||
detail: "Used by terminals, editors, and code fields that follow the system choice"
|
||||
},
|
||||
{
|
||||
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
|
||||
unit: "pt", group: "typography",
|
||||
label: "Monospace text size",
|
||||
detail: "The default text size for terminals and code"
|
||||
},
|
||||
{
|
||||
key: "fontHinting", type: "enum", def: "slight", group: "typography",
|
||||
label: "Font hinting",
|
||||
detail: "How strongly text aligns to the pixel grid",
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "slight", label: "Slight" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "full", label: "Full" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
|
||||
label: "Text smoothing",
|
||||
detail: "How application text softens its edges",
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "grayscale", label: "Grayscale" },
|
||||
{ value: "rgba", label: "Subpixel" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application titlebars ──────────────────────────────────────────
|
||||
// These affect applications that honour GNOME's window preferences.
|
||||
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||
// deliberately absent rather than presented as a switch that lies.
|
||||
{
|
||||
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
|
||||
label: "Button side",
|
||||
detail: "Place application titlebar buttons on the left or right",
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
|
||||
label: "Maximize button",
|
||||
detail: "Show a maximize button in application titlebars that support it"
|
||||
},
|
||||
{
|
||||
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
|
||||
label: "Double-click titlebar",
|
||||
detail: "Choose what a double-click on an application titlebar does",
|
||||
options: [
|
||||
{ value: "toggle-maximize", label: "Toggle maximize" },
|
||||
{ value: "none", label: "Do nothing" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────
|
||||
// Backed by gsettings so GTK applications agree with the shell, and
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,8 +17,10 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
property string expandedPicker: ""
|
||||
|
||||
title: "Appearance"
|
||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
||||
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||
|
||||
header: Component {
|
||||
Column {
|
||||
@@ -83,7 +85,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Typography"
|
||||
title: "Shell typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||
@@ -123,6 +125,137 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application typography"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
|
||||
|
||||
ActionRow {
|
||||
label: "Application font"
|
||||
detail: DesktopStyle.applicationFont
|
||||
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
visible: root.expandedPicker === "application-font"
|
||||
width: parent.width
|
||||
families: Fonts.interfaceFonts
|
||||
current: DesktopStyle.applicationFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
|
||||
onPicked: family => {
|
||||
if (DesktopStyle.setApplicationFont(family))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow { setting: "applicationFontSize" }
|
||||
|
||||
ActionRow {
|
||||
label: "Document font"
|
||||
detail: DesktopStyle.documentFont
|
||||
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
visible: root.expandedPicker === "document-font"
|
||||
width: parent.width
|
||||
families: Fonts.interfaceFonts
|
||||
current: DesktopStyle.documentFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
|
||||
onPicked: family => {
|
||||
if (DesktopStyle.setDocumentFont(family))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow { setting: "documentFontSize" }
|
||||
|
||||
ActionRow {
|
||||
label: "Monospace font"
|
||||
detail: DesktopStyle.monospaceFont
|
||||
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
visible: root.expandedPicker === "monospace-font"
|
||||
width: parent.width
|
||||
families: Fonts.monospaceFonts
|
||||
current: DesktopStyle.monospaceFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
|
||||
onPicked: family => {
|
||||
if (DesktopStyle.setMonospaceFont(family))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
SliderRow { setting: "monospaceFontSize" }
|
||||
ChoiceRow { setting: "fontHinting" }
|
||||
ChoiceRow { setting: "fontAntialiasing"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Icons & pointer"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
: "Installed themes only. The pointer updates in applications and Hyprland together."
|
||||
|
||||
ActionRow {
|
||||
label: "Application icons"
|
||||
detail: DesktopStyle.iconTheme
|
||||
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
|
||||
enabled: DesktopStyle.catalogLoaded
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
|
||||
}
|
||||
|
||||
SearchPicker {
|
||||
visible: root.expandedPicker === "icon-theme"
|
||||
width: parent.width
|
||||
items: DesktopStyle.iconThemes
|
||||
current: DesktopStyle.iconTheme
|
||||
placeholder: "Search icon themes"
|
||||
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
|
||||
onPicked: value => {
|
||||
if (DesktopStyle.setIconTheme(value))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Pointer theme"
|
||||
detail: DesktopStyle.cursorTheme
|
||||
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
|
||||
enabled: DesktopStyle.catalogLoaded
|
||||
divider: false
|
||||
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
|
||||
}
|
||||
|
||||
SearchPicker {
|
||||
visible: root.expandedPicker === "cursor-theme"
|
||||
width: parent.width
|
||||
items: DesktopStyle.cursorThemes
|
||||
current: DesktopStyle.cursorTheme
|
||||
placeholder: "Search pointer themes"
|
||||
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
|
||||
onPicked: value => {
|
||||
if (DesktopStyle.setCursorTheme(value))
|
||||
root.expandedPicker = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Titlebars"
|
||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||
|
||||
ChoiceRow { setting: "titlebarButtonSide" }
|
||||
ToggleRow { setting: "titlebarMaximizeButton" }
|
||||
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
@@ -131,7 +264,10 @@ SettingsPage {
|
||||
SliderRow { setting: "gapsIn" }
|
||||
SliderRow { setting: "gapsOut" }
|
||||
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
||||
SliderRow { setting: "roundingPower" }
|
||||
SliderRow { setting: "inactiveOpacity" }
|
||||
SliderRow { setting: "activeOpacity" }
|
||||
SliderRow { setting: "fullscreenOpacity"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -143,6 +279,8 @@ SettingsPage {
|
||||
SliderRow { setting: "blurPasses" }
|
||||
ToggleRow { setting: "shadowEnabled" }
|
||||
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||
SliderRow { setting: "shadowRenderPower" }
|
||||
ToggleRow { setting: "shadowSharp" }
|
||||
ToggleRow { setting: "glowEnabled" }
|
||||
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||
@@ -182,11 +320,4 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Theme"
|
||||
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
||||
|
||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ SettingsPage {
|
||||
lede: "Choose what opens your files and links, and what starts with your session."
|
||||
|
||||
property string expandedRole: ""
|
||||
property bool addingAutostart: false
|
||||
readonly property var applications: DesktopEntries.applications.values
|
||||
readonly property var roles: [
|
||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||
@@ -178,7 +179,28 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "User autostart"
|
||||
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
||||
subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor."
|
||||
|
||||
ActionRow {
|
||||
label: "Add an application"
|
||||
detail: root.addingAutostart
|
||||
? "Search the applications installed on this machine"
|
||||
: "Start another installed application when you sign in"
|
||||
action: root.addingAutostart ? "Close" : "Choose"
|
||||
divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0
|
||||
enabled: !DefaultApps.busy
|
||||
onTriggered: root.addingAutostart = !root.addingAutostart
|
||||
}
|
||||
|
||||
AutostartAppPicker {
|
||||
visible: root.addingAutostart
|
||||
width: parent.width
|
||||
existing: DefaultApps.autostartEntries.map(entry => entry.id)
|
||||
onPicked: id => {
|
||||
DefaultApps.addAutostart(id);
|
||||
root.addingAutostart = false;
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Adds an installed application to the user's freedesktop autostart directory.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
required property var existing
|
||||
signal picked(string id)
|
||||
|
||||
spacing: 0
|
||||
|
||||
function desktopId(entry: var): string {
|
||||
const id = String(entry?.id ?? "");
|
||||
return id.endsWith(".desktop") ? id : id + ".desktop";
|
||||
}
|
||||
|
||||
readonly property var matches: {
|
||||
const needle = search.text.trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return [];
|
||||
const out = [];
|
||||
for (const entry of DesktopEntries.applications.values) {
|
||||
const desktopId = root.desktopId(entry);
|
||||
if (entry.noDisplay || root.existing.indexOf(desktopId) >= 0)
|
||||
continue;
|
||||
const haystack = `${entry.name ?? ""} ${entry.genericName ?? ""} ${desktopId}`.toLowerCase();
|
||||
if (haystack.indexOf(needle) >= 0)
|
||||
out.push(entry);
|
||||
if (out.length >= 8)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: search
|
||||
width: parent.width
|
||||
placeholder: "Search installed applications"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.matches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(candidate.modelData.name || root.desktopId(candidate.modelData))
|
||||
detail: String(candidate.modelData.genericName || root.desktopId(candidate.modelData))
|
||||
divider: candidate.index < root.matches.length - 1
|
||||
controlWidth: 86
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Add"
|
||||
onClicked: {
|
||||
root.picked(root.desktopId(candidate.modelData));
|
||||
search.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: search.text.trim() !== "" && root.matches.length === 0
|
||||
label: "No matching applications"
|
||||
detail: "Only installed desktop applications can start with the session"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,45 @@ SettingsPage {
|
||||
}
|
||||
|
||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||
// Only meaningful when the layout above is Master and stack. Hidden
|
||||
// otherwise, because a card of settings that do nothing under the layout
|
||||
// you are actually running is worse than not offering the layout at all.
|
||||
SettingsCard {
|
||||
visible: DesktopPreferences.get("windowLayout") === "master"
|
||||
title: "Master and stack"
|
||||
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
|
||||
|
||||
SliderRow { setting: "masterFactor" }
|
||||
ChoiceRow { setting: "masterOrientation" }
|
||||
ChoiceRow { setting: "masterNewStatus" }
|
||||
ToggleRow { setting: "masterNewOnTop"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window edges"
|
||||
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
|
||||
|
||||
ToggleRow { setting: "resizeOnBorder" }
|
||||
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
|
||||
ToggleRow { setting: "hoverIconOnBorder" }
|
||||
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
|
||||
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
|
||||
ToggleRow { setting: "snapRespectGaps"; divider: false }
|
||||
}
|
||||
|
||||
// Hyprland's own interruptions. Panama turns all four off, which is a
|
||||
// defensible default and was not previously a decision anyone could
|
||||
// reverse without editing looks.lua.
|
||||
SettingsCard {
|
||||
title: "Hyprland notices"
|
||||
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
|
||||
|
||||
ToggleRow { setting: "hyprlandLogo" }
|
||||
ToggleRow { setting: "hyprlandSplash" }
|
||||
ToggleRow { setting: "hyprlandUpdateNews" }
|
||||
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Workspaces & focus"
|
||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||
@@ -74,8 +113,7 @@ SettingsPage {
|
||||
ToggleRow { setting: "workspaceBackAndForth" }
|
||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||
ToggleRow { setting: "focusOnActivate" }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||
ChoiceRow { setting: "followMouse"; divider: false }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -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,458 @@
|
||||
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);
|
||||
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.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)),
|
||||
fedoraHandoffs: fedoraHandoffs.map(row => ({
|
||||
id: String(row.objectName).slice("health-fedora-handoff:".length),
|
||||
label: row.label,
|
||||
action: row.action
|
||||
})),
|
||||
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: "These areas remain owned by Fedora and GNOME's mature system panels."
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: 42
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: gnomeSettingsButton.left
|
||||
anchors.rightMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Use GNOME Settings for the parts of the system this app does not manage."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: gnomeSettingsButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Open GNOME Settings"
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: SystemSettings.openGnomePanel("network")
|
||||
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
|
||||
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:users"
|
||||
label: "Users"
|
||||
detail: "Accounts, passwords, and automatic login"
|
||||
action: "Open users"
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:sharing"
|
||||
label: "Sharing"
|
||||
detail: "Remote desktop, media sharing, and remote login"
|
||||
action: "Open sharing"
|
||||
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
action: "Open colour"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:wellbeing"
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,12 @@ SettingsPage {
|
||||
ChoiceRow { setting: "accelProfile" }
|
||||
ToggleRow { setting: "naturalScroll" }
|
||||
SliderRow { setting: "scrollFactor" }
|
||||
ToggleRow { setting: "leftHanded"; divider: false }
|
||||
ToggleRow { setting: "leftHanded" }
|
||||
ToggleRow {
|
||||
setting: "middleClickPaste"
|
||||
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -4,6 +4,33 @@ The control centre for everything Panama owns. Anything the system owns —
|
||||
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||
such rather than half-reimplemented.
|
||||
|
||||
## System Health
|
||||
|
||||
The stable internal `services` route renders **System Health**. It is reachable
|
||||
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
||||
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
|
||||
reserve no bar space and produce no notification.
|
||||
|
||||
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||
`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an
|
||||
explicit **Copy Report**, and bounded `notify-send` only when an external repair
|
||||
fails. For a concise terminal view, run:
|
||||
|
||||
```bash
|
||||
~/.config/quickshell/scripts/panama-doctor --summary
|
||||
```
|
||||
|
||||
The helper diagnoses Panama-owned desktop services, dependencies, links, and
|
||||
configured integrations. It does not read secret values, clipboard or
|
||||
notification contents, calendar events, SSIDs, addresses, or arbitrary command
|
||||
output. Its repair interface is an authored allow-list: it never installs a
|
||||
package, runs `sudo`, deletes user data, or repairs a service Panama does not
|
||||
own. A repair remains degraded until a fresh scan observes recovery.
|
||||
|
||||
The final card is the ownership boundary. Network configuration and the exact
|
||||
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
|
||||
Settings because Fedora's system services own those areas.
|
||||
|
||||
## Adding a setting
|
||||
|
||||
One schema entry. That is the whole job.
|
||||
@@ -83,11 +110,12 @@ slot**, because that is the row's default property, so only the right-hand edge
|
||||
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
|
||||
target.
|
||||
|
||||
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
|
||||
derives the Shell ID from config *content*, not path, so
|
||||
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
|
||||
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
|
||||
single distinct `.qml` file are safe; copying the whole directory is not.
|
||||
**A content-identical Quickshell entry can share the live shell's ID.**
|
||||
Quickshell derives the Shell ID from config *content*, not path. Runtime
|
||||
harnesses therefore create a distinct semantic entry file, address that exact
|
||||
file with `qs -p`, and discover its PID from the exact Config path in
|
||||
`qs list --all`. They terminate only that recorded PID with `kill`; never use
|
||||
`qs kill` from a copied configuration.
|
||||
|
||||
## Where state lives
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,25 @@ SettingsPage {
|
||||
// when nothing is being captured. Held here rather than per row so that
|
||||
// starting a new capture cancels any other.
|
||||
property string capturingChord: ""
|
||||
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||
|
||||
function xkbOptions(): var {
|
||||
return root.storedXkbOptions
|
||||
.split(",")
|
||||
.map(option => option.trim())
|
||||
.filter(option => option !== "");
|
||||
}
|
||||
|
||||
function currentXkbOption(prefix: string): string {
|
||||
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
|
||||
}
|
||||
|
||||
function setXkbOption(prefix: string, option: string): void {
|
||||
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
|
||||
if (option !== "")
|
||||
options.push(option);
|
||||
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
||||
}
|
||||
|
||||
title: "Input & Shortcuts"
|
||||
lede: "The Forge mental model, carried forward into native tiling."
|
||||
@@ -35,6 +54,52 @@ SettingsPage {
|
||||
// they are real controls.
|
||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Caps Lock"
|
||||
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
||||
current: root.currentXkbOption("caps:")
|
||||
options: [
|
||||
{ value: "", label: "Standard" },
|
||||
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
|
||||
{ value: "caps:escape", label: "Escape" },
|
||||
{ value: "caps:ctrl_modifier", label: "Control" }
|
||||
]
|
||||
onPicked: value => root.setXkbOption("caps:", value)
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Compose key"
|
||||
detail: "Type accented characters and symbols with memorable key sequences"
|
||||
current: root.currentXkbOption("compose:")
|
||||
options: [
|
||||
{ value: "", label: "Off" },
|
||||
{ value: "compose:ralt", label: "Right Alt" },
|
||||
{ value: "compose:rwin", label: "Right Super" },
|
||||
{ value: "compose:menu", label: "Menu" }
|
||||
]
|
||||
onPicked: value => root.setXkbOption("compose:", value)
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Layout switching"
|
||||
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
||||
current: root.currentXkbOption("grp:")
|
||||
options: [
|
||||
{ value: "", label: "Off" },
|
||||
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
||||
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
|
||||
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
||||
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
||||
]
|
||||
onPicked: value => root.setXkbOption("grp:", value)
|
||||
}
|
||||
|
||||
// Presets preserve every option outside their own category. The raw
|
||||
// value remains visible for less common xkeyboard-config features.
|
||||
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||
SliderRow { setting: "keyRepeatDelay" }
|
||||
SliderRow { setting: "keyRepeatRate" }
|
||||
|
||||
@@ -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
|
||||
@@ -32,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.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
|
||||
|
||||
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
|
||||
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
|
||||
|
||||
|
||||
def discovered_desktop_ids() -> set[str]:
|
||||
desktop_ids: set[str] = set()
|
||||
def discovered_desktop_files() -> dict[str, Path]:
|
||||
desktop_files: dict[str, Path] = {}
|
||||
for root in xdg_data_roots():
|
||||
applications = root / "applications"
|
||||
if not applications.is_dir():
|
||||
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(applications)
|
||||
desktop_ids.add("-".join(relative.parts))
|
||||
return desktop_ids
|
||||
desktop_files.setdefault("-".join(relative.parts), path)
|
||||
return desktop_files
|
||||
|
||||
|
||||
def discovered_desktop_ids() -> set[str]:
|
||||
return set(discovered_desktop_files())
|
||||
|
||||
|
||||
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
|
||||
@@ -184,12 +188,7 @@ def set_default(role: str, desktop_id: str) -> None:
|
||||
run(command)
|
||||
|
||||
|
||||
def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||
try:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise BoundaryError("That autostart entry could not be read.") from error
|
||||
|
||||
def with_hidden(original: str, *, hidden: bool) -> str:
|
||||
lines = original.splitlines()
|
||||
output: list[str] = []
|
||||
section = ""
|
||||
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||
raise BoundaryError("That autostart entry is not a desktop file.")
|
||||
if not wrote_hidden:
|
||||
output.append(f"Hidden={'true' if hidden else 'false'}")
|
||||
return "\n".join(output) + "\n"
|
||||
|
||||
mode = path.stat().st_mode
|
||||
|
||||
def write_atomic(path: Path, text: str, *, mode: int) -> None:
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||
) as temporary:
|
||||
temporary.write("\n".join(output) + "\n")
|
||||
temporary.write(text)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
temporary_path.chmod(mode)
|
||||
os.replace(temporary_path, path)
|
||||
except OSError as error:
|
||||
if "temporary_path" in locals():
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise BoundaryError("That autostart entry could not be updated.") from error
|
||||
|
||||
|
||||
def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||
try:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise BoundaryError("That autostart entry could not be read.") from error
|
||||
|
||||
mode = path.stat().st_mode
|
||||
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
|
||||
|
||||
|
||||
def add_autostart(desktop_id: str) -> None:
|
||||
desktop_files = discovered_desktop_files()
|
||||
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
||||
source = desktop_files[desktop_id]
|
||||
directory = autostart_directory()
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
raise BoundaryError("The user autostart directory could not be created.") from error
|
||||
|
||||
target = directory / desktop_id
|
||||
if target.is_symlink():
|
||||
raise BoundaryError("That autostart entry is not available.")
|
||||
if target.exists():
|
||||
if not target.is_file():
|
||||
raise BoundaryError("That autostart entry is not available.")
|
||||
update_hidden(target, hidden=False)
|
||||
return
|
||||
|
||||
try:
|
||||
original = source.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise BoundaryError("That application could not be read.") from error
|
||||
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
|
||||
|
||||
|
||||
def set_autostart(desktop_id: str, enabled_text: str) -> None:
|
||||
if enabled_text not in {"true", "false"}:
|
||||
raise BoundaryError("Autostart state must be true or false.")
|
||||
@@ -260,10 +298,12 @@ def main(arguments: list[str]) -> int:
|
||||
set_default(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||
set_autostart(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||
add_autostart(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
||||
"set-autostart DESKTOP_ID true|false"
|
||||
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||
)
|
||||
except BoundaryError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Report installed cursor and icon themes from the standard XDG roots.
|
||||
|
||||
This helper is intentionally read-only and argument-free. Theme paths come
|
||||
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def icon_roots() -> list[Path]:
|
||||
home = Path(os.environ.get("HOME") or "/nonexistent")
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
|
||||
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
|
||||
|
||||
roots = [data_home / "icons"]
|
||||
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
|
||||
# XDG paths are required to be absolute. Ignoring malformed relative
|
||||
# entries also prevents the helper's working directory becoming an
|
||||
# accidental caller-controlled search root.
|
||||
return [root for root in roots if root.is_absolute()]
|
||||
|
||||
|
||||
def is_real_directory(path: Path) -> bool:
|
||||
try:
|
||||
return stat.S_ISDIR(path.lstat().st_mode)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def is_real_file(path: Path) -> bool:
|
||||
try:
|
||||
return stat.S_ISREG(path.lstat().st_mode)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def has_icon_directories(index_path: Path) -> bool:
|
||||
try:
|
||||
with index_path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if line.startswith(("#", ";")) or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == "Directories":
|
||||
return bool(value.strip())
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def catalog() -> dict[str, list[str]]:
|
||||
cursor_themes: set[str] = set()
|
||||
icon_themes: set[str] = set()
|
||||
|
||||
for root in icon_roots():
|
||||
if not is_real_directory(root):
|
||||
continue
|
||||
try:
|
||||
entries = list(os.scandir(root))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
if not entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
theme = Path(entry.path)
|
||||
if is_real_directory(theme / "cursors"):
|
||||
cursor_themes.add(entry.name)
|
||||
|
||||
index_path = theme / "index.theme"
|
||||
if is_real_file(index_path) and has_icon_directories(index_path):
|
||||
icon_themes.add(entry.name)
|
||||
|
||||
return {
|
||||
"cursorThemes": sorted(cursor_themes, key=str.casefold),
|
||||
"iconThemes": sorted(icon_themes, key=str.casefold),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 1:
|
||||
print("panama-desktop-style takes no arguments", file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+873
@@ -0,0 +1,873 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Redacted diagnostics and bounded repairs for Panama-owned functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import signal
|
||||
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"]
|
||||
InhibitorRow = tuple[str, str, str, str, str, str, str, str]
|
||||
|
||||
AT_FDCWD = -100
|
||||
RENAME_NOREPLACE = 1
|
||||
RENAME_EXCHANGE = 2
|
||||
|
||||
|
||||
@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 = ("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/panama"
|
||||
try:
|
||||
linked = source.is_dir() and installed.is_symlink() \
|
||||
and installed.resolve(strict=False) == source.resolve(strict=True)
|
||||
except OSError:
|
||||
linked = False
|
||||
if linked:
|
||||
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:
|
||||
# `qs` is both the long-running shell and every short-lived IPC client.
|
||||
# Counting it with pgrep races the other parallel health probes and reports
|
||||
# duplicates whenever one of them happens to call `qs ipc`. The instance
|
||||
# list is the authoritative view and contains only actual shells.
|
||||
quickshell = run_command(("qs", "list"), config)
|
||||
if quickshell.state == "ok":
|
||||
quickshell_count = sum(
|
||||
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
|
||||
)
|
||||
elif quickshell.state == "failed":
|
||||
quickshell_count = 0
|
||||
else:
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||
|
||||
counts: list[int] = [quickshell_count]
|
||||
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.")
|
||||
inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid()))
|
||||
if inhibitor_rows is None:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
|
||||
inhibitors = len(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
|
||||
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 lexical_path(path: Path) -> Path:
|
||||
"""Normalize dot segments without following any filesystem symlink."""
|
||||
return Path(os.path.abspath(os.fspath(path)))
|
||||
|
||||
|
||||
def lexical_link_target(destination: Path) -> Path:
|
||||
target = Path(os.readlink(destination))
|
||||
return lexical_path(target if target.is_absolute() else destination.parent / target)
|
||||
|
||||
|
||||
def renameat2(source: Path, destination: Path, flags: int) -> None:
|
||||
"""Call Linux renameat2 with fixed flags selected by authored code."""
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
function = getattr(libc, "renameat2", None)
|
||||
if function is None:
|
||||
raise OSError(errno.ENOSYS, "renameat2 is unavailable")
|
||||
function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
function.restype = ctypes.c_int
|
||||
result = function(
|
||||
AT_FDCWD,
|
||||
os.fsencode(source),
|
||||
AT_FDCWD,
|
||||
os.fsencode(destination),
|
||||
flags,
|
||||
)
|
||||
if result != 0:
|
||||
error = ctypes.get_errno()
|
||||
raise OSError(error, os.strerror(error), destination)
|
||||
|
||||
|
||||
def rename_exchange(source: Path, destination: Path) -> None:
|
||||
renameat2(source, destination, RENAME_EXCHANGE)
|
||||
|
||||
|
||||
def rename_noreplace(source: Path, destination: Path) -> None:
|
||||
renameat2(source, destination, RENAME_NOREPLACE)
|
||||
|
||||
|
||||
def create_symlink_candidate(destination: Path, source: Path) -> Path:
|
||||
"""Create one unpredictable authored sibling candidate symlink."""
|
||||
for _ in range(32):
|
||||
candidate = destination.with_name(
|
||||
f".panama-link-{destination.name}-{os.getpid()}-{secrets.token_hex(8)}"
|
||||
)
|
||||
try:
|
||||
os.symlink(source, candidate, target_is_directory=True)
|
||||
return candidate
|
||||
except FileExistsError:
|
||||
continue
|
||||
raise OSError("Could not allocate an authored temporary link")
|
||||
|
||||
|
||||
def cleanup_candidate(candidate: Path) -> None:
|
||||
try:
|
||||
candidate.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def install_absent_symlink(destination: Path, source: Path) -> Literal["repaired", "blocked", "failed"]:
|
||||
candidate = create_symlink_candidate(destination, source)
|
||||
try:
|
||||
try:
|
||||
rename_noreplace(candidate, destination)
|
||||
except FileExistsError:
|
||||
return "blocked"
|
||||
except OSError:
|
||||
return "failed"
|
||||
return "repaired"
|
||||
finally:
|
||||
cleanup_candidate(candidate)
|
||||
|
||||
|
||||
def exchange_owned_symlink(
|
||||
destination: Path,
|
||||
source: Path,
|
||||
authored_sources: frozenset[Path],
|
||||
) -> Literal["repaired", "blocked", "failed"]:
|
||||
"""Exchange first, then validate the exact object removed from destination."""
|
||||
candidate = create_symlink_candidate(destination, source)
|
||||
exchanged = False
|
||||
rolled_back = False
|
||||
try:
|
||||
try:
|
||||
rename_exchange(candidate, destination)
|
||||
exchanged = True
|
||||
except OSError:
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
old_is_authored = candidate.is_symlink() \
|
||||
and lexical_link_target(candidate) in authored_sources
|
||||
except OSError:
|
||||
old_is_authored = False
|
||||
if old_is_authored:
|
||||
cleanup_candidate(candidate)
|
||||
return "repaired"
|
||||
|
||||
try:
|
||||
rename_exchange(candidate, destination)
|
||||
rolled_back = True
|
||||
except OSError:
|
||||
# The displaced object remains at the unpredictable candidate path;
|
||||
# never unlink it when rollback could not restore ownership.
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
restored_candidate_is_ours = candidate.is_symlink() \
|
||||
and lexical_link_target(candidate) == source
|
||||
except OSError:
|
||||
restored_candidate_is_ours = False
|
||||
if not restored_candidate_is_ours:
|
||||
return "failed"
|
||||
cleanup_candidate(candidate)
|
||||
return "blocked"
|
||||
finally:
|
||||
if not exchanged or rolled_back:
|
||||
try:
|
||||
if candidate.is_symlink() and lexical_link_target(candidate) == source:
|
||||
cleanup_candidate(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def repair_runtime_links(config: DoctorConfig) -> RepairResult:
|
||||
root = lexical_path(config.root)
|
||||
sources = [(name, lexical_path(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.")
|
||||
if any(not source.is_relative_to(root) for _, source in sources):
|
||||
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are invalid.")
|
||||
authored_sources = frozenset(source for _, source in sources)
|
||||
|
||||
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():
|
||||
current_target = lexical_link_target(destination)
|
||||
if current_target == source:
|
||||
continue
|
||||
if current_target not in authored_sources:
|
||||
blocked = True
|
||||
continue
|
||||
outcome = exchange_owned_symlink(destination, source, authored_sources)
|
||||
blocked = blocked or outcome == "blocked"
|
||||
failed = failed or outcome == "failed"
|
||||
elif destination.exists():
|
||||
# A regular file or directory is user-owned unless proven
|
||||
# otherwise. Report it, but never replace it.
|
||||
blocked = True
|
||||
else:
|
||||
outcome = install_absent_symlink(destination, source)
|
||||
blocked = blocked or outcome == "blocked"
|
||||
failed = failed or outcome == "failed"
|
||||
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 runtime path is blocking a Panama 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 parse_caffeine_rows(output: str, uid: str) -> list[InhibitorRow] | None:
|
||||
inhibitor_rows: list[InhibitorRow] = []
|
||||
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:
|
||||
if "Caffeine" in parts:
|
||||
return None
|
||||
continue
|
||||
if parts[6] != "Caffeine" or parts[7] != "block":
|
||||
continue
|
||||
if not parts[3].isdecimal():
|
||||
return None
|
||||
inhibitor_rows.append(tuple(parts))
|
||||
return inhibitor_rows
|
||||
|
||||
|
||||
def close_pidfds(pidfds: list[int]) -> None:
|
||||
for pidfd in pidfds:
|
||||
try:
|
||||
os.close(pidfd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def signal_caffeine_pidfds(
|
||||
pidfds: list[int],
|
||||
sender: Callable[..., None] | None = None,
|
||||
) -> Literal["released", "preflight-failed", "incomplete"]:
|
||||
send = sender or signal.pidfd_send_signal
|
||||
for pidfd in pidfds:
|
||||
try:
|
||||
send(pidfd, 0, None, 0)
|
||||
except (OSError, ValueError):
|
||||
return "preflight-failed"
|
||||
|
||||
incomplete = False
|
||||
for pidfd in pidfds:
|
||||
try:
|
||||
send(pidfd, signal.SIGTERM, None, 0)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as error:
|
||||
if error.errno != errno.ESRCH:
|
||||
incomplete = True
|
||||
except ValueError:
|
||||
incomplete = True
|
||||
return "incomplete" if incomplete else "released"
|
||||
|
||||
|
||||
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_rows = parse_caffeine_rows(output, uid)
|
||||
if inhibitor_rows is None:
|
||||
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
||||
inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
|
||||
|
||||
if len(inhibitor_pids) <= 1:
|
||||
return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.")
|
||||
|
||||
duplicates = inhibitor_pids[1:]
|
||||
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
|
||||
return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.")
|
||||
|
||||
pidfds: list[int] = []
|
||||
try:
|
||||
try:
|
||||
pidfds = [os.pidfd_open(pid, 0) for pid in duplicates]
|
||||
except (OSError, ValueError):
|
||||
return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.")
|
||||
|
||||
second_exit, second_output = run_repair_command(list_command, config)
|
||||
if second_exit != 0:
|
||||
return RepairResult("panama.caffeine", True, second_exit, "Caffeine inhibitors could not be revalidated; nothing was released.")
|
||||
second_rows = parse_caffeine_rows(second_output, uid)
|
||||
if second_rows is None or second_rows != inhibitor_rows:
|
||||
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata changed; nothing was released.")
|
||||
|
||||
signal_outcome = signal_caffeine_pidfds(pidfds)
|
||||
if signal_outcome == "preflight-failed":
|
||||
return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.")
|
||||
if signal_outcome == "incomplete":
|
||||
return RepairResult("panama.caffeine", True, 1, "One or more duplicate inhibitors could not be released.")
|
||||
finally:
|
||||
close_pidfds(pidfds)
|
||||
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:]))
|
||||
@@ -25,6 +25,9 @@ PLUGIN_ACTIONS = {
|
||||
"kdeconnect_share": "share",
|
||||
}
|
||||
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
|
||||
DEVICE_OBJECT_LINE = re.compile(
|
||||
rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[A-Fa-f0-9]{{32,64}}))$"
|
||||
)
|
||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
@@ -107,6 +110,13 @@ def parse_string_property(output: str) -> str:
|
||||
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
||||
|
||||
|
||||
def parse_bool_property(output: str) -> bool | None:
|
||||
parts = output.split()
|
||||
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
|
||||
return None
|
||||
return parts[1] == "true"
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
*,
|
||||
@@ -179,6 +189,82 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
|
||||
return parse_string_property(result.stdout) if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def device_property(
|
||||
device_id: str,
|
||||
member: str,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return run_command(
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"get-property",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
"org.kde.kdeconnect.device",
|
||||
member,
|
||||
],
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
|
||||
def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]:
|
||||
try:
|
||||
result = run_command(
|
||||
["busctl", "--user", "tree", "org.kde.kdeconnect"],
|
||||
runner=runner,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
match.group("id")
|
||||
for line in result.stdout.splitlines()
|
||||
if (match := DEVICE_OBJECT_LINE.search(line.strip())) is not None
|
||||
]
|
||||
|
||||
|
||||
def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]:
|
||||
devices: list[dict[str, object]] = []
|
||||
for device_id in dbus_device_ids(runner):
|
||||
try:
|
||||
name_result = device_property(device_id, "name", runner)
|
||||
type_result = device_property(device_id, "type", runner)
|
||||
paired_result = device_property(device_id, "isPaired", runner)
|
||||
reachable_result = device_property(device_id, "isReachable", runner)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
name = parse_string_property(name_result.stdout) if name_result.returncode == 0 else ""
|
||||
device_type = parse_string_property(type_result.stdout) if type_result.returncode == 0 else ""
|
||||
paired = parse_bool_property(paired_result.stdout) if paired_result.returncode == 0 else None
|
||||
reachable = parse_bool_property(reachable_result.stdout) if reachable_result.returncode == 0 else None
|
||||
if not name or paired is not True or reachable is None:
|
||||
continue
|
||||
try:
|
||||
plugins = device_plugins(device_id, runner)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
plugins = []
|
||||
actions = sorted(
|
||||
{
|
||||
action
|
||||
for plugin, action in PLUGIN_ACTIONS.items()
|
||||
if plugin in plugins
|
||||
}
|
||||
)
|
||||
devices.append(
|
||||
{
|
||||
"id": device_id,
|
||||
"name": name,
|
||||
"type": device_type or inferred_type(name),
|
||||
"paired": paired,
|
||||
"reachable": reachable,
|
||||
"actions": actions,
|
||||
}
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
||||
try:
|
||||
listing = run_command(
|
||||
@@ -200,15 +286,24 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
||||
continue
|
||||
device_id = match.group("id")
|
||||
try:
|
||||
device = normalize_device_line(
|
||||
line,
|
||||
device_plugins(device_id, runner),
|
||||
reported_type(device_id, runner),
|
||||
)
|
||||
plugins = device_plugins(device_id, runner)
|
||||
device_type = reported_type(device_id, runner)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
plugins = []
|
||||
device_type = ""
|
||||
try:
|
||||
device = normalize_device_line(line, plugins, device_type)
|
||||
except ValueError:
|
||||
continue
|
||||
devices.append(device)
|
||||
|
||||
known_ids = {str(device["id"]) for device in devices}
|
||||
devices.extend(
|
||||
device
|
||||
for device in dbus_devices(runner)
|
||||
if str(device["id"]) not in known_ids
|
||||
)
|
||||
|
||||
devices.sort(
|
||||
key=lambda device: (
|
||||
not bool(device["reachable"]),
|
||||
|
||||
@@ -25,25 +25,14 @@ import qs.config
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string cursorTheme: ""
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0
|
||||
readonly property bool busy: runner.running || root.pending.length > 0
|
||||
|
||||
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||||
readonly property real textScale: DesktopPreferences.get("textScale")
|
||||
|
||||
Process {
|
||||
id: themeQuery
|
||||
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// gsettings quotes strings: 'oreo_blue_cursors'
|
||||
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A short queue, because applying one setting takes several commands and
|
||||
// Process runs one at a time.
|
||||
property var pending: []
|
||||
@@ -71,7 +60,6 @@ Singleton {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
themeQuery.running = true;
|
||||
settle.restart();
|
||||
}
|
||||
|
||||
@@ -102,9 +90,6 @@ Singleton {
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||||
];
|
||||
// setcursor needs a theme name; skip it rather than guess if gsettings
|
||||
// has not answered yet. The next change will catch up.
|
||||
if (root.cursorTheme !== "")
|
||||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||||
root.enqueue(commands);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,16 @@ Singleton {
|
||||
}
|
||||
|
||||
readonly property var wiredDevice: {
|
||||
let fallback = null;
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wired)
|
||||
if (device.type !== DeviceType.Wired)
|
||||
continue;
|
||||
if (device.connected)
|
||||
return device;
|
||||
if (!fallback)
|
||||
fallback = device;
|
||||
}
|
||||
return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
|
||||
@@ -98,5 +98,16 @@ Singleton {
|
||||
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
||||
}
|
||||
|
||||
function addAutostart(desktopId: string): void {
|
||||
if (root.busy)
|
||||
return;
|
||||
if (!root.knownDesktopId(desktopId)) {
|
||||
root.lastError = "Choose an installed application.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
mutationProcess.exec([root.helper, "add-autostart", desktopId]);
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
pragma Singleton
|
||||
|
||||
// Application-facing desktop style.
|
||||
//
|
||||
// Panama owns the durable choices; gsettings is an output boundary for GTK
|
||||
// and applications that follow GNOME's desktop schemas. Commands are arrays,
|
||||
// values are validated before storage, and no user text is ever sent through a
|
||||
// shell. Hyprland's pointer setting stays live through Accessibility.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
|
||||
|
||||
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
|
||||
// a separate allow-list so a caller cannot smuggle a display label into a
|
||||
// stored theme name.
|
||||
property var cursorThemes: []
|
||||
property var iconThemes: []
|
||||
property var cursorThemeNames: []
|
||||
property var iconThemeNames: []
|
||||
property bool catalogLoaded: false
|
||||
property bool scanning: false
|
||||
property bool startupApplied: false
|
||||
property string lastError: ""
|
||||
|
||||
property var pending: []
|
||||
readonly property bool busy: root.scanning || catalogProcess.running
|
||||
|| runner.running || root.pending.length > 0
|
||||
readonly property int preferenceRevision: DesktopPreferences.revision
|
||||
|
||||
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||||
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
|
||||
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
|
||||
readonly property string documentFont: DesktopPreferences.get("documentFont")
|
||||
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
|
||||
|
||||
Process {
|
||||
id: catalogProcess
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.acceptCatalog(this.text)
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0) {
|
||||
root.catalogLoaded = false;
|
||||
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: runner
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "One desktop style setting could not be applied.";
|
||||
root.drain();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.ensureStarted();
|
||||
// Accessing the singleton here keeps its existing hyprctl setcursor
|
||||
// path alive for cursor-theme changes as well as cursor-size changes.
|
||||
Accessibility.applyAll();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: startupApply
|
||||
interval: 1200
|
||||
onTriggered: {
|
||||
root.startupApplied = true;
|
||||
root.applyAll();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { applyCoalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: applyCoalesce
|
||||
interval: 180
|
||||
onTriggered: root.applyAll()
|
||||
}
|
||||
|
||||
function ensureStarted(): void {
|
||||
if (!root.catalogLoaded && !root.scanning)
|
||||
root.refreshCatalog();
|
||||
if (!root.startupApplied && !startupApply.running)
|
||||
startupApply.restart();
|
||||
}
|
||||
|
||||
function refreshCatalog(): void {
|
||||
if (catalogProcess.running)
|
||||
return;
|
||||
root.scanning = true;
|
||||
catalogProcess.exec([root.helperPath]);
|
||||
}
|
||||
|
||||
function acceptCatalog(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
|
||||
throw new Error("invalid catalog shape");
|
||||
|
||||
const cursors = parsed.cursorThemes.filter(name =>
|
||||
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
|
||||
const icons = parsed.iconThemes.filter(name =>
|
||||
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
|
||||
root.cursorThemeNames = cursors;
|
||||
root.iconThemeNames = icons;
|
||||
root.cursorThemes = cursors.map(name => ({
|
||||
value: name,
|
||||
label: name,
|
||||
detail: "Pointer theme"
|
||||
}));
|
||||
root.iconThemes = icons.map(name => ({
|
||||
value: name,
|
||||
label: name,
|
||||
detail: "Application icon theme"
|
||||
}));
|
||||
root.catalogLoaded = true;
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.cursorThemes = [];
|
||||
root.iconThemes = [];
|
||||
root.cursorThemeNames = [];
|
||||
root.iconThemeNames = [];
|
||||
root.catalogLoaded = false;
|
||||
root.lastError = "Installed icon and pointer themes could not be read.";
|
||||
}
|
||||
root.scanning = false;
|
||||
}
|
||||
|
||||
function drain(): void {
|
||||
if (runner.running || root.pending.length === 0)
|
||||
return;
|
||||
const next = root.pending[0];
|
||||
root.pending = root.pending.slice(1);
|
||||
runner.exec(next);
|
||||
}
|
||||
|
||||
function enqueue(commands: var): void {
|
||||
// A fresh revision supersedes commands that have not started yet. The
|
||||
// currently running command is allowed to finish, then the newest full
|
||||
// state is replayed in a deterministic order.
|
||||
root.pending = commands;
|
||||
root.drain();
|
||||
}
|
||||
|
||||
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
|
||||
// quote, backslash, and control character, and the schema patterns further
|
||||
// constrain stored font/theme names. Arguments still travel directly to
|
||||
// gsettings rather than through a shell.
|
||||
function gvariant(value: var): string {
|
||||
if (typeof value === "boolean")
|
||||
return value ? "true" : "false";
|
||||
if (typeof value === "number")
|
||||
return String(value);
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
function fontName(familyKey: string, sizeKey: string): string {
|
||||
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||
}
|
||||
|
||||
function buttonLayout(): string {
|
||||
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||
|
||||
// Tokens are fixed. Only their side and whether maximize is present
|
||||
// vary, so preference data can never become command syntax.
|
||||
if (side === "left")
|
||||
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||
}
|
||||
|
||||
function setting(schema: string, key: string, value: var): var {
|
||||
return ["gsettings", "set", schema, key, root.gvariant(value)];
|
||||
}
|
||||
|
||||
function applyAll(): void {
|
||||
root.lastError = "";
|
||||
root.enqueue([
|
||||
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
|
||||
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
|
||||
root.setting("org.gnome.desktop.interface", "font-name",
|
||||
root.fontName("applicationFont", "applicationFontSize")),
|
||||
root.setting("org.gnome.desktop.interface", "document-font-name",
|
||||
root.fontName("documentFont", "documentFontSize")),
|
||||
root.setting("org.gnome.desktop.interface", "monospace-font-name",
|
||||
root.fontName("monospaceFont", "monospaceFontSize")),
|
||||
root.setting("org.gnome.desktop.interface", "font-hinting",
|
||||
DesktopPreferences.get("fontHinting")),
|
||||
root.setting("org.gnome.desktop.interface", "font-antialiasing",
|
||||
DesktopPreferences.get("fontAntialiasing")),
|
||||
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||
DesktopPreferences.get("middleClickPaste")),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||
DesktopPreferences.get("titlebarDoubleClick"))
|
||||
]);
|
||||
}
|
||||
|
||||
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
|
||||
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
|
||||
root.lastError = `That ${kind} theme is not installed.`;
|
||||
return false;
|
||||
}
|
||||
if (!DesktopPreferences.set(key, value)) {
|
||||
root.lastError = `That ${kind} theme name could not be saved.`;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function setCursorTheme(value: string): bool {
|
||||
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
|
||||
}
|
||||
|
||||
function setIconTheme(value: string): bool {
|
||||
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
|
||||
}
|
||||
|
||||
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
|
||||
if (allowed.indexOf(family) < 0) {
|
||||
root.lastError = `That ${kind} font is not installed.`;
|
||||
return false;
|
||||
}
|
||||
if (!DesktopPreferences.set(key, family)) {
|
||||
root.lastError = `That ${kind} font name could not be saved.`;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function setApplicationFont(family: string): bool {
|
||||
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
|
||||
}
|
||||
|
||||
function setDocumentFont(family: string): bool {
|
||||
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
|
||||
}
|
||||
|
||||
function setMonospaceFont(family: string): bool {
|
||||
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
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 (root.postRepairScanPending)
|
||||
return false;
|
||||
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;
|
||||
if (external && check.action.confirm)
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,20 @@ import qs.config
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// SettingsSearch is part of the always-constructed sidebar. Touching the
|
||||
// desktop-style service here gives application preferences their startup
|
||||
// replay even when Appearance is not the page that opens first.
|
||||
Component.onCompleted: DesktopStyle.ensureStarted()
|
||||
|
||||
// Which page shows the settings in a given schema group. A group with no
|
||||
// entry here still appears in results and routes to Home rather than being
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"typography": "appearance",
|
||||
"themes": "appearance",
|
||||
"titlebar": "appearance",
|
||||
"windows": "appearance",
|
||||
"effects": "appearance",
|
||||
"wallpaper": "appearance",
|
||||
@@ -36,6 +44,9 @@ Singleton {
|
||||
"pointer": "mouse",
|
||||
"touchpad": "mouse",
|
||||
"multitasking": "desktop",
|
||||
"edges": "desktop",
|
||||
"master": "desktop",
|
||||
"notices": "desktop",
|
||||
"weather": "appearance",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
@@ -52,7 +63,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 {
|
||||
|
||||
@@ -273,6 +273,15 @@ Singleton {
|
||||
// decides, and config/dot/hypr/prefs.lua does the same conversion via
|
||||
// prefs.getInt so both sides agree.
|
||||
function hyprValue(entry: var, value: var): var {
|
||||
// Some options are phrased as a negative by the compositor -- the four
|
||||
// Hyprland notices are all `disable_x` -- while the setting reads as
|
||||
// "show x", because a switch labelled "Disable splash text" that must
|
||||
// be ON to hide something is a small cruelty. `invert` bridges the two,
|
||||
// in exactly one place, so nothing downstream has to remember which
|
||||
// options are backwards.
|
||||
if (entry.hypr.invert === true && typeof value === "boolean")
|
||||
value = !value;
|
||||
|
||||
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
||||
return value ? 1 : 0;
|
||||
return value;
|
||||
@@ -300,6 +309,24 @@ Singleton {
|
||||
return value ? "true" : "false";
|
||||
if (typeof value === "number")
|
||||
return String(value);
|
||||
|
||||
// A gradient is the one setting whose Lua form is not a scalar. The
|
||||
// stubs declare it as `string|{colors:string[], angle?:number}`, and
|
||||
// the string form only ever carries ONE stop -- writing
|
||||
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
|
||||
// the previous value, which is how a two-stop write looks like it
|
||||
// worked and did nothing. Multi-stop must be the table form.
|
||||
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||
const stops = value.colors
|
||||
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
|
||||
.join(", ");
|
||||
const angle = Number(value.angle);
|
||||
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
|
||||
}
|
||||
|
||||
// A vec2 reaches Lua as a two-element table.
|
||||
if (Array.isArray(value) && value.length === 2)
|
||||
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
|
||||
// Strings only reach here after the schema's pattern check; quoting is
|
||||
// belt-and-braces rather than the primary defence.
|
||||
return `"${String(value).replace(/["\\]/g, "")}"`;
|
||||
@@ -309,7 +336,15 @@ Singleton {
|
||||
const parts = [];
|
||||
for (const name in node) {
|
||||
const child = node[name];
|
||||
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
|
||||
// A leaf arrives pre-serialised as a string; anything else is
|
||||
// either a nested section or a structured value (gradient, vec2)
|
||||
// that serialiseValue knows how to render.
|
||||
const rendered = typeof child === "string"
|
||||
? child
|
||||
: (Array.isArray(child) || (child && child.colors !== undefined)
|
||||
? root.serialiseValue(child)
|
||||
: root.serialiseTable(child));
|
||||
parts.push(`${name} = ${rendered}`);
|
||||
}
|
||||
return `{ ${parts.join(", ")} }`;
|
||||
}
|
||||
@@ -353,6 +388,59 @@ Singleton {
|
||||
root.drainQueue();
|
||||
}
|
||||
|
||||
// Gradients are written in one notation and read back in another, so they
|
||||
// cannot be compared directly the way every other type can.
|
||||
//
|
||||
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
|
||||
// read: "993b4261 45deg"
|
||||
//
|
||||
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
|
||||
// always appended even when it was never given. Comparing the raw strings
|
||||
// reports every gradient write as rejected, which is what would have
|
||||
// happened had this been added with readAs: "str".
|
||||
function gradientMatches(expected: var, observed: string): bool {
|
||||
if (typeof observed !== "string")
|
||||
return false;
|
||||
return root.normaliseGradient(expected) === root.normaliseGradient(observed);
|
||||
}
|
||||
|
||||
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
|
||||
function normaliseGradient(value: var): string {
|
||||
const stops = [];
|
||||
let angle = 0;
|
||||
|
||||
const readStop = function (text: string): void {
|
||||
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
|
||||
if (rgba) {
|
||||
let hex = rgba[1].toLowerCase();
|
||||
// rgb() has no alpha; the compositor reports it as fully opaque.
|
||||
if (hex.length === 6)
|
||||
hex = hex + "ff";
|
||||
// RRGGBBAA in, AARRGGBB out.
|
||||
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
|
||||
return;
|
||||
}
|
||||
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
|
||||
if (bare) {
|
||||
stops.push(bare[1].toLowerCase());
|
||||
return;
|
||||
}
|
||||
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
|
||||
if (deg)
|
||||
angle = Number(deg[1]);
|
||||
};
|
||||
|
||||
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
||||
value.colors.forEach(readStop);
|
||||
if (value.angle !== undefined && isFinite(Number(value.angle)))
|
||||
angle = Number(value.angle);
|
||||
} else {
|
||||
String(value).trim().split(/\s+/).forEach(readStop);
|
||||
}
|
||||
|
||||
return stops.join(" ") + " " + angle + "deg";
|
||||
}
|
||||
|
||||
function matchesObserved(entry: var, value: var, answer: var): bool {
|
||||
if (!answer)
|
||||
return false;
|
||||
@@ -370,6 +458,12 @@ Singleton {
|
||||
case "css":
|
||||
// Gaps read back as a box, e.g. "10 10 10 10".
|
||||
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
|
||||
case "gradient":
|
||||
return root.gradientMatches(expected, answer.gradient);
|
||||
case "vec2":
|
||||
return Array.isArray(answer.vec2) && Array.isArray(expected)
|
||||
&& Number(answer.vec2[0]) === Number(expected[0])
|
||||
&& Number(answer.vec2[1]) === Number(expected[1]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,43 @@ 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); }
|
||||
}
|
||||
|
||||
// Health checks the wallpaper service through the same typed IPC boundary
|
||||
// as capture and clipboard. This is deliberately read-only: choosing an
|
||||
// image remains an explicit Settings action.
|
||||
IpcHandler {
|
||||
target: "wallpaper"
|
||||
|
||||
function refresh(): void { Wallpaper.refreshActive(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: Wallpaper.active,
|
||||
configured: Wallpaper.configured,
|
||||
availableCount: Wallpaper.available.length,
|
||||
lastError: Wallpaper.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,598 @@
|
||||
# Panama Health & Recovery Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a quiet, trustworthy System Health surface that diagnoses Panama-owned desktop functionality, exposes redacted reports, and offers only narrow allow-listed repairs.
|
||||
|
||||
**Architecture:** An executable Python helper, `panama-doctor`, is the only operating-system boundary and emits one deterministic JSON schema. A `Health.qml` singleton owns accepted snapshots, scan generations, repair state, and report copying; Settings, the bar, IPC, and Vicinae consume that typed state without constructing commands.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, Bash contract tests, Quickshell/QML, QtQuick, Hyprland IPC, Vicinae script commands, Prism design tokens.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-panama-health-recovery-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Healthy background scans are silent: no notifications, Signal Glass events, animations, or permanent bar ornament.
|
||||
- Allowed statuses are exactly `ok`, `warning`, `error`, and `unconfigured`; overall status is `healthy`, `warning`, or `error`.
|
||||
- Optional integrations that have never been configured are `unconfigured`, never warnings.
|
||||
- The helper never reads or reports secret values, clipboard contents, notification bodies, calendar event data, SSIDs, addresses, or arbitrary command output.
|
||||
- The helper never installs packages, invokes `sudo`, deletes user data, rewrites arbitrary configuration, or repairs services Panama does not own.
|
||||
- Probe-derived values may populate observations only; check IDs, groups, titles, actions, commands, and arguments are authored constants.
|
||||
- All process launches use argument arrays. UI text and report content never become commands.
|
||||
- Preserve the last valid snapshot on helper failure or malformed JSON.
|
||||
- Repairs are judged by a fresh observed scan, not by process exit status alone.
|
||||
- Do not run a state-changing live repair without a genuinely degraded disposable target or explicit user approval.
|
||||
|
||||
## File map and stable interfaces
|
||||
|
||||
- `config/dot/quickshell/scripts/panama-doctor`: Python CLI and sole diagnostic/repair OS boundary.
|
||||
- `config/dot/quickshell/services/Health.qml`: snapshot state machine, scan/repair processes, report copy, and fixture seams.
|
||||
- `config/dot/quickshell/health-harness.qml`: deterministic IPC harness for generations, malformed data, coalescing, and repairs.
|
||||
- `config/dot/quickshell/modules/settings/HealthPage.qml`: System Health page composition.
|
||||
- `config/dot/quickshell/modules/settings/HealthSummary.qml`: stable-height summary hero and primary controls.
|
||||
- `config/dot/quickshell/modules/settings/HealthCheckRow.qml`: one accessible check row with one action.
|
||||
- `config/dot/quickshell/modules/bar/HealthIndicator.qml`: degraded-only bar entry point.
|
||||
- `config/local/share/vicinae/scripts/check-system-health.sh`: searchable launcher command.
|
||||
- `tests/quickshell/fixtures/doctor/`: isolated command, config, state, and runtime fixtures containing no real workstation data.
|
||||
- `tests/quickshell/panama-doctor-contract.sh`: schema, status, redaction, timeout, ordering, and repair allow-list contract.
|
||||
- `tests/quickshell/health-service-contract.sh`: QML state-machine contract.
|
||||
- `tests/quickshell/health-ui-contract.sh`: Settings, footer, report, indicator, IPC, and Vicinae integration contract.
|
||||
|
||||
The helper's authored check order is:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
Only these repair IDs are executable in release one:
|
||||
|
||||
```text
|
||||
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 (confirmation required)
|
||||
panama.runtime-links -> recreate only known Panama-owned broken symlinks
|
||||
panama.vicinae-commands -> setup/scripts/link-vicinae-scripts
|
||||
panama.caffeine -> release duplicate Panama/Caffeine inhibitor PIDs only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Prism health mocks and visual approval
|
||||
|
||||
**Approved visual:** A — Diagnostic Ledger. Preserve its restrained issue rail, stable summary hero, two-column healthy ledgers, live sidebar footer, and degraded-only bar capsule.
|
||||
|
||||
**Files:**
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/index.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/panama.css`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/mock.js`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/a-ledger.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/b-focus.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/c-compact.html`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing 272 px Settings sidebar, 48 px titlebar, Tokyo Night Moon Prism tokens, and the approved information architecture.
|
||||
- Produces: one approved visual composition for healthy, warning, checking, and error states plus the degraded-only bar indicator.
|
||||
|
||||
- [ ] **Step 1: Build three static compositions from real copy**
|
||||
|
||||
Use the same warning fixture in all three: `Vicinae` is stopped with action `Restart Vicinae`; `External monitor brightness` needs permission with action `View setup instructions`; `BlueBubbles` is `Not set up`. Keep every variant inside the real Settings geometry and include the footer and bar indicator.
|
||||
|
||||
```text
|
||||
A — Diagnostic ledger: one restrained amber issue rail beside calm grouped rows.
|
||||
B — Focus card: issues receive the visual focus; healthy groups collapse into quieter ledgers below.
|
||||
C — Compact matrix: dense two-column group cards with the same issue-first ordering.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Serve and visually inspect the mocks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 52780 --directory .superpowers/mocks/system-health
|
||||
```
|
||||
|
||||
Expected: all three variants render at `http://localhost:52780`, keyboard focus is visible, no element overflows at 1360x900, and reduced-motion mode has no continuous animation.
|
||||
|
||||
- [ ] **Step 3: Capture the approved direction in the plan**
|
||||
|
||||
Add a short `Approved visual: <variant>` note beneath this task after user selection. Production UI work in Task 4 must reproduce that composition using existing QML tokens rather than copying browser-only effects.
|
||||
|
||||
### Task 2: Deterministic read-only doctor
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Create: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/systemctl`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/pgrep`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/busctl`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/qs`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/vicinae`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/systemd-inhibit`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PANAMA_DOCTOR_ROOT`, `PANAMA_DOCTOR_HOME`, `PANAMA_DOCTOR_CONFIG_HOME`, `PANAMA_DOCTOR_STATE_HOME`, `PANAMA_DOCTOR_RUNTIME_DIR`, `PANAMA_DOCTOR_PATH`, and `PANAMA_DOCTOR_TIMEOUT` test seams; production defaults resolve from the real process environment.
|
||||
- Produces: `panama-doctor --json`, `panama-doctor --summary`, and a versioned schema with `summary` plus the 25 ordered check objects listed above.
|
||||
|
||||
- [ ] **Step 1: Write the failing schema and redaction contract**
|
||||
|
||||
The contract must create an isolated home, tracked source tree, runtime tree, and fake command directory, then assert:
|
||||
|
||||
```bash
|
||||
snapshot="$($doctor --json)"
|
||||
jq -e '.schemaVersion == 1
|
||||
and (.generatedAt | type == "string")
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 25)
|
||||
and ([.checks[].id] | unique | length == 25)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' <<<"$snapshot"
|
||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]]
|
||||
! grep -Fq 'fixture-secret-token' <<<"$snapshot"
|
||||
! grep -Fq 'fixture clipboard body' <<<"$snapshot"
|
||||
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot"
|
||||
```
|
||||
|
||||
Cover a healthy required service, a missing required executable, an unconfigured optional integration, a configured-but-stopped integration, an inaccessible DDC bus, a timed-out probe, duplicate Caffeine inhibitors, malformed probe output, and concise `--summary` output.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify the helper is absent**
|
||||
|
||||
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: FAIL because `config/dot/quickshell/scripts/panama-doctor` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement authored checks and concurrent bounded probes**
|
||||
|
||||
Use Python standard-library types and deterministic assembly:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
kind: Literal["repair", "open", "instructions"]
|
||||
label: str
|
||||
confirm: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Check:
|
||||
id: str
|
||||
group: Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
title: str
|
||||
status: Literal["ok", "warning", "error", "unconfigured"]
|
||||
detail: str
|
||||
action: Action | None = None
|
||||
```
|
||||
|
||||
Run independent probes through `ThreadPoolExecutor(max_workers=8)`. Every subprocess call must use a constant argument tuple, `capture_output=True`, `text=True`, and the configured timeout. Convert timeout, non-zero status, and parse failure into a check result. Assemble checks by the authored ID tuple after futures settle; never emit completion order.
|
||||
|
||||
Configuration checks may inspect existence and file type only. Home Assistant is configured when both expected variable names are present, but their values are never retained. Calendar is configured from enabled EDS source count only; event commands are never called. BlueBubbles is configured from the Flatpak installation check. DDC uses only `panama-brightness list` and retains display count plus its authored error classification, never connector names.
|
||||
|
||||
The top-level `context` contains only an authored session class and an ordered array of parsed Hyprland, Quickshell, Fedora, and Panama revision versions. `panama.processes` counts only exact authored process names and flags duplicate Quickshell, Vicinae, Hyprpaper, or Hypridle instances without exposing command lines. Integration actions are authored too: Nextcloud, RustDesk, KDE Connect, and BlueBubbles may offer their exact Open action; Home Assistant and calendar failures route to `home-phone` and `datetime`; never derive an application or page name from probe output.
|
||||
|
||||
- [ ] **Step 4: Run the doctor contract**
|
||||
|
||||
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: `panama doctor contract: PASS`.
|
||||
|
||||
- [ ] **Step 5: Commit the read-only engine**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/scripts/panama-doctor tests/quickshell/panama-doctor-contract.sh tests/quickshell/fixtures/doctor
|
||||
git commit -m "Add Panama system health diagnostics"
|
||||
```
|
||||
|
||||
### Task 3: Health singleton and typed IPC state machine
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/services/Health.qml`
|
||||
- Create: `config/dot/quickshell/health-harness.qml`
|
||||
- Create: `tests/quickshell/health-service-contract.sh`
|
||||
- Modify: `config/dot/quickshell/shell.qml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `panama-doctor --json` and `panama-doctor --repair CHECK_ID --json`.
|
||||
- Produces: `Health.snapshot`, `Health.checks`, `Health.summary`, `Health.status`, `Health.actionable`, `Health.busy`, `Health.diagnosticUnavailable`, `Health.lastError`, `Health.repairingId`, `Health.lastRepair`, `Health.lastCopyResult`, `Health.refresh()`, `Health.repair(id, external)`, `Health.copyReport()`, and IPC target `health` with `refresh`, `status`, `open`, and `repair(id)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing QML state contract**
|
||||
|
||||
The harness exposes fixture methods that call the real singleton's pure consumption seams:
|
||||
|
||||
```qml
|
||||
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()); }
|
||||
```
|
||||
|
||||
Assert that a valid warning snapshot is accepted, an older generation is ignored, malformed JSON preserves the prior checks and marks the engine unavailable, two refreshes while running schedule exactly one follow-up, a valid repair triggers one rescan, and unknown/non-repairable IDs start no process.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify it fails**
|
||||
|
||||
Run: `tests/quickshell/health-service-contract.sh`
|
||||
|
||||
Expected: FAIL because `Health.qml` and the harness do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the singleton state machine**
|
||||
|
||||
Define the stable state shape:
|
||||
|
||||
```qml
|
||||
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 repairingId: ""
|
||||
property var lastRepair: ({})
|
||||
property string lastCopyResult: ""
|
||||
readonly property bool actionable: status === "warning" || status === "error"
|
||||
readonly property bool busy: scanProcess.running || repairProcess.running
|
||||
```
|
||||
|
||||
Use `Process.exec([root.helperPath, "--json"])`; attach the current generation to the collector before launch. `consumeSnapshot(text, generation)` validates schema version, summary keys, context shape, unique IDs, groups, statuses, titles, details, and action shapes before replacing state. A 2200 ms one-shot startup timer requests the initial scan. A running scan sets `queuedRefresh`; exit consumes at most one queued follow-up. `copyReport()` sends only `JSON.stringify(root.snapshot, null, 2)` to `wl-copy` through a `Process` stdin buffer and writes success or failure to `lastCopyResult` without touching the clipboard service's history model.
|
||||
|
||||
The `health` IPC `status()` returns only the already-redacted summary, busy flags, generation, and check IDs/statuses. `open()` calls `ShellState.openSettings("services")` then refreshes. IPC `repair(id)` calls `Health.repair(id, true)` and returns a Boolean acceptance result; Settings calls `Health.repair(id, false)`. A failed externally-originated repair uses an argument-array `notify-send` process with the existing `Panama action failed` title, while Settings failures remain inline.
|
||||
|
||||
- [ ] **Step 4: Run service and IPC contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/settings-window-contract.sh
|
||||
```
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 5: Commit the service layer**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Health.qml config/dot/quickshell/health-harness.qml tests/quickshell/health-service-contract.sh config/dot/quickshell/shell.qml
|
||||
git commit -m "Add Panama health state service"
|
||||
```
|
||||
|
||||
### Task 4: Approved System Health Settings page
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthPage.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthSummary.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Delete: `config/dot/quickshell/modules/settings/ServicesPage.qml`
|
||||
- Create: `tests/quickshell/health-ui-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all read-only state and methods from `Health.qml`; route remains the stable internal name `services`.
|
||||
- Produces: System Health summary, issues-first cards, four grouped ledgers, live clickable sidebar footer, Copy Report feedback, and confirmation requests for disruptive repairs.
|
||||
|
||||
- [ ] **Step 1: Write failing Settings and accessibility assertions**
|
||||
|
||||
Assert static structure and fixture-rendered state:
|
||||
|
||||
```bash
|
||||
rg -Fq 'label: "System Health"' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||
rg -Fq 'onClicked: Health.copyReport()' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||
rg -Fq 'onTapped: root.pageRequested("services")' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||
rg -Fq 'text: "Checking…"' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||
rg -Fq 'Health.refresh()' config/dot/quickshell/modules/settings/HealthPage.qml
|
||||
```
|
||||
|
||||
The runtime harness must prove warning rows appear before healthy groups, unconfigured is visible as `Not set up`, every status has text in addition to color, refresh preserves row geometry, keyboard focus reaches both hero actions and row actions, and a Quickshell-restart repair opens a confirmation sheet.
|
||||
|
||||
- [ ] **Step 2: Run UI contracts and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because the approved Health components are absent.
|
||||
|
||||
- [ ] **Step 3: Implement the approved composition**
|
||||
|
||||
Use `SettingsPage`, `SettingsCard`, `SettingsButton`, `Theme`, and `PrismEdge`. Keep the summary hero stable at 126 px and each check row at least 62 px. Derive labels exactly:
|
||||
|
||||
```qml
|
||||
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";
|
||||
}
|
||||
```
|
||||
|
||||
`HealthPage.Component.onCompleted` calls `Health.refresh()`. Issues are checks with `warning` or `error`. Group cards preserve helper order. Each row exposes at most one action. `open` actions route to exact Settings pages; `instructions` actions reveal authored inline instructions; `repair` actions call `Health.repair(id, false)` after confirmation only when `action.confirm === true`.
|
||||
|
||||
The sidebar footer is a 54 px `TapHandler` target with status text derived from Health, not a hardcoded string. It opens `services`; when no scan has completed it says `Checking Panama desktop`. A malformed or failed doctor run retains the last rows, changes only the hero to `Health check unavailable`, and exposes one bounded `Retry` action. The page ends with the approved boundary note and an `Open GNOME Settings` action for networking, printers, users, and other Fedora-owned areas.
|
||||
|
||||
- [ ] **Step 4: Run UI contracts and inspect the rendered fixture**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS with zero QML warnings.
|
||||
|
||||
- [ ] **Step 5: Commit the Settings experience**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/health-ui-contract.sh tests/quickshell/settings-pages-contract.sh tests/quickshell/settings-search-contract.sh
|
||||
git commit -m "Build the System Health settings page"
|
||||
```
|
||||
|
||||
### Task 5: Quiet bar indicator and launcher entry point
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/bar/HealthIndicator.qml`
|
||||
- Modify: `config/dot/quickshell/modules/bar/Bar.qml`
|
||||
- Create: `config/local/share/vicinae/scripts/check-system-health.sh`
|
||||
- Modify: `config/dot/quickshell/scripts/panama-action`
|
||||
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-action-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-commands-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Health.actionable`, `Health.status`, and `Health.summary`; existing `panama-action` dispatcher and Settings IPC.
|
||||
- Produces: one degraded-only bar affordance and Vicinae command `Panama: Check System Health`.
|
||||
|
||||
- [ ] **Step 1: Extend contracts before production files**
|
||||
|
||||
Assert the indicator is absent for healthy/unconfigured-only fixtures, visible amber for warnings, visible red for errors, includes a textual accessible label, and opens `services`. Extend command fixtures so:
|
||||
|
||||
```text
|
||||
panama-action health -> qs ipc call health open
|
||||
check-system-health.sh title -> Panama: Check System Health
|
||||
check-system-health.sh exec -> $HOME/.config/quickshell/scripts/panama-action health
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/panama-action-contract.sh
|
||||
tests/quickshell/panama-commands-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL on the missing indicator and command.
|
||||
|
||||
- [ ] **Step 3: Implement the quiet entry points**
|
||||
|
||||
Place `HealthIndicator` in the right-side bar row before `ActivityIndicator`. It has no reserved width while hidden, no animation, and one compact shield/wrench glyph with an issue-count tooltip or accessible description. Use `Theme.warn` only for warnings and `Theme.danger` only for errors. Clicking calls `ShellState.openSettings("services")` and `Health.refresh()`.
|
||||
|
||||
Add this dispatcher case and usage token:
|
||||
|
||||
```bash
|
||||
health) qs ipc call health open ;;
|
||||
```
|
||||
|
||||
Create a Vicinae script with schema version 1, silent mode, Panama Settings icon, keywords `health`, `doctor`, `repair`, `services`, and the stable `panama-action health` execution path.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run the three commands from Step 2.
|
||||
|
||||
Expected: all PASS; command count increases from 17 to 18.
|
||||
|
||||
- [ ] **Step 5: Commit the entry points**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/bar config/local/share/vicinae/scripts/check-system-health.sh config/dot/quickshell/scripts/panama-action tests/quickshell
|
||||
git commit -m "Add quiet System Health entry points"
|
||||
```
|
||||
|
||||
### Task 6: Allow-listed repairs and observed recovery
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Modify: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Modify: `config/dot/quickshell/services/Health.qml`
|
||||
- Modify: `tests/quickshell/health-service-contract.sh`
|
||||
- Modify: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the fixed repair matrix in this plan and current accepted checks from `Health.qml`.
|
||||
- Produces: `panama-doctor --repair CHECK_ID --json` result `{schemaVersion, checkId, accepted, exitCode, message}`, inline repair state, and one post-repair scan.
|
||||
|
||||
- [ ] **Step 1: Add exact repair-command tests**
|
||||
|
||||
For every repair ID, use fake commands and isolated paths to assert the exact argv. Assert all of these are rejected before any process or filesystem write:
|
||||
|
||||
```text
|
||||
unknown.check
|
||||
integration.home-assistant
|
||||
input.brightness
|
||||
desktop.notifications
|
||||
../../escape
|
||||
desktop.vicinae;touch injected
|
||||
```
|
||||
|
||||
For runtime links, fixtures must prove only these link names are eligible: `hypr`, `quickshell`, `uwsm`, and `vicinae`; a regular user-owned directory is reported but never replaced. For Caffeine, only duplicate rows with application `Panama`, current UID, reason `Caffeine`, and mode `block` may yield numeric PIDs; leave one valid inhibitor alive and release extras.
|
||||
|
||||
- [ ] **Step 2: Run repair contracts and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because `--repair` is not implemented.
|
||||
|
||||
- [ ] **Step 3: Implement the authored repair registry**
|
||||
|
||||
Represent commands as immutable constant tuples or dedicated functions:
|
||||
|
||||
```python
|
||||
REPAIR_COMMANDS = {
|
||||
"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"),
|
||||
}
|
||||
```
|
||||
|
||||
Handle runtime links, Vicinae command linking, and duplicate inhibitors in dedicated functions that accept no caller-controlled path or command. Return JSON on every known failure. Unknown IDs exit 2 with `accepted: false` and do not invoke any runner.
|
||||
|
||||
`Health.repair(id, external)` requires the ID to exist in the current snapshot with `action.kind === "repair"`, records `repairingId`, runs the helper with an argument array, parses the result, clears the busy row, and requests exactly one fresh scan. Keep the row degraded until that scan reports recovery.
|
||||
|
||||
- [ ] **Step 4: Run repair and UI contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit repairs**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/services/Health.qml config/dot/quickshell/modules/settings/HealthCheckRow.qml tests/quickshell
|
||||
git commit -m "Add bounded Panama recovery actions"
|
||||
```
|
||||
|
||||
### Task 7: Full verification, controller-deferred live audit, and documentation
|
||||
|
||||
**Integration note:** `origin/main` added Mouse, Privacy, Region, and Online
|
||||
Accounts destinations while this feature was in review. Merge commit `4ef2f01`
|
||||
preserves those routes and the newer Settings navigation architecture, keeps
|
||||
the stable `services` route rendered by `HealthPage`, and leaves
|
||||
`ServicesPage.qml` retired. Its useful Fedora handoffs for Users, Sharing,
|
||||
Colour profiles, and Digital wellbeing now live in the boundary-last System
|
||||
Health card alongside the existing network handoff, with focused static and
|
||||
isolated runtime coverage.
|
||||
|
||||
**Live-audit handoff:** Per the integration brief, this task does not reload the
|
||||
daily-driver Quickshell, invoke a live repair, or run the read-only live
|
||||
doctor/IPC comparison. Those checks remain for the controller after code review.
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/hypr/DESKTOP-PARITY.md`
|
||||
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||
- Modify: `docs/superpowers/plans/2026-08-18-panama-health-recovery.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the complete feature and existing regression suite.
|
||||
- Produces: current user documentation and final contract evidence. The
|
||||
redacted live health snapshot and live shell audit are deferred to the
|
||||
controller after code review.
|
||||
|
||||
- [x] **Step 1: Document boundaries and entry points**
|
||||
|
||||
Document `Panama: Check System Health`, Settings → System Health, the degraded-only bar indicator, `panama-doctor --summary`, the no-`sudo`/no-package-install boundary, and the fact that GNOME/Fedora tools remain responsible for generic system configuration.
|
||||
|
||||
- [ ] **Step 2: Run syntax, focused, and full contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile config/dot/quickshell/scripts/panama-doctor
|
||||
bash -n config/dot/quickshell/scripts/panama-action
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
for test in tests/quickshell/*contract.sh; do "$test"; done
|
||||
for test in tests/hypr/*contract.sh; do "$test"; done
|
||||
```
|
||||
|
||||
Expected: every command exits 0. After the latest Settings and installer work,
|
||||
the current inventory is 66 Quickshell contracts and 2 Hyprland contracts (the original
|
||||
pre-merge estimate was 58).
|
||||
|
||||
Integration result: syntax and all focused Health/Settings contracts pass. Two
|
||||
complete serial Quickshell runs each passed 63/65, but failed on different
|
||||
order-sensitive contracts. Run one failed Displays and Settings Hyprland Write;
|
||||
run two failed Focus Session and Health Service. Each failed contract passed
|
||||
immediately when rerun alone. Hyprland contracts passed 2/2. No out-of-scope
|
||||
test or service code was changed to hide this suite-order interference.
|
||||
|
||||
- [ ] **Step 3: Controller runs a redacted live read-only comparison**
|
||||
|
||||
Deferred to the controller after code review; Task 7 does not produce this
|
||||
live output.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
config/dot/quickshell/scripts/panama-doctor --json >"$(mktemp)"
|
||||
config/dot/quickshell/scripts/panama-doctor --summary
|
||||
systemctl --user is-active hyprpaper.service hypridle.service vicinae.service pipewire.service
|
||||
qs ipc call health refresh
|
||||
qs ipc call health status | jq '{status, busy, checks: [.checks[] | {id, status}]}'
|
||||
```
|
||||
|
||||
Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations.
|
||||
|
||||
- [ ] **Step 4: Controller reloads and inspects the live shell**
|
||||
|
||||
Deferred to the controller after code review; Task 7 does not reload or inspect
|
||||
the daily-driver shell.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
qs reload
|
||||
sleep 4
|
||||
journalctl --user --since '-2 minutes' --no-pager | rg -i 'quickshell|qml|panama' | tail -200
|
||||
```
|
||||
|
||||
Expected: the shell returns, System Health opens, the healthy state is silent, and there are no new QML errors or binding-loop warnings. Do not invoke a repair during this step.
|
||||
|
||||
- [ ] **Step 5: Final diff and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
git diff --stat origin/main...HEAD
|
||||
git add config/dot/hypr/DESKTOP-PARITY.md config/dot/quickshell/modules/settings/README.md docs/superpowers/plans/2026-08-18-panama-health-recovery.md
|
||||
git commit -m "Document Panama health and recovery"
|
||||
```
|
||||
|
||||
Expected: only intentional Health & Recovery files are present and no workstation-specific values appear in the diff.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Panama Health & Recovery Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Panama Health & Recovery makes the desktop explain itself. It verifies the
|
||||
local services, dependencies, links, and integrations that Panama relies on,
|
||||
then presents useful recovery actions without asking the user to read logs or
|
||||
diagnose a collection of unrelated Linux processes.
|
||||
|
||||
The feature is intentionally quiet. A healthy desktop produces no notification,
|
||||
banner, or permanent bar ornament. Problems appear in Panama Settings and, when
|
||||
actionable, as one restrained bar indicator. User-initiated repairs receive
|
||||
immediate Prism OSD or inline feedback.
|
||||
|
||||
## Product boundaries
|
||||
|
||||
The first release covers Panama-owned or Panama-integrated functionality:
|
||||
|
||||
- Hyprland, Quickshell, the notification server, XDG desktop portals, PipeWire,
|
||||
Vicinae, the clipboard watcher, wallpaper, idle policy, and Panama's runtime
|
||||
configuration links.
|
||||
- The Panama command collection, screenshot and OCR dependencies, DDC
|
||||
brightness support, and the currently selected terminal and launcher.
|
||||
- Nextcloud, RustDesk, KDE Connect, BlueBubbles, Home Assistant, calendar
|
||||
aggregation, and the configured autostart entries.
|
||||
- Orphaned Panama processes and inhibitors, including duplicate Caffeine locks.
|
||||
- Versions and non-sensitive diagnostic context needed for a useful copied
|
||||
report.
|
||||
|
||||
It does not become a package manager, a generic system monitor, or a replacement
|
||||
for Fedora's troubleshooting tools. It never installs packages, invokes `sudo`,
|
||||
deletes user data, rewrites arbitrary configuration, or repairs services Panama
|
||||
does not own.
|
||||
|
||||
An optional integration that has never been configured is neutral **Not set
|
||||
up**, not a warning. A configured integration that cannot operate is degraded.
|
||||
This distinction prevents the health UI from pressuring the user to enable
|
||||
features they do not want.
|
||||
|
||||
## Information architecture
|
||||
|
||||
The existing **Startup & Services** destination becomes **System Health**. This
|
||||
avoids two pages reporting the same background services. Its existing Open and
|
||||
Refresh actions remain available through the richer health rows.
|
||||
|
||||
The page has four levels:
|
||||
|
||||
1. A compact summary hero: **Healthy**, **Needs attention**, or **Action
|
||||
required**, the last completed scan time, Refresh, and Copy Report.
|
||||
2. An issues-first section shown only when one or more checks are degraded.
|
||||
3. Grouped cards for Desktop Foundation, Input & Media, Integrations, and Panama
|
||||
Tools. Healthy rows remain visible but visually quiet.
|
||||
4. A short boundary note linking to GNOME or Fedora tools for system areas Panama
|
||||
does not own.
|
||||
|
||||
Each row contains a stable title, one-sentence observation, status label, and at
|
||||
most one primary action. Actions use concrete language such as **Restart
|
||||
Vicinae**, **Repair command link**, **Open Home settings**, or **View setup
|
||||
instructions**. There is no generic Fix Everything button.
|
||||
|
||||
The Settings sidebar's existing health footer becomes real and clickable. It
|
||||
shows the aggregate state and opens System Health. The top bar gains a small
|
||||
`HealthIndicator` only while an actionable warning or error exists; clicking it
|
||||
opens the same page. Background scans never publish Signal Glass events or
|
||||
desktop notifications.
|
||||
|
||||
## Diagnostic engine
|
||||
|
||||
`config/dot/quickshell/scripts/panama-doctor` is the single operating-system
|
||||
boundary. It supports:
|
||||
|
||||
- `panama-doctor --json` for a complete versioned snapshot.
|
||||
- `panama-doctor --summary` for a concise human-readable installer or terminal
|
||||
result.
|
||||
- `panama-doctor --repair CHECK_ID --json` for an explicitly allow-listed repair.
|
||||
|
||||
The helper emits one schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-08-18T12:00:00Z",
|
||||
"summary": {
|
||||
"status": "warning",
|
||||
"healthy": 18,
|
||||
"warnings": 1,
|
||||
"errors": 0,
|
||||
"unconfigured": 2
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"id": "launcher.panama-commands",
|
||||
"group": "panama-tools",
|
||||
"title": "Panama Commands",
|
||||
"status": "warning",
|
||||
"detail": "16 of 17 commands are loaded",
|
||||
"action": {
|
||||
"kind": "repair",
|
||||
"label": "Repair command link"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Allowed statuses are `ok`, `warning`, `error`, and `unconfigured`. Check IDs,
|
||||
group IDs, titles, and repair mappings are authored constants. Probe output may
|
||||
populate observations but can never become a command or executable argument.
|
||||
|
||||
Checks run concurrently where doing so is safe, with short per-probe timeouts.
|
||||
A failed or timed-out probe yields a check result rather than aborting the whole
|
||||
snapshot. Output order is deterministic so tests, copied reports, and visual
|
||||
rows do not jump between scans.
|
||||
|
||||
No secrets are read. The report may state whether a Home Assistant URL or token
|
||||
is configured, but never includes either value. It excludes clipboard contents,
|
||||
notification bodies, calendar event data, SSIDs, device addresses, environment
|
||||
values, file contents, and command output that has not been explicitly parsed.
|
||||
|
||||
## Quickshell state and refresh model
|
||||
|
||||
`services/Health.qml` owns the latest accepted snapshot, aggregate severity,
|
||||
busy state, last scan time, and the result of the most recent repair. It invokes
|
||||
`panama-doctor` with argument arrays through `Process`; UI components never
|
||||
construct shell commands.
|
||||
|
||||
Health performs one delayed scan after the shell reaches a stable startup state.
|
||||
It scans again when the System Health page is opened, when the user presses
|
||||
Refresh, and after a repair settles. There is no periodic polling loop while the
|
||||
desktop is idle. Services that already expose event-driven state remain the
|
||||
authoritative source for their own interactive controls; Health is a diagnostic
|
||||
snapshot, not a competing live service model.
|
||||
|
||||
Every scan receives a monotonically increasing generation. Late output from an
|
||||
older scan is discarded. A malformed snapshot leaves the last valid result in
|
||||
place, marks the diagnostic engine unavailable, and offers a bounded Retry.
|
||||
|
||||
The shell exposes a typed `health` IPC target with `refresh`, `status`, `open`,
|
||||
and `repair(id)` operations. Vicinae gains **Panama: Check System Health**, which
|
||||
opens the page and requests a fresh scan through the existing `panama-action`
|
||||
dispatcher.
|
||||
|
||||
## Repair policy
|
||||
|
||||
Repairs are narrow, reversible, and attached to one check. The first release may:
|
||||
|
||||
- Restart Panama's user services such as Vicinae, Hyprpaper, or Hypridle.
|
||||
- Recreate Panama-owned symlinks when their destination is known and tracked.
|
||||
- Reload Vicinae's Panama command collection.
|
||||
- Release duplicate user-owned inhibitors whose metadata identifies Panama and
|
||||
Caffeine.
|
||||
- Restart Quickshell through the verified `panama-action restart-shell` path.
|
||||
- Open the exact Panama Settings page required to finish credentials or entity
|
||||
selection.
|
||||
|
||||
Restarting a working service is not presented as a repair. Repairs that interrupt
|
||||
visible desktop chrome require a confirmation sheet in Settings. Navigation and
|
||||
setup actions do not. Package installation, privileged service changes, display
|
||||
mode writes, and destructive cleanup are never automatic; the UI shows concise
|
||||
instructions instead.
|
||||
|
||||
After a repair, Health rescans and judges success from the observed result. A
|
||||
zero exit status alone never turns a row green. Failure remains inline on the
|
||||
affected row and also produces the existing Panama action-failure notification
|
||||
when the action originated outside Settings.
|
||||
|
||||
## Visual language and interaction
|
||||
|
||||
System Health uses the established Settings cards and Prism tokens. Healthy
|
||||
states use a small muted green dot and subdued **Healthy** copy. Warnings use
|
||||
amber; red is reserved for functionality that is configured, required, and
|
||||
currently broken. `unconfigured` rows use neutral gray.
|
||||
|
||||
The summary hero does not use a decorative gauge, percentage score, pulse,
|
||||
shimmer, or animated gradient. A desktop is not “82% healthy.” The headline and
|
||||
issue count are more understandable and do not create false precision.
|
||||
|
||||
Rows keep their height stable while refreshing. The previous snapshot remains
|
||||
visible with a quiet **Checking…** label rather than replacing the page with a
|
||||
spinner. Keyboard focus order reaches Refresh, Copy Report, issue rows, repair
|
||||
actions, and external handoffs. Status is always expressed in text as well as
|
||||
color.
|
||||
|
||||
Before production components are edited, the page and degraded bar indicator
|
||||
will be shown in several static mocks using the existing Settings geometry. The
|
||||
chosen mock must preserve this information architecture and Panama's current
|
||||
Prism language rather than introduce a new visual system.
|
||||
|
||||
## Failure handling
|
||||
|
||||
- Missing required executables become actionable check results.
|
||||
- Missing optional applications remain neutral until configured.
|
||||
- A doctor crash, timeout, or malformed JSON does not clear the last good
|
||||
snapshot or crash Quickshell.
|
||||
- Concurrent refresh requests coalesce into one follow-up scan.
|
||||
- A repair request for an unknown or non-repairable ID is rejected before any
|
||||
process starts.
|
||||
- Copy Report uses only the already-redacted snapshot and reports clipboard
|
||||
failure inline.
|
||||
- If the Settings window is closed during a scan or repair, the process may
|
||||
finish; reopening the page shows the settled result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the real helper against isolated fake command, config, state, and runtime
|
||||
directories and prove every status transition deterministically.
|
||||
- Validate the JSON schema, stable check IDs, deterministic ordering, and
|
||||
uniqueness of each ID.
|
||||
- Prove unconfigured integrations remain neutral while configured failures are
|
||||
degraded.
|
||||
- Prove reports contain no fixture secrets, clipboard text, calendar data,
|
||||
addresses, or unparsed environment values.
|
||||
- Exercise every repair through the allow-list, assert its exact command, and
|
||||
prove unknown IDs cannot execute anything.
|
||||
- Test scan generations, malformed snapshots, refresh coalescing, repair
|
||||
rescans, and preservation of the last valid state in a Quickshell harness.
|
||||
- Verify Settings routing, search entries, the live sidebar footer, and the
|
||||
degraded-only bar indicator without QML warnings.
|
||||
- Validate the Vicinae command and typed IPC surface.
|
||||
- Run a read-only doctor scan on the real workstation and compare key results to
|
||||
direct service checks. State-changing live repair tests require an actually
|
||||
degraded disposable target or explicit user approval.
|
||||
- Restart the live shell, inspect the fresh log, and visually review healthy,
|
||||
warning, error, unconfigured, refreshing, and repair-result states.
|
||||
|
||||
## Delivery slices
|
||||
|
||||
1. Diagnostic schema, read-only probes, redaction, and contract tests.
|
||||
2. `Health.qml`, typed IPC, startup/manual refresh, and fixture harness.
|
||||
3. System Health Settings page, live sidebar footer, search, and report copy.
|
||||
4. Degraded-only bar indicator and Vicinae command.
|
||||
5. Allow-listed repairs, confirmations, post-repair verification, and live audit.
|
||||
|
||||
The slices are one feature and land together. Their order keeps the UI backed by
|
||||
real diagnostics from its first production render.
|
||||
@@ -1,11 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
source ~/.local/share/Panama/bin/ascii
|
||||
# Set host name
|
||||
|
||||
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
|
||||
# also the upgrade path.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
source "$PANAMA_PATH/bin/ascii"
|
||||
|
||||
# ── Hostname, which is optional ──────────────────────────────────────────────
|
||||
#
|
||||
# Declining this used to `exit`, which aborted the ENTIRE installation. The
|
||||
# prompt defaults to N, so simply pressing Enter -- the obvious thing to do when
|
||||
# you do not want to rename your machine -- installed nothing at all and said
|
||||
# nothing about it.
|
||||
echo -e "Current hostname is: $(hostname)"
|
||||
read -p "Do you want to change the hostname? [y/N]: " confirm_change
|
||||
read -r -p "Do you want to change the hostname? [y/N]: " confirm_change
|
||||
if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
|
||||
read -p "Hostname: " HOST_NAME
|
||||
read -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname
|
||||
read -r -p "Hostname: " HOST_NAME
|
||||
read -r -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname
|
||||
if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then
|
||||
sudo hostnamectl set-hostname "$HOST_NAME"
|
||||
echo "Hostname set to: $(hostname)"
|
||||
@@ -13,18 +26,45 @@ if [[ "$confirm_change" =~ ^[Yy]$ ]]; then
|
||||
echo "Hostname not changed."
|
||||
fi
|
||||
else
|
||||
echo "Not changing hostname."
|
||||
exit
|
||||
echo "Keeping the current hostname."
|
||||
fi
|
||||
|
||||
# Ensure computer doesn't go to sleep.
|
||||
gsettings set org.gnome.desktop.screensaver lock-enabled false
|
||||
gsettings set org.gnome.desktop.session idle-delay 0
|
||||
# ── Keep the machine awake for the duration ──────────────────────────────────
|
||||
# Package installation takes long enough to hit an idle lock, and being locked
|
||||
# out mid-transaction is unpleasant. Restored on every exit path, including
|
||||
# failure and Ctrl-C, so an interrupted install does not leave the screen
|
||||
# permanently awake.
|
||||
restore_idle() {
|
||||
gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true
|
||||
gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true
|
||||
}
|
||||
trap restore_idle EXIT INT TERM
|
||||
|
||||
# Run each setup stage in its own process. This keeps strict-shell options and
|
||||
# helper variables local to the script that owns them.
|
||||
for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done
|
||||
gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true
|
||||
gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
|
||||
|
||||
# Revert to normal idle settings
|
||||
gsettings set org.gnome.desktop.screensaver lock-enabled true
|
||||
gsettings set org.gnome.desktop.session idle-delay 300
|
||||
# ── Stages ───────────────────────────────────────────────────────────────────
|
||||
# Each runs in its own process so strict-shell options and helper variables stay
|
||||
# local to the script that owns them. A failing stage is reported and the rest
|
||||
# still run: a missing optional package should not stop the dotfiles being
|
||||
# linked. The summary at the end is what decides whether the install worked,
|
||||
# because a failure scrolled past twenty minutes ago is a failure nobody saw.
|
||||
failed=()
|
||||
for script in "$PANAMA_PATH"/setup/scripts/*; do
|
||||
[[ -x "$script" ]] || continue
|
||||
stage="$(basename "$script")"
|
||||
printf '\n=== %s ===\n' "$stage"
|
||||
if ! "$script"; then
|
||||
failed+=("$stage")
|
||||
printf '!!! %s failed\n' "$stage" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n'
|
||||
if (( ${#failed[@]} == 0 )); then
|
||||
echo "Panama installed. Log out and choose the Hyprland session to start it."
|
||||
else
|
||||
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
|
||||
printf 'Re-running ./install is safe and will retry them.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
hyprland
|
||||
hyprland-uwsm
|
||||
uwsm
|
||||
quickshell
|
||||
vicinae
|
||||
hyprlock
|
||||
hypridle
|
||||
hyprpaper
|
||||
hyprpicker
|
||||
hyprsunset
|
||||
hyprpolkitagent
|
||||
hyprshutdown
|
||||
hyprpwcenter
|
||||
hyprsysteminfo
|
||||
hyprland-guiutils
|
||||
xdg-desktop-portal-hyprland
|
||||
grim
|
||||
slurp
|
||||
grimblast
|
||||
satty
|
||||
wl-clipboard
|
||||
wf-recorder
|
||||
gpu-screen-recorder
|
||||
brightnessctl
|
||||
playerctl
|
||||
pamixer
|
||||
udiskie
|
||||
wofi
|
||||
NetworkManager
|
||||
adw-gtk3-theme
|
||||
adwaita-icon-theme
|
||||
adwaita-sans-fonts
|
||||
qt6-qtwayland
|
||||
nm-connection-editor
|
||||
brightnessctl
|
||||
ddcutil
|
||||
gpu-screen-recorder
|
||||
grim
|
||||
grimblast
|
||||
gtk-update-icon-cache
|
||||
hypridle
|
||||
hyprland
|
||||
hyprland-guiutils
|
||||
hyprland-uwsm
|
||||
hyprlock
|
||||
hyprpaper
|
||||
hyprpicker
|
||||
hyprpolkitagent
|
||||
hyprpwcenter
|
||||
hyprshutdown
|
||||
hyprsunset
|
||||
hyprsysteminfo
|
||||
kde-connect
|
||||
libnotify
|
||||
nm-connection-editor
|
||||
orca
|
||||
pamixer
|
||||
playerctl
|
||||
qrencode
|
||||
qt6-qtwayland
|
||||
quickshell
|
||||
satty
|
||||
slurp
|
||||
system-config-printer
|
||||
tesseract
|
||||
tesseract-langpack-eng
|
||||
udiskie
|
||||
uwsm
|
||||
vicinae
|
||||
wf-recorder
|
||||
wireplumber
|
||||
wl-clipboard
|
||||
wofi
|
||||
xdg-desktop-portal-hyprland
|
||||
zbar
|
||||
system-config-printer
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
awk
|
||||
bat
|
||||
btop
|
||||
cargo
|
||||
curl
|
||||
eza
|
||||
fontconfig
|
||||
fwupd
|
||||
fzf
|
||||
git-all
|
||||
gh
|
||||
git-all
|
||||
gum
|
||||
jq
|
||||
kitty
|
||||
ksshaskpass
|
||||
libselinux-utils
|
||||
neovim
|
||||
openssl
|
||||
pciutils
|
||||
python3-dnf
|
||||
python3-neovim
|
||||
rustup
|
||||
tmux
|
||||
unzip
|
||||
wireguard-tools
|
||||
wget
|
||||
wireguard-tools
|
||||
zoxide
|
||||
|
||||
@@ -33,6 +33,9 @@ done
|
||||
assert_contains 'title: "Default applications"'
|
||||
assert_contains 'title: "User autostart"'
|
||||
assert_contains 'title: "Compositor autostart"'
|
||||
assert_contains 'AutostartAppPicker {'
|
||||
assert_contains 'DefaultApps.addAutostart('
|
||||
assert_contains 'label: "Add an application"'
|
||||
assert_contains 'categories'
|
||||
assert_contains 'genericName'
|
||||
assert_contains '.sort('
|
||||
@@ -123,4 +126,14 @@ fi
|
||||
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|
||||
|| fail 'default and autostart rows are not both whole-row activatable'
|
||||
|
||||
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
|
||||
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
|
||||
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
|
||||
rg -Fq 'required property var existing' "$picker" \
|
||||
|| fail 'autostart picker cannot exclude existing entries'
|
||||
rg -Fq 'signal picked(string id)' "$picker" \
|
||||
|| fail 'autostart picker does not emit a validated desktop id'
|
||||
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|
||||
|| fail 'autostart picker is not registered in the Settings module'
|
||||
|
||||
printf 'applications settings contract: PASS\n'
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every external command Panama's own scripts invoke must be installed by
|
||||
# Panama's own package lists.
|
||||
#
|
||||
# This exists because the lists had drifted badly. jq is used by thirty-one call
|
||||
# sites across the helpers and the contracts; kitty has a full shipped config
|
||||
# and a dock pin; tmux and btop have shipped themes that the colour scheme
|
||||
# switches. None of the four were declared. So a fresh machine that followed
|
||||
# this repository's own install instructions would not have them.
|
||||
#
|
||||
# The failure is quiet by design, which is what makes it worth a test: the
|
||||
# helpers are written to report "not installed" rather than crash, so a missing
|
||||
# dependency presents as a feature that silently is not there.
|
||||
#
|
||||
# Commands from coreutils and the shell itself are not checked -- nothing
|
||||
# installs those separately, and listing them would be noise.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
fail() {
|
||||
printf 'declared dependencies contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Shell syntax and builtins. These are not commands anyone installs, and the
|
||||
# first version of this contract reported `then`, `esac` and `done` as missing
|
||||
# packages, which buried the four real findings in a hundred lines of noise.
|
||||
SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$'
|
||||
|
||||
# Provided by any Fedora install: coreutils, util-linux, the shell, and the
|
||||
# systemd/session tooling. Nothing here is a choice Panama makes.
|
||||
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof)$'
|
||||
|
||||
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
||||
|
||||
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
|
||||
[[ -n "$declared" ]] || fail 'no package lists found'
|
||||
|
||||
# A package is not always named after its command. Only the genuine mismatches
|
||||
# are mapped, so an unmapped command is a real omission rather than a lookup
|
||||
# failure.
|
||||
package_for() {
|
||||
case "$1" in
|
||||
zbarimg) printf 'zbar' ;;
|
||||
fc-list|fc-match) printf 'fontconfig' ;;
|
||||
lspci) printf 'pciutils' ;;
|
||||
getenforce) printf 'libselinux-utils' ;;
|
||||
nmcli) printf 'NetworkManager' ;;
|
||||
wpctl) printf 'wireplumber' ;;
|
||||
nvim) printf 'neovim' ;;
|
||||
fwupdmgr) printf 'fwupd' ;;
|
||||
dnf4) printf 'python3-dnf' ;;
|
||||
notify-send) printf 'libnotify' ;;
|
||||
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
||||
rg) printf 'ripgrep' ;;
|
||||
python3) printf 'python3' ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
missing=()
|
||||
checked=0
|
||||
|
||||
while read -r script; do
|
||||
[[ -n "$script" ]] || continue
|
||||
head -1 "$script" | grep -qE 'bash|/sh' || continue
|
||||
|
||||
# Commands appearing at the start of a statement or after a pipe. Crude, but
|
||||
# it is looking for undeclared dependencies, not building a call graph.
|
||||
#
|
||||
# No minimum length. An earlier version required three characters, which
|
||||
# quietly excluded the most-used dependency in the repository -- jq, at
|
||||
# thirty-one call sites -- along with rg, ss and ip. A dependency checker
|
||||
# with a blind spot for short names is worse than none, because it reports
|
||||
# PASS.
|
||||
while read -r cmd; do
|
||||
[[ -n "$cmd" ]] || continue
|
||||
[[ "$cmd" =~ $SHELL_WORDS ]] && continue
|
||||
[[ "$cmd" =~ $BASELINE ]] && continue
|
||||
[[ "$cmd" =~ $SESSION ]] && continue
|
||||
|
||||
pkg="$(package_for "$cmd")"
|
||||
grep -qx "$pkg" <<<"$declared" && continue
|
||||
|
||||
# Only report a command that actually exists on this machine. An
|
||||
# invented name in a comment or a heredoc is a false positive; a real
|
||||
# binary that nothing declares is the thing being looked for.
|
||||
command -v "$cmd" >/dev/null 2>&1 || continue
|
||||
|
||||
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
|
||||
done < <({
|
||||
# Statement-initial or after a pipe.
|
||||
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \
|
||||
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||
|
||||
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
|
||||
# so it never appears statement-initial and was missed entirely.
|
||||
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
||||
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||
|
||||
# `command -v X` is how these helpers probe for a tool before using it,
|
||||
# which makes it the clearest possible statement of a dependency.
|
||||
grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
||||
| grep -oE '[a-z][a-z0-9_-]+$'
|
||||
} | sort -u)
|
||||
|
||||
checked=$((checked + 1))
|
||||
done < <(find "$repo_dir/config/dot/quickshell/scripts" \
|
||||
"$repo_dir/config/local/share/vicinae/scripts" \
|
||||
"$repo_dir/setup/scripts" "$repo_dir/bin" \
|
||||
-type f 2>/dev/null)
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
printf 'declared dependencies contract: commands used but never installed:\n' >&2
|
||||
printf ' %s\n' "${missing[@]}" | sort -u >&2
|
||||
fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine'
|
||||
fi
|
||||
|
||||
printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked"
|
||||
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
|
||||
assert_service_contains 'function refresh(): void'
|
||||
assert_service_contains 'function setDefault(role: string, desktopId: string): void'
|
||||
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
|
||||
assert_service_contains 'function addAutostart(desktopId: string): void'
|
||||
assert_service_contains 'DesktopEntries.applications.values'
|
||||
if rg --quiet 'command\s*:\s*"' "$service"; then
|
||||
fail 'Process command must be an argument array'
|
||||
@@ -227,4 +228,27 @@ if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
|
||||
fail 'read-only compositor entry was accepted for mutation'
|
||||
fi
|
||||
|
||||
$helper add-autostart org.mozilla.firefox.desktop
|
||||
firefox_autostart="$config_home/autostart/org.mozilla.firefox.desktop"
|
||||
[[ -f "$firefox_autostart" && ! -L "$firefox_autostart" ]] \
|
||||
|| fail 'adding an installed application did not create a regular user autostart entry'
|
||||
rg --quiet '^Name=Firefox$' "$firefox_autostart" \
|
||||
|| fail 'adding an application did not preserve its desktop entry'
|
||||
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|
||||
|| fail 'a newly added application was not enabled'
|
||||
[[ "$(rg --count '^Hidden=' "$firefox_autostart")" == "1" ]] \
|
||||
|| fail 'adding an application wrote more than one Hidden key'
|
||||
|
||||
$helper set-autostart org.mozilla.firefox.desktop false
|
||||
$helper add-autostart org.mozilla.firefox.desktop
|
||||
rg --quiet '^Hidden=false$' "$firefox_autostart" \
|
||||
|| fail 'adding an existing disabled application did not re-enable it'
|
||||
|
||||
if $helper add-autostart org.example.Missing.desktop >/dev/null 2>&1; then
|
||||
fail 'an undiscovered application was accepted for autostart'
|
||||
fi
|
||||
if $helper add-autostart ../escape.desktop >/dev/null 2>&1; then
|
||||
fail 'an unsafe desktop id was accepted for autostart'
|
||||
fi
|
||||
|
||||
printf 'default apps contract: PASS\n'
|
||||
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Desktop style stays split across three boundaries that can all be checked
|
||||
# without starting the shell: the read-only icon catalog, the preference/schema
|
||||
# wiring, and the QML surfaces that consume it. The catalog runs only against
|
||||
# disposable XDG roots so this contract never reads or changes the live desktop.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-desktop-style"
|
||||
service="$repo_dir/config/dot/quickshell/services/DesktopStyle.qml"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
||||
mouse="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml"
|
||||
accessibility="$repo_dir/config/dot/quickshell/services/Accessibility.qml"
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
env_lua="$repo_dir/config/dot/hypr/env.lua"
|
||||
looks_lua="$repo_dir/config/dot/hypr/looks.lua"
|
||||
|
||||
fail() {
|
||||
printf 'desktop style contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for file in "$helper" "$service" "$schema" "$appearance" "$mouse" \
|
||||
"$accessibility" "$search" "$env_lua" "$looks_lua"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'desktop-style catalog is not executable'
|
||||
|
||||
# ── Read-only catalog semantics ─────────────────────────────────────────────
|
||||
fixture="$(mktemp -d /tmp/panama-desktop-style.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
home="$fixture/home"
|
||||
data_home="$fixture/data-home"
|
||||
data_one="$fixture/data-one"
|
||||
data_two="$fixture/data-two"
|
||||
mkdir -p "$home" \
|
||||
"$data_home/icons/CursorOnly/cursors" \
|
||||
"$data_one/icons/IconOnly/16x16/apps" \
|
||||
"$data_one/icons/Both/cursors" \
|
||||
"$data_one/icons/Both/scalable/apps" \
|
||||
"$data_one/icons/EmptyDirectories" \
|
||||
"$data_two/icons/Both/cursors" \
|
||||
"$data_two/icons/Both/scalable/apps" \
|
||||
"$data_two/icons/NoIndex/16x16/apps"
|
||||
|
||||
cat >"$data_home/icons/CursorOnly/index.theme" <<'EOF'
|
||||
[Icon Theme]
|
||||
Name=Cursor only
|
||||
Directories=
|
||||
EOF
|
||||
cat >"$data_one/icons/IconOnly/index.theme" <<'EOF'
|
||||
[Icon Theme]
|
||||
Name=Icon only
|
||||
Directories=16x16/apps
|
||||
EOF
|
||||
cat >"$data_one/icons/Both/index.theme" <<'EOF'
|
||||
[Icon Theme]
|
||||
Name=Both
|
||||
Directories=scalable/apps
|
||||
EOF
|
||||
cat >"$data_one/icons/EmptyDirectories/index.theme" <<'EOF'
|
||||
[Icon Theme]
|
||||
Name=Not an icon catalog entry
|
||||
Directories=
|
||||
EOF
|
||||
cp "$data_one/icons/Both/index.theme" "$data_two/icons/Both/index.theme"
|
||||
|
||||
catalog="$(
|
||||
HOME="$home" \
|
||||
XDG_DATA_HOME="$data_home" \
|
||||
XDG_DATA_DIRS="$data_one:$data_two" \
|
||||
"$helper"
|
||||
)" || fail 'catalog helper failed against disposable XDG roots'
|
||||
|
||||
jq -e '
|
||||
type == "object"
|
||||
and (keys | sort) == ["cursorThemes", "iconThemes"]
|
||||
and .cursorThemes == ["Both", "CursorOnly"]
|
||||
and .iconThemes == ["Both", "IconOnly"]
|
||||
and (all(.cursorThemes[]; type == "string" and length > 0))
|
||||
and (all(.iconThemes[]; type == "string" and length > 0))
|
||||
' <<<"$catalog" >/dev/null \
|
||||
|| fail "catalog JSON shape or theme classification is wrong: $catalog"
|
||||
|
||||
if HOME="$home" XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_one:$data_two" \
|
||||
"$helper" "$fixture/not-an-xdg-root" >/dev/null 2>&1; then
|
||||
fail 'catalog accepted a caller-supplied path'
|
||||
fi
|
||||
|
||||
# ── Schema, compositor replay, service, and UI wiring ───────────────────────
|
||||
python3 - "$schema" <<'PY' || fail 'desktop-style schema entries are missing or malformed'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
|
||||
expected = {
|
||||
"cursorTheme": ("string", '"oreo_blue_cursors"', "themes"),
|
||||
"iconTheme": ("string", '"Adwaita"', "themes"),
|
||||
"applicationFont": ("string", '"Adwaita Sans"', "typography"),
|
||||
"applicationFontSize": ("int", "11", "typography"),
|
||||
"documentFont": ("string", '"Adwaita Sans"', "typography"),
|
||||
"documentFontSize": ("int", "12", "typography"),
|
||||
"monospaceFont": ("string", '"VictorMono Nerd Font"', "typography"),
|
||||
"monospaceFontSize": ("int", "10", "typography"),
|
||||
"fontHinting": ("enum", '"slight"', "typography"),
|
||||
"fontAntialiasing": ("enum", '"rgba"', "typography"),
|
||||
"titlebarButtonSide": ("enum", '"right"', "titlebar"),
|
||||
"titlebarMaximizeButton": ("bool", "false", "titlebar"),
|
||||
"titlebarDoubleClick": ("enum", '"toggle-maximize"', "titlebar"),
|
||||
"middleClickPaste": ("bool", "true", "pointer"),
|
||||
}
|
||||
|
||||
def block_for(key: str) -> str:
|
||||
match = re.search(r"\{\s*\n\s*key:\s*\"" + re.escape(key) + r"\".*?\n\s{8}\}", text, re.S)
|
||||
if not match:
|
||||
raise SystemExit(f"missing {key}")
|
||||
return match.group(0)
|
||||
|
||||
for key, (kind, default, group) in expected.items():
|
||||
block = block_for(key)
|
||||
if not re.search(rf'type:\s*"{re.escape(kind)}"', block):
|
||||
raise SystemExit(f"{key} has wrong type")
|
||||
if not re.search(rf'def:\s*{re.escape(default)}', block):
|
||||
raise SystemExit(f"{key} has wrong default")
|
||||
if not re.search(rf'group:\s*"{re.escape(group)}"', block):
|
||||
raise SystemExit(f"{key} has wrong group")
|
||||
|
||||
enum_values = {
|
||||
"fontHinting": ["none", "slight", "medium", "full"],
|
||||
"fontAntialiasing": ["none", "grayscale", "rgba"],
|
||||
"titlebarButtonSide": ["left", "right"],
|
||||
"titlebarDoubleClick": ["toggle-maximize", "none"],
|
||||
}
|
||||
for key, values in enum_values.items():
|
||||
block = block_for(key)
|
||||
actual = re.findall(r'value:\s*"([^"]+)"', block)
|
||||
if actual != values:
|
||||
raise SystemExit(f"{key} options are {actual}, expected {values}")
|
||||
|
||||
middle = block_for("middleClickPaste")
|
||||
for fragment in (
|
||||
'path: ["misc", "middle_click_paste"]',
|
||||
'option: "misc:middle_click_paste"',
|
||||
'readAs: "bool"',
|
||||
):
|
||||
if fragment not in middle:
|
||||
raise SystemExit(f"middleClickPaste is missing {fragment}")
|
||||
PY
|
||||
|
||||
rg -q 'prefs\.get\("cursorTheme", "oreo_blue_cursors"\)' "$env_lua" \
|
||||
|| fail 'Hyprland environment does not replay cursorTheme'
|
||||
rg -q 'middle_click_paste\s*=\s*prefs\.get\("middleClickPaste", true\)' "$looks_lua" \
|
||||
|| fail 'Hyprland misc does not replay middleClickPaste'
|
||||
|
||||
rg -q 'DesktopPreferences\.get\("cursorTheme"\)' "$accessibility" \
|
||||
|| fail 'Accessibility does not use the stored cursor theme'
|
||||
! rg -q 'gsettings.*cursor-theme|themeQuery' "$accessibility" \
|
||||
|| fail 'Accessibility still queries cursor-theme from gsettings'
|
||||
|
||||
for needle in \
|
||||
'property var cursorThemes' \
|
||||
'property var iconThemes' \
|
||||
'DesktopPreferences.revision' \
|
||||
'Fonts.interfaceFonts' \
|
||||
'Fonts.monospaceFonts' \
|
||||
'gtk-enable-primary-paste' \
|
||||
'button-layout' \
|
||||
'action-double-click-titlebar'; do
|
||||
rg -Fq "$needle" "$service" || fail "DesktopStyle is missing $needle"
|
||||
done
|
||||
! rg -q 'sh -c|bash -c' "$service" \
|
||||
|| fail 'DesktopStyle routes gsettings through a shell'
|
||||
|
||||
for group in typography themes titlebar; do
|
||||
rg -q "\"$group\": \"appearance\"" "$search" \
|
||||
|| fail "settings search does not route $group to Appearance"
|
||||
done
|
||||
rg -q '"pointer": "mouse"' "$search" \
|
||||
|| fail 'settings search no longer routes pointer controls to Mouse'
|
||||
|
||||
for needle in \
|
||||
'title: "Application typography"' \
|
||||
'setting: "applicationFontSize"' \
|
||||
'setting: "documentFontSize"' \
|
||||
'setting: "monospaceFontSize"' \
|
||||
'setting: "fontHinting"' \
|
||||
'setting: "fontAntialiasing"' \
|
||||
'title: "Icons & pointer"' \
|
||||
'DesktopStyle.cursorThemes' \
|
||||
'DesktopStyle.iconThemes' \
|
||||
'title: "Titlebars"' \
|
||||
'setting: "titlebarButtonSide"' \
|
||||
'setting: "titlebarMaximizeButton"' \
|
||||
'setting: "titlebarDoubleClick"'; do
|
||||
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
||||
done
|
||||
! rg -q 'setting: "titlebarMinimize|GTK theme|Shell theme' "$appearance" \
|
||||
|| fail 'Appearance exposes an inert or separately-owned theme control'
|
||||
|
||||
rg -q 'setting: "middleClickPaste"' "$mouse" \
|
||||
|| fail 'Mouse does not expose middle-click paste'
|
||||
rg -q 'GTK.*Wayland|Wayland.*GTK' "$mouse" \
|
||||
|| fail 'Mouse does not explain the GTK and Wayland scope honestly'
|
||||
|
||||
printf 'desktop style contract: PASS\n'
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "invalid-utf8" ]]; then
|
||||
printf '\377\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'org.freedesktop.portal.Desktop 1000 portal' \
|
||||
'org.kde.kdeconnect 1000 kdeconnect' \
|
||||
'fixture clipboard body AA:BB:CC:DD:EE:FF'
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
name="${!#}"
|
||||
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
||||
*",$name:duplicate,"*) printf '4101\n4102\n' ;;
|
||||
*",$name:malformed,"*) printf 'not-a-pid\n' ;;
|
||||
*",$name:missing,"*) exit 1 ;;
|
||||
*) printf '4101\n' ;;
|
||||
esac
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;;
|
||||
list)
|
||||
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
||||
*,qs:duplicate,*) printf '%s\n' 'Instance fixture-one:' 'Instance fixture-two:' ;;
|
||||
*,qs:missing,*) ;;
|
||||
*) printf '%s\n' 'Instance fixture-one:' ;;
|
||||
esac
|
||||
;;
|
||||
ipc)
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
|
||||
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
|
||||
else
|
||||
printf '%s\n' 'target notifications' 'target clipboard' 'target wallpaper' 'target capture'
|
||||
fi
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n ${PANAMA_DOCTOR_FIXTURE_PROBE_SECRET+x} ]]; then
|
||||
exit 97
|
||||
fi
|
||||
|
||||
service="${4:-}"
|
||||
case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in
|
||||
*",$service,"*) exit 3 ;;
|
||||
esac
|
||||
printf 'fixture-secret-token\n'
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
uid="$(/usr/bin/id -u)"
|
||||
printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then
|
||||
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
fi
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "malformed" ]]; then
|
||||
printf 'Panama %s fixture-user invalid Caffeine\n' "$uid"
|
||||
fi
|
||||
printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid"
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
--version) printf 'Vicinae 0.26.0 fixture-secret-token\n' ;;
|
||||
ping) exit 0 ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
Executable
+412
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Health owns the accepted diagnostic snapshot. A newer unreadable response
|
||||
# must degrade diagnostics without discarding the last report that Settings
|
||||
# and future health surfaces will render.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source_harness="$repo_dir/config/dot/quickshell/health-harness.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Health.qml"
|
||||
shell="$repo_dir/config/dot/quickshell/shell.qml"
|
||||
warning_snapshot='{"schemaVersion":1,"generatedAt":"2026-08-18T00:00:00Z","summary":{"status":"warning","healthy":0,"warnings":2,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"warning","detail":"Duplicate inhibitors are active.","action":{"kind":"repair","label":"Release duplicate inhibitors","confirm":false}}]}'
|
||||
confirm_snapshot="$(jq -c '
|
||||
.summary.status = "error"
|
||||
| .summary.errors = 1
|
||||
| .checks += [{
|
||||
id: "desktop.quickshell",
|
||||
group: "desktop-foundation",
|
||||
title: "Quickshell",
|
||||
status: "error",
|
||||
detail: "Panama shell needs to restart.",
|
||||
action: {kind: "repair", label: "Restart Panama", confirm: true}
|
||||
}]
|
||||
' <<<"$warning_snapshot")"
|
||||
projection_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
| .context.fixtureSecret = "fixture-secret"
|
||||
| .context.versions[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].fixtureSecret = "fixture-secret"
|
||||
' <<<"$warning_snapshot")"
|
||||
adversarial_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
| .context.fixtureSecret = "fixture-secret"
|
||||
| .context.versions[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].action.fixtureSecret = "fixture-secret"
|
||||
' <<<"$warning_snapshot")"
|
||||
|
||||
fail() {
|
||||
printf 'health service contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||
[[ -f "$source_harness" ]] || fail 'health harness is missing'
|
||||
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
||||
rg -q 'target: "wallpaper"' "$shell" \
|
||||
|| fail 'wallpaper health target is not exported by the shell'
|
||||
|
||||
# shell.qml is not started here: it is the active desktop shell. Keep this
|
||||
# contract static while pinning the typed, redacted IPC boundary it exports.
|
||||
python3 - "$shell" <<'PY' || fail 'health IPC contract is missing or exposes unsafe state'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r'IpcHandler \{\s*target: "health"(?P<body>.*?)\n \}', text, re.S)
|
||||
if not match:
|
||||
raise SystemExit(1)
|
||||
body = match.group("body")
|
||||
required = (
|
||||
'function refresh(): bool { return Health.refresh(); }',
|
||||
'function status(): string {',
|
||||
'summary: Health.summary,',
|
||||
'busy: Health.busy,',
|
||||
'generation: Health.generation,',
|
||||
'acceptedGeneration: Health.acceptedGeneration,',
|
||||
'checks: Health.checks.map(check => ({ id: check.id, status: check.status }))',
|
||||
'ShellState.openSettings("services");',
|
||||
'Health.refresh();',
|
||||
'function repair(id: string): bool { return Health.repair(id, true); }',
|
||||
)
|
||||
if any(entry not in body for entry in required):
|
||||
raise SystemExit(1)
|
||||
if 'Health.snapshot' in body or 'Health.diagnostics' in body:
|
||||
raise SystemExit(1)
|
||||
status = re.search(r'function status\(\): string \{\s*return JSON\.stringify\(\{(?P<fields>.*?)\n \}\);', body, re.S)
|
||||
if not status:
|
||||
raise SystemExit(1)
|
||||
keys = re.findall(r'^\s*([A-Za-z][A-Za-z0-9]*):', status.group("fields"), re.M)
|
||||
if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
|
||||
config_path="$fixture_dir/quickshell"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
harness="$config_path/health-harness.qml"
|
||||
python3 - "$harness" <<'PY'
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
source = open(path, encoding="utf-8").read()
|
||||
needle = ' function repair(id: string): bool { return Health.repair(id, false); }\n'
|
||||
replacement = needle + ''' function externalRepair(id: string): bool { return Health.repair(id, true); }
|
||||
function pendingRefreshRace(): string {
|
||||
const before = Health.generation;
|
||||
Health.finishRepair(0, "panama.caffeine", false, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
checkId: "panama.caffeine",
|
||||
accepted: true,
|
||||
exitCode: 0,
|
||||
message: "Fixture repair completed."
|
||||
}));
|
||||
const accepted = Health.refresh();
|
||||
return JSON.stringify({ accepted: accepted, before: before });
|
||||
}
|
||||
'''
|
||||
if needle not in source:
|
||||
raise SystemExit("health harness repair seam is missing")
|
||||
open(path, "w", encoding="utf-8").write(source.replace(needle, replacement))
|
||||
PY
|
||||
helper="$fixture_dir/panama-doctor"
|
||||
copy_bin="$fixture_dir/bin"
|
||||
copy_file="$fixture_dir/copied-report.json"
|
||||
repair_mode_file="$fixture_dir/repair-mode"
|
||||
repair_log="$fixture_dir/repair.log"
|
||||
notification_log="$fixture_dir/notifications.log"
|
||||
repair_started_file="$fixture_dir/repair-started"
|
||||
repair_release_file="$fixture_dir/repair-release"
|
||||
printf 'success\n' >"$repair_mode_file"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf "%s\n" "$*" >>"$PANAMA_HEALTH_REPAIR_LOG"' \
|
||||
'if [[ "$1" == "--json" ]]; then' \
|
||||
' sleep 0.2' \
|
||||
" printf '%s\\n' '$warning_snapshot'" \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" ]]; then' \
|
||||
' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \
|
||||
' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \
|
||||
' while [[ ! -e "$PANAMA_HEALTH_REPAIR_RELEASE" ]]; do sleep 0.02; done' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \
|
||||
' case "$(cat "$PANAMA_HEALTH_REPAIR_MODE_FILE")" in' \
|
||||
' success) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":0,\"message\":\"Duplicate inhibitors were released.\"}\\n"; exit 0 ;;' \
|
||||
' failed) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":7,\"message\":\"Duplicate inhibitors could not be released.\"}\\n"; exit 7 ;;' \
|
||||
' mismatch) printf "{\"schemaVersion\":1,\"checkId\":\"desktop.vicinae\",\"accepted\":true,\"exitCode\":0,\"message\":\"Wrong row.\"}\\n"; exit 0 ;;' \
|
||||
' *) printf "not-json\\n"; exit 0 ;;' \
|
||||
' esac' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" && "$2" == "desktop.quickshell" && "$3" == "--json" ]]; then' \
|
||||
' printf "{\"schemaVersion\":1,\"checkId\":\"desktop.quickshell\",\"accepted\":true,\"exitCode\":0,\"message\":\"Panama shell restart was requested.\"}\\n"' \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'exit 2' >"$helper"
|
||||
chmod +x "$helper"
|
||||
mkdir -p "$copy_bin"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'/usr/bin/cat > "$PANAMA_HEALTH_COPY_FILE"' >"$copy_bin/wl-copy"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'printf "%s\n" "$*" >>"$PANAMA_HEALTH_NOTIFICATION_LOG"' >"$copy_bin/notify-send"
|
||||
chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
|
||||
|
||||
run() {
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
|
||||
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
harness_pid=""
|
||||
harness_start_time=""
|
||||
|
||||
process_identity_matches() {
|
||||
local pid="$1" expected_start_time="$2" expected_command="${3:-}" current_start_time
|
||||
|
||||
[[ "$pid" =~ ^[0-9]+$ && "$expected_start_time" =~ ^[0-9]+$ ]] || return 1
|
||||
[[ -r "/proc/$pid/stat" ]] || return 1
|
||||
current_start_time="$(awk '{ print $22 }' "/proc/$pid/stat" 2>/dev/null)" || return 1
|
||||
[[ "$current_start_time" == "$expected_start_time" ]] || return 1
|
||||
if [[ -n "$expected_command" ]]; then
|
||||
[[ -r "/proc/$pid/cmdline" ]] || return 1
|
||||
tr '\0' '\n' <"/proc/$pid/cmdline" | grep -Fxq "$expected_command"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
: >"$repair_release_file"
|
||||
if [[ -f "$repair_started_file" ]]; then
|
||||
IFS='|' read -r repair_pid repair_start_time <"$repair_started_file" || true
|
||||
if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then
|
||||
for _ in $(seq 1 40); do
|
||||
! process_identity_matches "$repair_pid" "$repair_start_time" "$helper" && break
|
||||
sleep 0.05
|
||||
done
|
||||
if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then
|
||||
kill "$repair_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 20); do
|
||||
! process_identity_matches "$repair_pid" "$repair_start_time" "$helper" && break
|
||||
sleep 0.05
|
||||
done
|
||||
if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then
|
||||
kill -KILL "$repair_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if process_identity_matches "$harness_pid" "$harness_start_time"; then
|
||||
kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
! process_identity_matches "$harness_pid" "$harness_start_time" && break
|
||||
sleep 0.05
|
||||
done
|
||||
if process_identity_matches "$harness_pid" "$harness_start_time"; then
|
||||
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
rm -rf "$fixture_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
|
||||
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
|
||||
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
|
||||
qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
run ipc show 2>/dev/null | rg -q '^target health-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target health-test$' || fail 'test IPC target did not start'
|
||||
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||
harness_start_time="$(awk '{ print $22 }' "/proc/$harness_pid/stat" 2>/dev/null || true)"
|
||||
process_identity_matches "$harness_pid" "$harness_start_time" \
|
||||
|| fail 'could not capture a stable health harness process identity'
|
||||
|
||||
[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'valid warning snapshot was rejected'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
|
||||
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'snapshot with unknown non-action fields was rejected instead of safely projected'
|
||||
stored_report="$(run ipc call health-test report)"
|
||||
! grep -Fq 'fixture-secret' <<<"$stored_report" \
|
||||
|| fail "accepted snapshot retained an unknown secret field: $stored_report"
|
||||
[[ "$(run ipc call health-test copy)" == "true" ]] \
|
||||
|| fail 'copy report was refused'
|
||||
for _ in $(seq 1 40); do
|
||||
[[ -f "$copy_file" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ -f "$copy_file" ]] || fail 'copy report did not reach wl-copy'
|
||||
! grep -Fq 'fixture-secret' "$copy_file" \
|
||||
|| fail 'copied report retained an unknown secret field'
|
||||
|
||||
adversarial_result="$(run ipc call health-test accept "$adversarial_snapshot" 1)"
|
||||
[[ "$adversarial_result" == "true" || "$adversarial_result" == "false" ]] \
|
||||
|| fail "adversarial action fixture did not return a Boolean: $adversarial_result"
|
||||
stored_report="$(run ipc call health-test report)"
|
||||
! grep -Fq 'fixture-secret' <<<"$stored_report" \
|
||||
|| fail "adversarial snapshot leaked an unknown secret field: $stored_report"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$warning_snapshot" -1)" == "false" ]] \
|
||||
|| fail 'older generation replaced the current snapshot'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "older generation altered accepted state: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept '{not json' 1)" == "false" ]] \
|
||||
|| fail 'malformed snapshot was accepted'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.diagnosticUnavailable == true and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "malformed snapshot discarded the last valid checks: $state"
|
||||
|
||||
before_generation="$(jq -r .generation <<<"$state")"
|
||||
run ipc call health-test queue >/dev/null
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.queuedRefresh == true and .generation == ($before + 1)' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" || fail "two refreshes did not retain exactly one follow-up: $state"
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state"
|
||||
|
||||
printf 'success\n' >"$repair_mode_file"
|
||||
rm -f "$repair_started_file" "$repair_release_file"
|
||||
repair_generation="$(jq -r .generation <<<"$state")"
|
||||
[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|
||||
|| fail 'repairable check was refused'
|
||||
for _ in $(seq 1 100); do
|
||||
[[ -s "$repair_started_file" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
[[ -s "$repair_started_file" ]] || fail 'repair helper never reached the started marker'
|
||||
run ipc call health-test queue >/dev/null
|
||||
working_state="$(run ipc call health-test status)"
|
||||
jq -e '.repairingId == "panama.caffeine" and .queuedRefresh == true
|
||||
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \
|
||||
>/dev/null <<<"$working_state" || fail "repair did not retain the degraded row while working: $working_state"
|
||||
: >"$repair_release_file"
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' --argjson before "$repair_generation" \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false
|
||||
and .lastRepair == {schemaVersion:1, checkId:"panama.caffeine", accepted:true, exitCode:0, message:"Duplicate inhibitors were released."}
|
||||
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \
|
||||
--argjson before "$repair_generation" >/dev/null <<<"$state" \
|
||||
|| fail "accepted repair was trusted before exactly one observed rescan: $state"
|
||||
|
||||
# A syntactically valid command failure remains inline for Settings and still
|
||||
# receives exactly one observed rescan.
|
||||
printf 'failed\n' >"$repair_mode_file"
|
||||
failure_generation="$(jq -r .generation <<<"$state")"
|
||||
[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|
||||
|| fail 'second repairable check was refused'
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1)' --argjson before "$failure_generation" \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastRepair.checkId == "panama.caffeine"
|
||||
and .lastRepair.accepted == true and .lastRepair.exitCode == 7
|
||||
and .lastRepair.message == "Duplicate inhibitors could not be released."
|
||||
and .generation == ($before + 1)' --argjson before "$failure_generation" \
|
||||
>/dev/null <<<"$state" || fail "known repair failure was not retained inline: $state"
|
||||
[[ ! -e "$notification_log" || ! -s "$notification_log" ]] \
|
||||
|| fail 'Settings-originated repair emitted an external notification'
|
||||
|
||||
# A malformed or mismatched helper response is contained and cannot masquerade
|
||||
# as recovery; it also schedules only one scan.
|
||||
printf 'mismatch\n' >"$repair_mode_file"
|
||||
mismatch_generation="$(jq -r .generation <<<"$state")"
|
||||
[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|
||||
|| fail 'mismatch repair fixture was refused'
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1)' --argjson before "$mismatch_generation" \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastRepair.checkId == "panama.caffeine" and .lastRepair.accepted == false
|
||||
and .lastRepair.exitCode == 0 and .generation == ($before + 1)' \
|
||||
--argjson before "$mismatch_generation" >/dev/null <<<"$state" \
|
||||
|| fail "mismatched repair JSON escaped containment: $state"
|
||||
|
||||
# A refresh arriving after repair settlement but before the deferred mandatory
|
||||
# scan is coalesced into that scan instead of starting an extra generation.
|
||||
pending_race="$(run ipc call health-test pendingRefreshRace)"
|
||||
jq -e '.accepted == false' >/dev/null <<<"$pending_race" \
|
||||
|| fail "refresh escaped the post-repair pending window: $pending_race"
|
||||
pending_generation="$(jq -r .before <<<"$pending_race")"
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \
|
||||
--argjson before "$pending_generation" >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \
|
||||
--argjson before "$pending_generation" >/dev/null <<<"$state" \
|
||||
|| fail "pending-window refresh created duplicate scans: $state"
|
||||
|
||||
# External IPC cannot bypass an authored confirmation. The same current row is
|
||||
# still repairable through Settings' external=false path after UI confirmation.
|
||||
confirm_generation="$(jq -r .generation <<<"$state")"
|
||||
[[ "$(run ipc call health-test accept "$confirm_snapshot" "$confirm_generation")" == "true" ]] \
|
||||
|| fail 'confirmation fixture was rejected'
|
||||
before_repair_lines="$(wc -l <"$repair_log")"
|
||||
[[ "$(run ipc call health-test externalRepair desktop.quickshell)" == "false" ]] \
|
||||
|| fail 'external repair bypassed confirmation'
|
||||
[[ "$(wc -l <"$repair_log")" == "$before_repair_lines" ]] \
|
||||
|| fail 'external confirmation rejection started a process'
|
||||
[[ "$(run ipc call health-test repair desktop.quickshell)" == "true" ]] \
|
||||
|| fail 'confirmed Settings repair was refused'
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 1)' \
|
||||
--argjson before "$confirm_generation" >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastRepair == {schemaVersion:1, checkId:"desktop.quickshell", accepted:true, exitCode:0, message:"Panama shell restart was requested."}
|
||||
and .generation == ($before + 1)' --argjson before "$confirm_generation" \
|
||||
>/dev/null <<<"$state" || fail "confirmed Settings repair did not complete safely: $state"
|
||||
[[ "$(grep -Fc -- '--repair desktop.quickshell --json' "$repair_log")" == 1 ]] \
|
||||
|| fail 'confirmed Settings repair did not start exactly one repair process'
|
||||
|
||||
[[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \
|
||||
|| fail 'unknown check started a repair'
|
||||
[[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \
|
||||
|| fail 'non-repairable check started a repair'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
|
||||
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
|
||||
|
||||
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
assert 'if (failed && external && !failureNotification.running)' in source
|
||||
assert '"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic"' in source
|
||||
assert '"Panama action failed", "The requested health repair could not be completed."' in source
|
||||
PY
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'health service contract: PASS\n'
|
||||
Executable
+429
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The approved Diagnostic Ledger is exercised in an isolated Quickshell
|
||||
# harness. It never maps or reloads the user's production shell.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
bar_dir="$repo_dir/config/dot/quickshell/modules/bar"
|
||||
|
||||
fail() {
|
||||
printf 'health UI contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for file in HealthPage.qml HealthSummary.qml HealthCheckRow.qml; do
|
||||
[[ -f "$settings_dir/$file" ]] || fail "$file is missing"
|
||||
done
|
||||
[[ -f "$bar_dir/HealthIndicator.qml" ]] || fail 'HealthIndicator.qml is missing'
|
||||
|
||||
python3 - "$bar_dir/Bar.qml" <<'PY' || fail 'health indicator is not immediately before activity in the bar'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r'HealthIndicator\s*\{.*?\}\s*ActivityIndicator\s*\{', source, re.S)
|
||||
assert match is not None
|
||||
PY
|
||||
|
||||
! rg -n '#[0-9a-fA-F]{3,8}' "$bar_dir/HealthIndicator.qml" >/dev/null \
|
||||
|| fail 'health indicator introduced colors outside Theme'
|
||||
! rg -n 'Behavior|Animation|Transition|pulse|shimmer' "$bar_dir/HealthIndicator.qml" >/dev/null \
|
||||
|| fail 'health indicator introduced motion'
|
||||
[[ ! -e "$settings_dir/ServicesPage.qml" ]] || fail 'ServicesPage.qml still exists'
|
||||
rg -Fq 'HealthPage 1.0 HealthPage.qml' "$settings_dir/qmldir" \
|
||||
|| fail 'HealthPage is not registered in the Settings QML module'
|
||||
rg -Fq 'HealthSummary 1.0 HealthSummary.qml' "$settings_dir/qmldir" \
|
||||
|| fail 'HealthSummary is not registered in the Settings QML module'
|
||||
rg -Fq 'HealthCheckRow 1.0 HealthCheckRow.qml' "$settings_dir/qmldir" \
|
||||
|| fail 'HealthCheckRow is not registered in the Settings QML module'
|
||||
! rg -Fq 'ServicesPage 1.0 ServicesPage.qml' "$settings_dir/qmldir" \
|
||||
|| fail 'retired ServicesPage remains registered in the Settings QML module'
|
||||
|
||||
rg -Fq 'label: "System Health"' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'sidebar does not label the stable services route System Health'
|
||||
rg -Fq 'onTapped: root.pageRequested("services")' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'health footer does not open the stable services route'
|
||||
rg -Fq 'height: 54' "$settings_dir/SettingsSidebar.qml" \
|
||||
|| fail 'health footer lost its 54px target'
|
||||
rg -Fq 'onClicked: Health.copyReport()' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'Copy Report does not use the redacted Health report path'
|
||||
rg -Fq 'text: "Checking…"' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'refresh state is not expressed in text'
|
||||
rg -Fq 'implicitHeight: 126' "$settings_dir/HealthSummary.qml" \
|
||||
|| fail 'summary hero is not the approved stable 126px height'
|
||||
rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \
|
||||
|| fail 'health rows are below the approved 62px target'
|
||||
rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'opening System Health does not request a fresh scan'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary does not open GNOME Settings'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Users handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Sharing handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Colour profiles handoff'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
|
||||
# Exact authored handoffs are asserted above. Also prove every panel named by
|
||||
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead
|
||||
# button even if its copy still looks correct.
|
||||
rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'the Fedora ownership boundary card is gone'
|
||||
|
||||
allowed="$(rg -o '"[a-z-]+"' "$repo_dir/config/dot/quickshell/services/SystemSettings.qml" \
|
||||
| sed -n '/"\(applications\|background\|bluetooth\|color\|display\|keyboard\|mouse\|multitasking\|network\|notifications\|online-accounts\|power\|printers\|privacy\|search\|sharing\|sound\|system\|universal-access\|wacom\|wellbeing\|wifi\|wwan\)"/p' \
|
||||
| tr -d '"' | sort -u)"
|
||||
|
||||
while read -r panel; do
|
||||
[[ -n "$panel" ]] || continue
|
||||
grep -qx "$panel" <<<"$allowed" \
|
||||
|| fail "the Fedora card opens \"$panel\", which openGnomePanel does not allow -- that button does nothing"
|
||||
done < <(rg -o 'openGnomePanel\("([a-z-]+)"' -r '$1' "$settings_dir/HealthPage.qml" | sort -u)
|
||||
|
||||
rg -q 'openGnomePanel\(' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'the Fedora ownership boundary does not open GNOME Settings at all'
|
||||
rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'Settings repair does not stay inline/non-external'
|
||||
rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \
|
||||
|| fail 'authored Settings targets are not routed directly'
|
||||
! rg -n '#[0-9a-fA-F]{3,8}' \
|
||||
"$settings_dir/HealthPage.qml" \
|
||||
"$settings_dir/HealthSummary.qml" \
|
||||
"$settings_dir/HealthCheckRow.qml" >/dev/null \
|
||||
|| fail 'health UI introduced colors outside Theme'
|
||||
|
||||
python3 - "$settings_dir/HealthPage.qml" "$settings_dir/HealthSummary.qml" \
|
||||
"$settings_dir/HealthCheckRow.qml" <<'PY' || fail 'approved health structure or accessibility contract is missing'
|
||||
import sys
|
||||
|
||||
page, summary, row = [open(path, encoding="utf-8").read() for path in sys.argv[1:]]
|
||||
|
||||
labels = (
|
||||
'if (status === "ok") return "Healthy";',
|
||||
'if (status === "warning") return "Needs attention";',
|
||||
'if (status === "error") return "Action required";',
|
||||
'return "Not set up";',
|
||||
)
|
||||
assert all(label in page for label in labels)
|
||||
assert 'group: "desktop-foundation"' in page
|
||||
assert 'group: "input-media"' in page
|
||||
assert 'group: "integrations"' in page
|
||||
assert 'group: "panama-tools"' in page
|
||||
assert 'SettingsCard {' in page and 'SettingsCard {' in summary
|
||||
assert 'activeFocusOnTab: enabled' in summary
|
||||
assert 'Keys.onReturnPressed' in summary and 'Keys.onSpacePressed' in summary
|
||||
assert 'activeFocusOnTab: enabled' in row
|
||||
assert 'Keys.onReturnPressed' in row and 'Keys.onSpacePressed' in row
|
||||
assert 'border.width: activeFocus ? 2 : 1' in summary
|
||||
assert 'border.width: activeFocus ? 2 : 1' in row
|
||||
assert 'Health.diagnosticUnavailable ? "Retry"' in summary
|
||||
assert 'Health.lastCopyResult' in summary
|
||||
assert 'pendingConfirmation' in page
|
||||
assert 'ddc-permissions' in page
|
||||
PY
|
||||
|
||||
fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":2,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false}},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true}},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
|
||||
|
||||
state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)"
|
||||
config_path="$state_home/quickshell"
|
||||
harness="$config_path/health-ui-harness.qml"
|
||||
helper="$state_home/panama-doctor"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
fixture_home="$state_home/home"
|
||||
mkdir -p "$fixture_home"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
|
||||
cat >"$helper" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "--repair" ]]; then
|
||||
sleep 0.35
|
||||
printf '{"schemaVersion":1,"checkId":"%s","accepted":true,"exitCode":7,"message":"The authored repair failed."}\n' "$2"
|
||||
exit 7
|
||||
fi
|
||||
sleep 1.5
|
||||
printf '%s\n' "$PANAMA_HEALTH_FIXTURE"
|
||||
EOF
|
||||
chmod +x "$helper"
|
||||
|
||||
cat >"$harness" <<'QML'
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import "modules/bar" as BarModule
|
||||
import qs.config
|
||||
import qs.modules.settings
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
Component.onCompleted: {
|
||||
Health.startupScanEnabled = false;
|
||||
ShellState.settingsPage = "services";
|
||||
Health.consumeSnapshot(Quickshell.env("PANAMA_HEALTH_FIXTURE"), 100);
|
||||
}
|
||||
|
||||
FloatingWindow {
|
||||
title: "Panama Health UI Contract"
|
||||
visible: true
|
||||
implicitWidth: 980
|
||||
implicitHeight: 820
|
||||
|
||||
SettingsShell {
|
||||
id: settingsShell
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
BarModule.HealthIndicator {
|
||||
id: healthIndicator
|
||||
objectName: "health-indicator"
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "health-ui-test"
|
||||
|
||||
function state(): string {
|
||||
return JSON.stringify(settingsShell.healthDiagnostics);
|
||||
}
|
||||
|
||||
function request(id: string): bool {
|
||||
return settingsShell.requestHealthAction(id);
|
||||
}
|
||||
|
||||
function indicatorMode(mode: string): bool {
|
||||
const checkStatus = mode === "healthy" ? "ok" : (mode === "unconfigured" ? "unconfigured" : mode);
|
||||
const overallStatus = mode === "warning" || mode === "error" ? mode : "healthy";
|
||||
const snapshot = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-08-18T12:00:00Z",
|
||||
summary: {
|
||||
status: overallStatus,
|
||||
healthy: mode === "healthy" ? 1 : 0,
|
||||
warnings: mode === "warning" ? 1 : 0,
|
||||
errors: mode === "error" ? 1 : 0,
|
||||
unconfigured: mode === "unconfigured" ? 1 : 0
|
||||
},
|
||||
context: { session: "hyprland", versions: [] },
|
||||
checks: [{
|
||||
id: "desktop.vicinae",
|
||||
group: "desktop-foundation",
|
||||
title: "Vicinae",
|
||||
status: checkStatus,
|
||||
detail: "Fixture observation."
|
||||
}]
|
||||
};
|
||||
return Health.consumeSnapshot(JSON.stringify(snapshot), Health.acceptedGeneration + 1);
|
||||
}
|
||||
|
||||
function indicatorState(): string {
|
||||
return JSON.stringify({
|
||||
visible: healthIndicator.visible,
|
||||
width: healthIndicator.width,
|
||||
implicitWidth: healthIndicator.implicitWidth,
|
||||
issueCount: healthIndicator.issueCount,
|
||||
statusText: healthIndicator.statusText,
|
||||
accessibleLabel: healthIndicator.accessibleLabel,
|
||||
tooltipText: healthIndicator.tooltipText,
|
||||
warningTone: healthIndicator.tone === Theme.warn,
|
||||
errorTone: healthIndicator.tone === Theme.danger,
|
||||
activeFocusOnTab: healthIndicator.activeFocusOnTab
|
||||
});
|
||||
}
|
||||
|
||||
function activateIndicator(): string {
|
||||
ShellState.settingsPage = "home";
|
||||
ShellState.settingsOpen = false;
|
||||
const generation = Health.generation;
|
||||
healthIndicator.activated();
|
||||
return JSON.stringify({
|
||||
page: ShellState.settingsPage,
|
||||
settingsOpen: ShellState.settingsOpen,
|
||||
refreshRequested: Health.generation > generation || Health.queuedRefresh
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
run() {
|
||||
PANAMA_HEALTH_FIXTURE="$fixture" PANAMA_HEALTH_HELPER="$helper" \
|
||||
HOME="$fixture_home" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
harness_pid=""
|
||||
cleanup() {
|
||||
if [[ -n "$harness_pid" ]]; then
|
||||
kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$harness_pid" >/dev/null 2>&1 || break
|
||||
sleep 0.1
|
||||
done
|
||||
else
|
||||
run kill >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$state_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PANAMA_HEALTH_FIXTURE="$fixture" PANAMA_HEALTH_HELPER="$helper" \
|
||||
HOME="$fixture_home" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" --daemonize >"$shell_log" 2>&1
|
||||
for _ in $(seq 1 40); do
|
||||
harness_pid="$(qs list --all 2>/dev/null | awk -v path="$harness" '
|
||||
/Process ID:/ { pid = $3 }
|
||||
index($0, "Config path: " path) { print pid; exit }
|
||||
')"
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
for _ in $(seq 1 60); do
|
||||
run ipc show 2>/dev/null | rg -q '^target health-ui-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target health-ui-test$' \
|
||||
|| { sed -n '1,200p' "$shell_log" >&2; fail 'isolated fixture did not start'; }
|
||||
|
||||
checking_state="$(run ipc call health-ui-test state)"
|
||||
jq -e '
|
||||
.renderedRows == [
|
||||
{objectName:"health-check-row:issue:desktop.vicinae", id:"desktop.vicinae", section:"issue", statusText:"Needs attention"},
|
||||
{objectName:"health-check-row:issue:desktop.quickshell", id:"desktop.quickshell", section:"issue", statusText:"Action required"},
|
||||
{objectName:"health-check-row:issue:integration.calendar", id:"integration.calendar", section:"issue", statusText:"Needs attention"},
|
||||
{objectName:"health-check-row:quiet:input.pipewire", id:"input.pipewire", section:"quiet", statusText:"Healthy"},
|
||||
{objectName:"health-check-row:quiet:integration.bluebubbles", id:"integration.bluebubbles", section:"quiet", statusText:"Not set up"},
|
||||
{objectName:"health-check-row:quiet:panama.caffeine", id:"panama.caffeine", section:"quiet", statusText:"Healthy"}
|
||||
]
|
||||
and (.renderedRows | map(.id) | length) == 6
|
||||
and (.renderedRows | map(.id) | unique | length) == 6
|
||||
and .emptyQuietGroups == ["desktop-foundation"]
|
||||
and .fedoraHandoffs == [
|
||||
{id:"users", label:"Users", action:"Open users"},
|
||||
{id:"sharing", label:"Sharing", action:"Open sharing"},
|
||||
{id:"color", label:"Colour profiles", action:"Open colour"},
|
||||
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
|
||||
]
|
||||
and .summaryHeight == 126
|
||||
and (.rowHeights | length) == 6
|
||||
and (.rowHeights | all(. >= 62))
|
||||
and .checking == true
|
||||
and .checkingText == "Checking…"
|
||||
' >/dev/null <<<"$checking_state" || fail "checking fixture did not render the approved state: $checking_state"
|
||||
|
||||
checking_heights="$(jq -c .rowHeights <<<"$checking_state")"
|
||||
for _ in $(seq 1 40); do
|
||||
settled_state="$(run ipc call health-ui-test state)"
|
||||
[[ "$(jq -r .checking <<<"$settled_state")" == "false" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
sleep 0.1
|
||||
settled_state="$(run ipc call health-ui-test state)"
|
||||
[[ "$(jq -c .rowHeights <<<"$settled_state")" == "$checking_heights" ]] \
|
||||
|| fail 'row geometry changed after refresh settled'
|
||||
jq -e '
|
||||
(.focusChain | index("health-copy-report-button")) != null
|
||||
and (.focusChain | index("health-refresh-button")) != null
|
||||
and (.focusChain | any(startswith("health-row-action:")))
|
||||
' >/dev/null <<<"$settled_state" || fail "actual focus-chain traversal does not reach hero and row actions: $settled_state"
|
||||
|
||||
settled_heights="$(jq -c .rowHeights <<<"$settled_state")"
|
||||
[[ "$(run ipc call health-ui-test request desktop.vicinae)" == "true" ]] \
|
||||
|| fail 'inline repair fixture could not be requested'
|
||||
working_repair_state="$(run ipc call health-ui-test state)"
|
||||
jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Working…"' \
|
||||
>/dev/null <<<"$working_repair_state" || fail "repair row did not show Working state: $working_repair_state"
|
||||
[[ "$(jq -c .rowHeights <<<"$working_repair_state")" == "$settled_heights" ]] \
|
||||
|| fail 'repair Working state changed row geometry'
|
||||
for _ in $(seq 1 40); do
|
||||
failed_repair_state="$(run ipc call health-ui-test state)"
|
||||
jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repair failed"' \
|
||||
>/dev/null <<<"$failed_repair_state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repair failed"' \
|
||||
>/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state"
|
||||
[[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \
|
||||
|| fail 'repair failure changed row geometry'
|
||||
[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 6 ]] \
|
||||
|| fail 'repair state duplicated a health action row'
|
||||
for _ in $(seq 1 40); do
|
||||
failed_repair_state="$(run ipc call health-ui-test state)"
|
||||
[[ "$(jq -r .checking <<<"$failed_repair_state")" == "false" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$(jq -r .checking <<<"$failed_repair_state")" == "false" ]] \
|
||||
|| fail 'post-repair scan did not settle before the next action'
|
||||
|
||||
[[ "$(run ipc call health-ui-test request desktop.quickshell)" == "true" ]] \
|
||||
|| fail 'restart confirmation fixture could not be requested'
|
||||
confirmation_state="$(run ipc call health-ui-test state)"
|
||||
jq -e '
|
||||
.confirmationVisible == true
|
||||
and .confirmationId == "desktop.quickshell"
|
||||
and (.activatedRows | index("health-check-row:issue:desktop.quickshell")) != null
|
||||
and (.activatedRows | map(select(endswith(":desktop.quickshell"))) | length) == 1
|
||||
' \
|
||||
>/dev/null <<<"$confirmation_state" || fail 'Quickshell restart did not open confirmation sheet'
|
||||
|
||||
for hidden_mode in healthy unconfigured; do
|
||||
[[ "$(run ipc call health-ui-test indicatorMode "$hidden_mode")" == "true" ]] \
|
||||
|| fail "$hidden_mode indicator fixture was rejected"
|
||||
hidden_state="$(run ipc call health-ui-test indicatorState)"
|
||||
jq -e '
|
||||
.visible == false
|
||||
and .width == 0
|
||||
and .implicitWidth == 0
|
||||
and .issueCount == 0
|
||||
' >/dev/null <<<"$hidden_state" \
|
||||
|| fail "$hidden_mode state reserved bar space: $hidden_state"
|
||||
done
|
||||
|
||||
[[ "$(run ipc call health-ui-test indicatorMode warning)" == "true" ]] \
|
||||
|| fail 'warning indicator fixture was rejected'
|
||||
warning_state="$(run ipc call health-ui-test indicatorState)"
|
||||
jq -e '
|
||||
.visible == true
|
||||
and .width > 0
|
||||
and .implicitWidth > 0
|
||||
and .issueCount == 1
|
||||
and .statusText == "1 system health issue"
|
||||
and .accessibleLabel == "System Health: 1 issue needs attention"
|
||||
and .tooltipText == "System Health: 1 issue needs attention"
|
||||
and .warningTone == true
|
||||
and .errorTone == false
|
||||
and .activeFocusOnTab == true
|
||||
' >/dev/null <<<"$warning_state" || fail "warning indicator is not the approved amber accessible capsule: $warning_state"
|
||||
|
||||
[[ "$(run ipc call health-ui-test indicatorMode error)" == "true" ]] \
|
||||
|| fail 'error indicator fixture was rejected'
|
||||
error_state="$(run ipc call health-ui-test indicatorState)"
|
||||
jq -e '
|
||||
.visible == true
|
||||
and .issueCount == 1
|
||||
and .accessibleLabel == "System Health: 1 issue requires action"
|
||||
and .tooltipText == "System Health: 1 issue requires action"
|
||||
and .warningTone == false
|
||||
and .errorTone == true
|
||||
' >/dev/null <<<"$error_state" || fail "error indicator is not the approved red accessible capsule: $error_state"
|
||||
|
||||
activation_state="$(run ipc call health-ui-test activateIndicator)"
|
||||
jq -e '
|
||||
.page == "services"
|
||||
and .settingsOpen == true
|
||||
and .refreshRequested == true
|
||||
' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state"
|
||||
|
||||
if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then
|
||||
fail 'isolated fixture emitted QML errors or warnings'
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'health UI contract: PASS\n'
|
||||
@@ -130,6 +130,134 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_status_falls_back_to_paired_dbus_device_when_cli_is_empty(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
device_path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, "0 devices found\n", "")
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {device_path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
values = {
|
||||
"name": 's "Fixture iPhone"\n',
|
||||
"type": 's "phone"\n',
|
||||
"isPaired": "b true\n",
|
||||
"isReachable": "b false\n",
|
||||
"supportedPlugins": (
|
||||
'as 3 "kdeconnect_share" "kdeconnect_clipboard" '
|
||||
'"kdeconnect_findmyphone"\n'
|
||||
),
|
||||
}
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(
|
||||
bridge.collect_status(runner),
|
||||
{
|
||||
"available": True,
|
||||
"devices": [
|
||||
{
|
||||
"id": device_id,
|
||||
"name": "Fixture iPhone",
|
||||
"type": "phone",
|
||||
"paired": True,
|
||||
"reachable": False,
|
||||
"actions": ["clipboard", "ring", "share"],
|
||||
}
|
||||
],
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
|
||||
def test_dbus_plugin_timeout_keeps_device_with_no_actions(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
values = {
|
||||
"name": 's "Fixture iPhone"\n',
|
||||
"type": 's "phone"\n',
|
||||
"isPaired": "b true\n",
|
||||
"isReachable": "b false\n",
|
||||
}
|
||||
if command[-1] == "supportedPlugins":
|
||||
raise subprocess.TimeoutExpired(command, 8)
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(bridge.dbus_devices(runner)[0]["actions"], [])
|
||||
|
||||
def test_cli_device_plugin_timeout_fails_closed(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
raise subprocess.TimeoutExpired(command, 8)
|
||||
|
||||
status = bridge.collect_status(runner)
|
||||
|
||||
self.assertEqual(status["devices"][0]["type"], "phone")
|
||||
self.assertEqual(status["devices"][0]["actions"], [])
|
||||
|
||||
def test_dbus_inventory_excludes_unpaired_peers(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
values = {
|
||||
"name": 's "Nearby Stranger"\n',
|
||||
"type": 's "phone"\n',
|
||||
"isPaired": "b false\n",
|
||||
"isReachable": "b true\n",
|
||||
}
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(bridge.dbus_devices(runner), [])
|
||||
|
||||
def test_status_merges_paired_dbus_device_missing_from_cli(self) -> None:
|
||||
cli_id = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
dbus_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
listing = f"- Fixture Laptop: {cli_id} (paired and reachable)\n"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]:
|
||||
path = f"/modules/kdeconnect/devices/{dbus_id}"
|
||||
return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "")
|
||||
if command[:3] == ["busctl", "--user", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if command[:3] == ["busctl", "--user", "get-property"]:
|
||||
is_dbus_device = dbus_id in command[4]
|
||||
values = {
|
||||
"name": 's "Fixture iPhone"\n',
|
||||
"type": 's "phone"\n' if is_dbus_device else 's "desktop"\n',
|
||||
"isPaired": "b true\n",
|
||||
"isReachable": "b false\n",
|
||||
"supportedPlugins": "as 0\n",
|
||||
}
|
||||
return subprocess.CompletedProcess(command, 0, values[command[-1]], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
status = bridge.collect_status(runner)
|
||||
|
||||
self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -148,6 +148,9 @@ assert_commands 'qs <ipc> <call> <overview> <open>'
|
||||
run_action settings
|
||||
assert_commands 'qs <ipc> <call> <settings> <open>'
|
||||
|
||||
run_action health
|
||||
assert_commands 'qs <ipc> <call> <health> <open>'
|
||||
|
||||
run_action dnd
|
||||
assert_commands $'qs <ipc> <call> <notifications> <dnd>\npanama-osd <message> <notifications-disabled-symbolic> <Do Not Disturb On>'
|
||||
|
||||
@@ -191,11 +194,13 @@ assert_commands $'qs <kill>\nquickshell <--daemonize>\nqs <ipc> <show>\npanama-o
|
||||
PANAMA_ACTION_TEST_FAIL_KILL=true run_action restart-shell
|
||||
assert_commands $'qs <kill>\nquickshell <--daemonize>\nqs <ipc> <show>\npanama-osd <message> <view-refresh-symbolic> <Panama Restarted>'
|
||||
|
||||
usage_output="$work/usage.txt"
|
||||
if HOME="$work/home" PATH="$fake_bin:$PATH" PANAMA_ACTION_HELPER_DIR="$fake_bin" \
|
||||
PANAMA_ACTION_CONFIRM_ATTEMPTS=1 PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
|
||||
"$dispatcher" definitely-not-an-action >/dev/null 2>&1; then
|
||||
"$dispatcher" definitely-not-an-action > /dev/null 2>"$usage_output"; then
|
||||
fail 'unknown action was accepted'
|
||||
fi
|
||||
rg -q '\bhealth\b' "$usage_output" || fail 'health is missing from dispatcher usage'
|
||||
|
||||
: >"$command_log"
|
||||
if PANAMA_ACTION_TEST_FAIL_QS=true HOME="$work/home" PATH="$fake_bin:$PATH" \
|
||||
|
||||
@@ -25,8 +25,16 @@ trap cleanup EXIT
|
||||
if rg -q 'setup/scripts/link-vicinae-scripts' "$dotfile_installer"; then
|
||||
fail 'link-dotfiles also invokes the command installer'
|
||||
fi
|
||||
rg -Fq 'do "$script"; done' "$top_level_installer" \
|
||||
|| fail 'top-level installer sources setup scripts into one shared shell'
|
||||
# Each setup stage must run in its OWN process, so strict-shell options and
|
||||
# helper variables stay local to the script that owns them. What matters is
|
||||
# that the stages are executed rather than sourced -- this previously matched
|
||||
# the literal one-liner `do "$script"; done`, which failed the moment the loop
|
||||
# gained error reporting and spanned more than one line, despite the property
|
||||
# it cares about being unchanged.
|
||||
rg -q '(^|[^a-z-])(\.|source)\s+[^;]*setup/scripts' "$top_level_installer" \
|
||||
&& fail 'top-level installer sources setup scripts into one shared shell'
|
||||
rg -q '"\$script"' "$top_level_installer" \
|
||||
|| fail 'top-level installer does not execute the setup scripts'
|
||||
|
||||
mkdir -p "$data_dir/scripts/panama" "$fake_bin"
|
||||
printf 'user-owned\n' >"$data_dir/scripts/panama/keep.sh"
|
||||
|
||||
@@ -24,6 +24,7 @@ declare -A expected=(
|
||||
[open-clipboard.sh]=clipboard
|
||||
[open-mission-control.sh]=overview
|
||||
[open-settings.sh]=settings
|
||||
[check-system-health.sh]=health
|
||||
[toggle-dnd.sh]=dnd
|
||||
[toggle-caffeine.sh]=caffeine
|
||||
[toggle-night-light.sh]=night-light
|
||||
@@ -72,6 +73,17 @@ for script_name in "${!expected[@]}"; do
|
||||
grep -Fxq '# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg' "$script" \
|
||||
|| fail "$script_name does not use the Panama application identity"
|
||||
|
||||
if [[ $script_name == check-system-health.sh ]]; then
|
||||
[[ $title == 'Panama: Check System Health' ]] \
|
||||
|| fail "health command has the wrong title: $title"
|
||||
grep -Fxq '# @vicinae.schemaVersion 1' "$script" \
|
||||
|| fail 'health command does not use schema version 1'
|
||||
grep -Fxq '# @vicinae.keywords ["health", "doctor", "repair", "services"]' "$script" \
|
||||
|| fail 'health command has the wrong search vocabulary'
|
||||
grep -Fxq 'exec "$HOME/.config/quickshell/scripts/panama-action" health' "$script" \
|
||||
|| fail 'health command bypasses the stable dispatcher path'
|
||||
fi
|
||||
|
||||
: >"$dispatch_log"
|
||||
HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script"
|
||||
dispatched="$(cat "$dispatch_log")"
|
||||
|
||||
Executable
+735
@@ -0,0 +1,735 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The doctor is deliberately exercised through its command boundary. The
|
||||
# fixture commands include sensitive-looking output so this test proves the
|
||||
# report only retains explicitly parsed, non-sensitive observations.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
||||
fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
|
||||
|
||||
fail() {
|
||||
printf 'panama doctor contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
|
||||
child_pids=()
|
||||
cleanup() {
|
||||
for pid in "${child_pids[@]}"; do
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
home="$fixture/home"
|
||||
config_home="$home/.config"
|
||||
state_home="$home/.local/state"
|
||||
runtime_dir="$fixture/runtime"
|
||||
bin_dir="$fixture/bin"
|
||||
data_home="$home/.local/share"
|
||||
|
||||
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts"
|
||||
cp "$fixture_root/bin/"* "$bin_dir/"
|
||||
chmod +x "$bin_dir"/*
|
||||
|
||||
# These are intentionally tiny stand-ins for authored executable probes. The
|
||||
# named fixture scripts above cover probes whose output needs branch coverage.
|
||||
for tool in hyprctl wl-paste grim tesseract kitty nextcloud rustdesk kdeconnect-cli; do
|
||||
cat >"$bin_dir/$tool" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
case "${0##*/}" in
|
||||
hyprctl) printf 'Hyprland 0.50.0\n' ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$bin_dir/$tool"
|
||||
done
|
||||
|
||||
cat >"$bin_dir/flatpak" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" \
|
||||
&& "${PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES:-installed}" == "installed" ]]; then
|
||||
printf 'BlueBubbles fixture-secret-token\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$bin_dir/flatpak"
|
||||
|
||||
cat >"$bin_dir/calendar-agenda" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
if [[ "${1:-}" != "probe" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
case "${PANAMA_DOCTOR_FIXTURE_CALENDAR:-ready}" in
|
||||
ready) printf '{"eds":true,"sourceRegistry":true,"enabledSources":2,"event":"fixture clipboard body"}\n' ;;
|
||||
malformed) printf 'calendar AA:BB:CC:DD:EE:FF\n' ;;
|
||||
timeout) /usr/bin/sleep 2; printf '{"enabledSources":2}\n' ;;
|
||||
*) printf '{"eds":true,"sourceRegistry":true,"enabledSources":0}\n' ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$bin_dir/calendar-agenda"
|
||||
|
||||
cat >"$bin_dir/panama-brightness" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
case "${PANAMA_DOCTOR_FIXTURE_BRIGHTNESS:-ready}" in
|
||||
ready) printf '{"displays":[{"connector":"AA:BB:CC:DD:EE:FF"}],"error":""}\n' ;;
|
||||
denied) printf '{"displays":[],"error":"fixture-secret-token"}\n' ;;
|
||||
malformed) printf 'fixture clipboard body\n' ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$bin_dir/panama-brightness"
|
||||
|
||||
mkdir -p "$config_home/autostart"
|
||||
touch "$config_home/autostart/nextcloud.desktop"
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
ln -s "$repo_dir/config/dot/$name" "$config_home/$name"
|
||||
done
|
||||
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama"
|
||||
|
||||
run_doctor() {
|
||||
HOME="$home" \
|
||||
PATH="$bin_dir" \
|
||||
XDG_CURRENT_DESKTOP=Hyprland \
|
||||
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
|
||||
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
|
||||
PANAMA_DOCTOR_ROOT="$repo_dir" \
|
||||
PANAMA_DOCTOR_HOME="$home" \
|
||||
PANAMA_DOCTOR_CONFIG_HOME="$config_home" \
|
||||
PANAMA_DOCTOR_STATE_HOME="$state_home" \
|
||||
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir" \
|
||||
PANAMA_DOCTOR_TIMEOUT="${PANAMA_DOCTOR_TIMEOUT:-0.2}" \
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
|
||||
assert_schema_and_redaction() {
|
||||
local snapshot="$1"
|
||||
jq -e '.schemaVersion == 1
|
||||
and (.generatedAt | type == "string")
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 25)
|
||||
and ([.checks[].id] | unique | length == 25)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
||||
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
||||
|| fail "checks are not in the authored order"
|
||||
! grep -Fq 'fixture-secret-token' <<<"$snapshot" \
|
||||
|| fail 'report exposed a fixture secret'
|
||||
! grep -Fq 'fixture clipboard body' <<<"$snapshot" \
|
||||
|| fail 'report exposed clipboard or calendar content'
|
||||
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot" \
|
||||
|| fail 'report exposed a device address'
|
||||
}
|
||||
|
||||
check_status() {
|
||||
local snapshot="$1" id="$2" expected="$3"
|
||||
[[ "$(jq -r --arg id "$id" '.checks[] | select(.id == $id) | .status' <<<"$snapshot")" == "$expected" ]] \
|
||||
|| fail "$id did not report $expected: $snapshot"
|
||||
}
|
||||
|
||||
snapshot="$(run_doctor --json)"
|
||||
assert_schema_and_redaction "$snapshot"
|
||||
check_status "$snapshot" panama.vicinae-commands ok
|
||||
|
||||
# The diagnostic follows the actual installer contract: the scripts parent is
|
||||
# a directory and only its Panama child is an authored link.
|
||||
rm "$data_home/vicinae/scripts/panama"
|
||||
unlinked_vicinae="$(run_doctor --json)"
|
||||
check_status "$unlinked_vicinae" panama.vicinae-commands warning
|
||||
PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_home/vicinae" HOME="$home" \
|
||||
PATH="$bin_dir:/usr/bin" "$repo_dir/setup/scripts/link-vicinae-scripts"
|
||||
relinked_vicinae="$(run_doctor --json)"
|
||||
check_status "$relinked_vicinae" panama.vicinae-commands ok
|
||||
[[ -L "$data_home/vicinae/scripts/panama" \
|
||||
&& "$(readlink "$data_home/vicinae/scripts/panama")" == "$repo_dir/config/local/share/vicinae/scripts" ]] \
|
||||
|| fail 'authored Vicinae helper did not create the diagnosed child link'
|
||||
|
||||
# A healthy systemd-backed service stays healthy.
|
||||
check_status "$snapshot" desktop.hyprpaper ok
|
||||
|
||||
# Arbitrary parent environment values are not propagated into probes.
|
||||
sealed_environment="$(PANAMA_DOCTOR_FIXTURE_PROBE_SECRET=fixture-secret-token run_doctor --json)"
|
||||
assert_schema_and_redaction "$sealed_environment"
|
||||
check_status "$sealed_environment" desktop.hyprpaper ok
|
||||
|
||||
# An OS-level launch failure is contained as a check result, never a failed
|
||||
# doctor invocation or a partial snapshot.
|
||||
chmod 0644 "$bin_dir/systemctl"
|
||||
if ! launch_failure="$(run_doctor --json)"; then
|
||||
chmod +x "$bin_dir/systemctl"
|
||||
fail 'launch failure prevented the doctor from emitting JSON'
|
||||
fi
|
||||
chmod +x "$bin_dir/systemctl"
|
||||
assert_schema_and_redaction "$launch_failure"
|
||||
check_status "$launch_failure" desktop.hyprpaper error
|
||||
|
||||
# A missing required executable is an error rather than a crash.
|
||||
mv "$bin_dir/qs" "$bin_dir/qs.off"
|
||||
missing_qs="$(run_doctor --json)"
|
||||
check_status "$missing_qs" desktop.quickshell error
|
||||
mv "$bin_dir/qs.off" "$bin_dir/qs"
|
||||
|
||||
# Optional integrations stay neutral until the user configures them.
|
||||
rm "$config_home/autostart/nextcloud.desktop"
|
||||
unconfigured_nextcloud="$(run_doctor --json)"
|
||||
check_status "$unconfigured_nextcloud" integration.nextcloud unconfigured
|
||||
touch "$config_home/autostart/nextcloud.desktop"
|
||||
|
||||
# A configured integration that stopped is actionable with an authored label,
|
||||
# never an application name or command derived from probe output.
|
||||
stopped_nextcloud="$(PANAMA_DOCTOR_FIXTURE_STOPPED=nextcloud.service run_doctor --json)"
|
||||
check_status "$stopped_nextcloud" integration.nextcloud warning
|
||||
jq -e '.checks[] | select(.id == "integration.nextcloud")
|
||||
| .action == {kind:"open", label:"Open Nextcloud", confirm:false}' \
|
||||
>/dev/null <<<"$stopped_nextcloud" || fail 'Nextcloud action was not authored'
|
||||
|
||||
# DDC errors are classified without retaining connectors or bus addresses.
|
||||
denied_brightness="$(PANAMA_DOCTOR_FIXTURE_BRIGHTNESS=denied run_doctor --json)"
|
||||
check_status "$denied_brightness" input.brightness warning
|
||||
jq -e '.checks[] | select(.id == "input.brightness")
|
||||
| .action == {kind:"instructions", label:"View setup instructions", confirm:false, target:"ddc-permissions"}' \
|
||||
>/dev/null <<<"$denied_brightness" || fail 'DDC instructions were not authored'
|
||||
|
||||
# A bounded probe timeout becomes a result, never a helper failure.
|
||||
timed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=timeout PANAMA_DOCTOR_TIMEOUT=0.05 run_doctor --json)"
|
||||
check_status "$timed_calendar" integration.calendar warning
|
||||
jq -e '.checks[] | select(.id == "integration.calendar")
|
||||
| .action == {kind:"open", label:"Open Date & Time", confirm:false, target:"datetime"}' \
|
||||
>/dev/null <<<"$timed_calendar" || fail 'calendar action was not authored'
|
||||
|
||||
# Exact Panama/Caffeine inhibitor rows detect duplicates without exposing PIDs.
|
||||
duplicated_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=duplicate run_doctor --json)"
|
||||
check_status "$duplicated_caffeine" panama.caffeine warning
|
||||
jq -e '.checks[] | select(.id == "panama.caffeine")
|
||||
| .action == {kind:"repair", label:"Release duplicate inhibitors", confirm:false}' \
|
||||
>/dev/null <<<"$duplicated_caffeine" || fail 'Caffeine repair action was not authored'
|
||||
! jq -r '.checks[] | select(.id == "panama.caffeine") | .detail' <<<"$duplicated_caffeine" | grep -Eq '[0-9]{3,}' \
|
||||
|| fail 'Caffeine detail exposed inhibitor PIDs'
|
||||
|
||||
# Process counts use only exact authored names and never expose command lines or PIDs.
|
||||
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:duplicate run_doctor --json)"
|
||||
check_status "$duplicated_processes" panama.processes warning
|
||||
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
||||
|| fail 'process detail exposed a PID'
|
||||
|
||||
missing_quickshell_process="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:missing run_doctor --json)"
|
||||
check_status "$missing_quickshell_process" panama.processes error
|
||||
|
||||
# Invalid output for a non-Quickshell authored process is not a normal zero
|
||||
# count that can be hidden by the running Quickshell process.
|
||||
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
||||
check_status "$malformed_processes" panama.processes warning
|
||||
|
||||
# Panama/Caffeine-shaped rows that do not satisfy the fixed inhibitor schema
|
||||
# are unavailable rather than reported as a healthy no-inhibitor state.
|
||||
malformed_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=malformed run_doctor --json)"
|
||||
check_status "$malformed_caffeine" panama.caffeine warning
|
||||
|
||||
# A decoding error raised inside a concurrent probe is converted to a complete
|
||||
# snapshot rather than escaping from Future.result().
|
||||
if ! invalid_probe="$(PANAMA_DOCTOR_FIXTURE_BUS=invalid-utf8 run_doctor --json)"; then
|
||||
fail 'unexpected probe exception prevented the doctor from emitting JSON'
|
||||
fi
|
||||
assert_schema_and_redaction "$invalid_probe"
|
||||
check_status "$invalid_probe" desktop.portals warning
|
||||
|
||||
# Configured Home Assistant failures route to the exact authored Settings page.
|
||||
rm "$config_home/quickshell"
|
||||
mkdir -p "$config_home/quickshell/scripts"
|
||||
home_assistant_failure="$(run_doctor --json)"
|
||||
check_status "$home_assistant_failure" integration.home-assistant warning
|
||||
jq -e '.checks[] | select(.id == "integration.home-assistant")
|
||||
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \
|
||||
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone'
|
||||
|
||||
# Invalid probe text is contained in its own check and never copied to JSON.
|
||||
malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)"
|
||||
check_status "$malformed_calendar" integration.calendar warning
|
||||
assert_schema_and_redaction "$malformed_calendar"
|
||||
|
||||
summary="$(run_doctor --summary)"
|
||||
[[ "$summary" =~ ^Panama\ system\ health:\ (healthy|warning|error)\ \([0-9]+\ ok,\ [0-9]+\ warnings,\ [0-9]+\ errors,\ [0-9]+\ unconfigured\)$ ]] \
|
||||
|| fail "summary is not concise: $summary"
|
||||
|
||||
# Repairs run against a second, disposable Panama root. Every process boundary
|
||||
# records its argv, and every filesystem assertion is confined to this fixture.
|
||||
repair_root="$home/.local/share/Panama"
|
||||
repair_log="$runtime_dir/repair.log"
|
||||
mkdir -p "$repair_root/config/dot" "$repair_root/config/local/share/vicinae/scripts" \
|
||||
"$repair_root/setup/scripts"
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
mkdir -p "$repair_root/config/dot/$name"
|
||||
done
|
||||
cp "$repo_dir/setup/scripts/link-vicinae-scripts" "$repair_root/setup/scripts/link-vicinae-scripts"
|
||||
|
||||
mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe"
|
||||
cat >"$bin_dir/systemctl" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "--user" && "${2:-}" == "restart" ]]; then
|
||||
printf 'systemctl' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
[[ ! -e "$XDG_RUNTIME_DIR/fail-repair" ]] || exit 5
|
||||
exit 0
|
||||
fi
|
||||
exec "${0%/*}/systemctl-probe" "$@"
|
||||
EOF
|
||||
|
||||
cat >"$bin_dir/panama-action" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'panama-action' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
EOF
|
||||
|
||||
cat >"$bin_dir/systemd-inhibit" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
|
||||
count_file="$XDG_RUNTIME_DIR/caffeine-list-count"
|
||||
count=0
|
||||
[[ ! -f "$count_file" ]] || read -r count <"$count_file"
|
||||
count=$((count + 1))
|
||||
printf '%s\n' "$count" >"$count_file"
|
||||
read -r preserved duplicate <"$XDG_RUNTIME_DIR/caffeine-pids"
|
||||
uid="$(/usr/bin/id -u)"
|
||||
mode="$(<"$XDG_RUNTIME_DIR/caffeine-mode")"
|
||||
if [[ "$mode" == disappear && "$count" -ge 2 ]]; then
|
||||
/usr/bin/touch "$XDG_RUNTIME_DIR/release-disappearing-pid"
|
||||
for _ in $(/usr/bin/seq 1 100); do
|
||||
[[ ! -e "/proc/$duplicate" ]] && break
|
||||
/usr/bin/sleep 0.01
|
||||
done
|
||||
fi
|
||||
preserved_comm=systemd-inhibit
|
||||
if [[ "$mode" == preserve-altered && "$count" -ge 2 ]]; then
|
||||
preserved_comm=changed-command
|
||||
fi
|
||||
printf 'Panama %s fixture-user %s %s sleep:idle Caffeine block\n' "$uid" "$preserved" "$preserved_comm"
|
||||
if [[ "$mode" != multiplicity || "$count" -lt 2 ]]; then
|
||||
printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved"
|
||||
fi
|
||||
if [[ "$mode" == altered && "$count" -ge 2 ]]; then
|
||||
printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Other block\n' "$uid" "$duplicate"
|
||||
else
|
||||
printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$duplicate"
|
||||
fi
|
||||
printf 'Other %s fixture-user 4999 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
printf 'Panama 99999 fixture-user 4998 systemd-inhibit sleep:idle Caffeine block\n'
|
||||
printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$uid"
|
||||
printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid"
|
||||
EOF
|
||||
|
||||
chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \
|
||||
"$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts"
|
||||
|
||||
run_repair() {
|
||||
HOME="$home" \
|
||||
PATH="$bin_dir:/usr/bin" \
|
||||
XDG_CURRENT_DESKTOP=Hyprland \
|
||||
PANAMA_DOCTOR_ROOT="$repair_root" \
|
||||
PANAMA_DOCTOR_HOME="$home" \
|
||||
PANAMA_DOCTOR_CONFIG_HOME="$config_home" \
|
||||
PANAMA_DOCTOR_STATE_HOME="$state_home" \
|
||||
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \
|
||||
PANAMA_DOCTOR_TIMEOUT=0.2 \
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
repair_output=""
|
||||
repair_status=0
|
||||
invoke_repair() {
|
||||
set +e
|
||||
repair_output="$(run_repair --repair "$1" --json)"
|
||||
repair_status=$?
|
||||
set -e
|
||||
}
|
||||
|
||||
assert_repair_result() {
|
||||
local id="$1" accepted="$2" exit_code="$3"
|
||||
jq -e --arg id "$id" --argjson accepted "$accepted" --argjson exitCode "$exit_code" '
|
||||
(keys | sort) == ["accepted", "checkId", "exitCode", "message", "schemaVersion"]
|
||||
and .schemaVersion == 1
|
||||
and .checkId == $id
|
||||
and .accepted == $accepted
|
||||
and .exitCode == $exitCode
|
||||
and (.message | type == "string" and length > 0)
|
||||
' >/dev/null <<<"$repair_output" || fail "invalid repair result for $id: $repair_output"
|
||||
}
|
||||
|
||||
for repair_case in \
|
||||
'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'; do
|
||||
IFS='|' read -r repair_id executable arg1 arg2 arg3 <<<"$repair_case"
|
||||
: >"$repair_log"
|
||||
invoke_repair "$repair_id"
|
||||
[[ "$repair_status" == 0 ]] || fail "$repair_id returned $repair_status"
|
||||
assert_repair_result "$repair_id" true 0
|
||||
expected="$executable|$arg1"
|
||||
[[ -z "$arg2" ]] || expected+="|$arg2"
|
||||
[[ -z "$arg3" ]] || expected+="|$arg3"
|
||||
[[ "$(<"$repair_log")" == "$expected" ]] \
|
||||
|| fail "$repair_id argv was not exact: $(<"$repair_log")"
|
||||
done
|
||||
|
||||
# Known process failures still return complete JSON and preserve the command's
|
||||
# exit status for the QML state machine.
|
||||
touch "$runtime_dir/fail-repair"
|
||||
: >"$repair_log"
|
||||
invoke_repair desktop.vicinae
|
||||
rm "$runtime_dir/fail-repair"
|
||||
[[ "$repair_status" == 5 ]] || fail "failed repair returned $repair_status instead of 5"
|
||||
assert_repair_result desktop.vicinae true 5
|
||||
[[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \
|
||||
|| fail 'failed repair changed the authored argv'
|
||||
|
||||
# The real authored Vicinae helper converges the exact child link diagnosed by
|
||||
# panama-doctor under the isolated HOME.
|
||||
rm -f "$data_home/vicinae/scripts/panama"
|
||||
before_vicinae_repair="$(run_repair --json)"
|
||||
check_status "$before_vicinae_repair" panama.vicinae-commands warning
|
||||
invoke_repair panama.vicinae-commands
|
||||
[[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status"
|
||||
assert_repair_result panama.vicinae-commands true 0
|
||||
after_vicinae_repair="$(run_repair --json)"
|
||||
check_status "$after_vicinae_repair" panama.vicinae-commands ok
|
||||
[[ -L "$data_home/vicinae/scripts/panama" \
|
||||
&& "$(readlink "$data_home/vicinae/scripts/panama")" == "$repair_root/config/local/share/vicinae/scripts" ]] \
|
||||
|| fail 'Vicinae repair did not install the diagnosed child link'
|
||||
|
||||
# Runtime-link repair may replace only absent links or symlinks whose lexical
|
||||
# target proves Panama ownership. Every other object remains untouched.
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
path="$config_home/$name"
|
||||
if [[ -e "$path" || -L "$path" ]]; then
|
||||
mv "$path" "$fixture/pre-repair-$name"
|
||||
fi
|
||||
done
|
||||
ln -s "$repair_root/config/dot/hypr" "$config_home/hypr"
|
||||
correct_inode="$(stat -c %i "$config_home/hypr")"
|
||||
ln -s "$repair_root/config/dot/quickshell" "$config_home/uwsm"
|
||||
ln -s "$fixture/external-broken-link" "$config_home/vicinae"
|
||||
ln -s "$fixture/untouched" "$config_home/not-panama"
|
||||
: >"$repair_log"
|
||||
invoke_repair panama.runtime-links
|
||||
[[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status"
|
||||
assert_repair_result panama.runtime-links true 1
|
||||
[[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \
|
||||
|| fail 'correct runtime link changed'
|
||||
[[ "$(stat -c %i "$config_home/hypr")" == "$correct_inode" ]] \
|
||||
|| fail 'correct runtime link was replaced instead of left untouched'
|
||||
[[ -L "$config_home/quickshell" && "$(readlink "$config_home/quickshell")" == "$repair_root/config/dot/quickshell" ]] \
|
||||
|| fail 'absent quickshell link was not created'
|
||||
[[ -L "$config_home/uwsm" && "$(readlink "$config_home/uwsm")" == "$repair_root/config/dot/uwsm" ]] \
|
||||
|| fail 'provably Panama-owned stale link was not repaired'
|
||||
[[ -L "$config_home/vicinae" && "$(readlink "$config_home/vicinae")" == "$fixture/external-broken-link" ]] \
|
||||
|| fail 'external broken symlink was replaced'
|
||||
[[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \
|
||||
|| fail 'runtime-link repair touched an unauthored link name'
|
||||
[[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process'
|
||||
|
||||
# Regular files and directories also remain untouched.
|
||||
rm "$config_home/vicinae"
|
||||
rm "$config_home/uwsm"
|
||||
printf 'user-owned file\n' >"$config_home/uwsm"
|
||||
mkdir "$config_home/vicinae"
|
||||
invoke_repair panama.runtime-links
|
||||
[[ "$repair_status" == 1 ]] || fail 'file/directory blockers did not make repair incomplete'
|
||||
[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \
|
||||
|| fail 'runtime-link repair replaced a regular file'
|
||||
[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \
|
||||
|| fail 'runtime-link repair replaced a user-owned directory'
|
||||
|
||||
# An injected exchange failure occurs after the authored candidate symlink is
|
||||
# made; the original link must still be intact.
|
||||
/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/atomic-config" <<'PY' \
|
||||
|| fail 'atomic replacement failure did not preserve the original link'
|
||||
import importlib.util
|
||||
import importlib.machinery
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
doctor_path, root_text, config_text = sys.argv[1:]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_contract", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
root = Path(root_text)
|
||||
config_home = Path(config_text)
|
||||
config_home.mkdir(parents=True)
|
||||
destination = config_home / "hypr"
|
||||
original = root / "config/dot/quickshell"
|
||||
destination.symlink_to(original, target_is_directory=True)
|
||||
config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2)
|
||||
real_exchange = module.rename_exchange
|
||||
module.rename_exchange = lambda source, target: (_ for _ in ()).throw(OSError("fixture exchange failure"))
|
||||
try:
|
||||
result = module.repair_runtime_links(config)
|
||||
finally:
|
||||
module.rename_exchange = real_exchange
|
||||
assert result.exit_code == 1
|
||||
assert destination.is_symlink()
|
||||
assert os.readlink(destination) == str(original)
|
||||
assert not list(config_home.glob(".panama-link-*"))
|
||||
PY
|
||||
|
||||
# A deterministic swap at the ownership/replacement boundary must be detected
|
||||
# from the exchanged-out object and rolled back, preserving the external link.
|
||||
/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/toctou-config" "$fixture/external-race-target" <<'PY' \
|
||||
|| fail 'runtime-link exchange did not restore a boundary-swapped external link'
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
doctor_path, root_text, config_text, external_text = sys.argv[1:]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_toctou", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
root = Path(root_text)
|
||||
config_home = Path(config_text)
|
||||
config_home.mkdir(parents=True)
|
||||
for name, relative in module.RUNTIME_LINK_TARGETS:
|
||||
(config_home / name).symlink_to(root / relative, target_is_directory=True)
|
||||
destination = config_home / "uwsm"
|
||||
destination.unlink()
|
||||
destination.symlink_to(root / "config/dot/quickshell", target_is_directory=True)
|
||||
external = Path(external_text)
|
||||
real_exchange = module.rename_exchange
|
||||
first = True
|
||||
|
||||
def race_exchange(candidate, target):
|
||||
global first
|
||||
if first:
|
||||
first = False
|
||||
target.unlink()
|
||||
target.symlink_to(external, target_is_directory=True)
|
||||
real_exchange(candidate, target)
|
||||
|
||||
module.rename_exchange = race_exchange
|
||||
config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2)
|
||||
try:
|
||||
result = module.repair_runtime_links(config)
|
||||
finally:
|
||||
module.rename_exchange = real_exchange
|
||||
assert result.exit_code == 1
|
||||
assert destination.is_symlink()
|
||||
assert os.readlink(destination) == str(external)
|
||||
assert not list(config_home.glob(".panama-link-*"))
|
||||
PY
|
||||
|
||||
# Caffeine repair deduplicates rows, pins each distinct duplicate with a
|
||||
# pidfd, revalidates authored metadata, and signals only the duplicate.
|
||||
/usr/bin/sleep 30 &
|
||||
preserved_pid=$!
|
||||
child_pids+=("$preserved_pid")
|
||||
/usr/bin/sleep 30 &
|
||||
duplicate_pid=$!
|
||||
child_pids+=("$duplicate_pid")
|
||||
printf '%s %s\n' "$preserved_pid" "$duplicate_pid" >"$runtime_dir/caffeine-pids"
|
||||
printf 'dedupe\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
: >"$repair_log"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status"
|
||||
assert_repair_result panama.caffeine true 0
|
||||
expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nsystemd-inhibit|--list|--no-pager|--no-legend'
|
||||
[[ "$(<"$repair_log")" == "$expected_caffeine" ]] \
|
||||
|| fail "Caffeine repair did not preserve/filter exact inhibitors: $(<"$repair_log")"
|
||||
kill -0 "$preserved_pid" >/dev/null 2>&1 || fail 'repeated inhibitor rows killed the preserved process'
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$duplicate_pid" >/dev/null 2>&1 || break
|
||||
sleep 0.05
|
||||
done
|
||||
! kill -0 "$duplicate_pid" >/dev/null 2>&1 || fail 'distinct duplicate inhibitor was not terminated'
|
||||
|
||||
# Changed second-list metadata invalidates the candidate before any signal.
|
||||
/usr/bin/sleep 30 &
|
||||
altered_preserved=$!
|
||||
child_pids+=("$altered_preserved")
|
||||
/usr/bin/sleep 30 &
|
||||
altered_duplicate=$!
|
||||
child_pids+=("$altered_duplicate")
|
||||
printf '%s %s\n' "$altered_preserved" "$altered_duplicate" >"$runtime_dir/caffeine-pids"
|
||||
printf 'altered\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'altered inhibitor metadata was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
kill -0 "$altered_preserved" >/dev/null 2>&1 || fail 'metadata refusal signaled the preserved process'
|
||||
kill -0 "$altered_duplicate" >/dev/null 2>&1 || fail 'metadata refusal signaled the candidate process'
|
||||
|
||||
# Changing metadata on the preserved row is also a full-identity mismatch,
|
||||
# even though every duplicate PID remains present.
|
||||
/usr/bin/sleep 30 &
|
||||
preserve_changed_keep=$!
|
||||
child_pids+=("$preserve_changed_keep")
|
||||
/usr/bin/sleep 30 &
|
||||
preserve_changed_duplicate=$!
|
||||
child_pids+=("$preserve_changed_duplicate")
|
||||
printf '%s %s\n' "$preserve_changed_keep" "$preserve_changed_duplicate" >"$runtime_dir/caffeine-pids"
|
||||
printf 'preserve-altered\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'preserved-row metadata change was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
kill -0 "$preserve_changed_keep" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the preserved process'
|
||||
kill -0 "$preserve_changed_duplicate" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the duplicate process'
|
||||
|
||||
# A repeated exact row disappearing between lists changes multiplicity and is
|
||||
# refused before signaling any pinned duplicate.
|
||||
/usr/bin/sleep 30 &
|
||||
multiplicity_keep=$!
|
||||
child_pids+=("$multiplicity_keep")
|
||||
/usr/bin/sleep 30 &
|
||||
multiplicity_duplicate=$!
|
||||
child_pids+=("$multiplicity_duplicate")
|
||||
printf '%s %s\n' "$multiplicity_keep" "$multiplicity_duplicate" >"$runtime_dir/caffeine-pids"
|
||||
printf 'multiplicity\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'inhibitor row multiplicity change was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
kill -0 "$multiplicity_keep" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the preserved process'
|
||||
kill -0 "$multiplicity_duplicate" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the duplicate process'
|
||||
|
||||
# The production pidfd release function preflights every candidate before any
|
||||
# SIGTERM. A refused second preflight leaves both disposable children alive.
|
||||
/usr/bin/sleep 30 &
|
||||
preflight_first=$!
|
||||
child_pids+=("$preflight_first")
|
||||
/usr/bin/sleep 30 &
|
||||
preflight_second=$!
|
||||
child_pids+=("$preflight_second")
|
||||
/usr/bin/python3 - "$doctor" "$preflight_first" "$preflight_second" <<'PY' \
|
||||
|| fail 'pidfd preflight failure signaled a disposable duplicate'
|
||||
import errno
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
doctor_path = sys.argv[1]
|
||||
pids = [int(value) for value in sys.argv[2:]]
|
||||
loader = importlib.machinery.SourceFileLoader("panama_doctor_preflight", doctor_path)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
loader.exec_module(module)
|
||||
pidfds = [os.pidfd_open(pid, 0) for pid in pids]
|
||||
calls = []
|
||||
|
||||
def sender(pidfd, sig, siginfo, flags):
|
||||
calls.append(sig)
|
||||
if sig == 0 and pidfd == pidfds[1]:
|
||||
raise PermissionError(errno.EPERM, "fixture preflight refusal")
|
||||
signal.pidfd_send_signal(pidfd, sig, siginfo, flags)
|
||||
|
||||
try:
|
||||
outcome = module.signal_caffeine_pidfds(pidfds, sender)
|
||||
finally:
|
||||
for pidfd in pidfds:
|
||||
os.close(pidfd)
|
||||
assert outcome == "preflight-failed"
|
||||
assert calls == [0, 0]
|
||||
for pid in pids:
|
||||
os.kill(pid, 0)
|
||||
PY
|
||||
kill -0 "$preflight_first" >/dev/null 2>&1 || fail 'preflight refusal killed the first duplicate'
|
||||
kill -0 "$preflight_second" >/dev/null 2>&1 || fail 'preflight refusal killed the second duplicate'
|
||||
|
||||
# A candidate that disappears after pidfd acquisition and second-list request
|
||||
# is a safe failure; an unrelated disposable process must remain untouched.
|
||||
/usr/bin/sleep 30 &
|
||||
unrelated_pid=$!
|
||||
child_pids+=("$unrelated_pid")
|
||||
(
|
||||
/usr/bin/sleep 30 &
|
||||
disappearing_pid=$!
|
||||
trap 'kill "$disappearing_pid" >/dev/null 2>&1 || true; wait "$disappearing_pid" >/dev/null 2>&1 || true' EXIT
|
||||
printf '%s\n' "$disappearing_pid" >"$runtime_dir/disappearing-pid"
|
||||
while [[ ! -e "$runtime_dir/release-disappearing-pid" ]]; do
|
||||
/usr/bin/sleep 0.01
|
||||
done
|
||||
kill "$disappearing_pid"
|
||||
wait "$disappearing_pid" >/dev/null 2>&1 || true
|
||||
trap - EXIT
|
||||
) &
|
||||
disappearance_controller=$!
|
||||
child_pids+=("$disappearance_controller")
|
||||
for _ in $(seq 1 100); do
|
||||
[[ -s "$runtime_dir/disappearing-pid" ]] && break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -s "$runtime_dir/disappearing-pid" ]] || fail 'disappearing PID fixture did not start'
|
||||
disappearing_pid="$(<"$runtime_dir/disappearing-pid")"
|
||||
printf '%s %s\n' "$altered_preserved" "$disappearing_pid" >"$runtime_dir/caffeine-pids"
|
||||
printf 'disappear\n' >"$runtime_dir/caffeine-mode"
|
||||
rm -f "$runtime_dir/caffeine-list-count"
|
||||
invoke_repair panama.caffeine
|
||||
[[ "$repair_status" == 1 ]] || fail 'disappeared inhibitor PID was not safely refused'
|
||||
assert_repair_result panama.caffeine true 1
|
||||
wait "$disappearance_controller"
|
||||
kill -0 "$unrelated_pid" >/dev/null 2>&1 || fail 'PID disappearance signaled an unrelated process'
|
||||
|
||||
# Rejected IDs are complete JSON, exit 2, and cause neither a process launch
|
||||
# nor a filesystem mutation.
|
||||
fixture_state() {
|
||||
/usr/bin/python3 - "$fixture" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(root.rglob("*"), key=lambda item: os.fsencode(str(item.relative_to(root)))):
|
||||
relative = os.fsencode(str(path.relative_to(root)))
|
||||
metadata = path.lstat()
|
||||
digest.update(relative + b"\0" + oct(stat.S_IMODE(metadata.st_mode)).encode() + b"\0")
|
||||
if path.is_symlink():
|
||||
digest.update(b"link\0" + os.fsencode(os.readlink(path)) + b"\0")
|
||||
elif path.is_file():
|
||||
digest.update(b"file\0" + hashlib.sha256(path.read_bytes()).digest())
|
||||
elif path.is_dir():
|
||||
digest.update(b"dir\0")
|
||||
else:
|
||||
digest.update(b"other\0")
|
||||
print(digest.hexdigest())
|
||||
PY
|
||||
}
|
||||
for rejected_id in unknown.check integration.home-assistant input.brightness \
|
||||
desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do
|
||||
: >"$repair_log"
|
||||
before_state="$(fixture_state)"
|
||||
invoke_repair "$rejected_id"
|
||||
[[ "$repair_status" == 2 ]] || fail "$rejected_id returned $repair_status instead of 2"
|
||||
assert_repair_result "$rejected_id" false 2
|
||||
[[ ! -s "$repair_log" ]] || fail "$rejected_id launched a process"
|
||||
[[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem"
|
||||
done
|
||||
|
||||
printf 'panama doctor contract: PASS\n'
|
||||
@@ -137,10 +137,20 @@ last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastE
|
||||
|
||||
# ── A rejected value must be refused, not silently accepted ──────────────────
|
||||
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null
|
||||
sleep 0.3
|
||||
|
||||
# The refusal is reported asynchronously, so wait for it rather than sleeping a
|
||||
# fixed 0.3s and hoping. That sleep made this fail roughly one run in three,
|
||||
# reporting "a rejected value did not surface an error" when the error simply
|
||||
# had not arrived yet -- which reads as a missing guard rather than a slow one.
|
||||
rejected=""
|
||||
for _ in $(seq 1 60); do
|
||||
rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
|
||||
[[ -n "$rejected" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
|
||||
[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \
|
||||
|| fail 'a rejected VRR value did not surface an error'
|
||||
[[ -n "$rejected" ]] || fail 'a rejected VRR value did not surface an error'
|
||||
|
||||
# ── Every getoption answer shape must be handled, not just integers ──────────
|
||||
# The compositor reports each option in a different JSON field depending on its
|
||||
|
||||
@@ -9,7 +9,7 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Services About)
|
||||
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Health About)
|
||||
for page in "${pages[@]}"; do
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
||||
@@ -85,8 +85,13 @@ fi
|
||||
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
||||
source_config_path="$repo_dir/config/dot/quickshell"
|
||||
config_path="$state_home/quickshell"
|
||||
harness="$config_path/settings-pages-harness.qml"
|
||||
test_bin="$state_home/bin"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
production_config_path="$HOME/.config/quickshell/shell.qml"
|
||||
harness_pid=""
|
||||
harness_shell_id=""
|
||||
production_before=""
|
||||
|
||||
cleanup_bootstrap() {
|
||||
rm -rf "$state_home"
|
||||
@@ -95,6 +100,21 @@ trap cleanup_bootstrap EXIT
|
||||
|
||||
mkdir -p "$test_bin"
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
python3 - "$config_path/shell.qml" "$harness" "$$" <<'PY'
|
||||
import sys
|
||||
|
||||
source_path, harness_path, identity = sys.argv[1:]
|
||||
source = open(source_path, encoding="utf-8").read()
|
||||
needle = "ShellRoot {\n"
|
||||
replacement = (
|
||||
needle
|
||||
+ f' readonly property string settingsPagesHarnessIdentity: "settings-pages-contract-{identity}"\n'
|
||||
)
|
||||
if source.count(needle) != 1:
|
||||
raise SystemExit("shell.qml does not have exactly one ShellRoot")
|
||||
with open(harness_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(source.replace(needle, replacement, 1))
|
||||
PY
|
||||
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
@@ -138,52 +158,141 @@ EOF
|
||||
chmod +x "$test_bin/flatpak"
|
||||
|
||||
qs_for_test() {
|
||||
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$config_path" "$@"
|
||||
if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
||||
else
|
||||
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$harness" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_test_shell() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
instances_for_path() {
|
||||
local expected_path="$1" listing
|
||||
|
||||
listing="$(qs list --all 2>/dev/null)" || return 1
|
||||
awk -v expected="$expected_path" '
|
||||
/^Instance / { pid = ""; shell_id = "" }
|
||||
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||
/^[[:space:]]*Shell ID:/ { shell_id = $3 }
|
||||
/^[[:space:]]*Config path:/ {
|
||||
path = $0
|
||||
sub(/^[[:space:]]*Config path: /, "", path)
|
||||
if (path == expected && pid ~ /^[0-9]+$/ && shell_id != "")
|
||||
print pid "|" shell_id
|
||||
}
|
||||
' <<<"$listing"
|
||||
}
|
||||
|
||||
harness_identity_matches() {
|
||||
local current
|
||||
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ && -n "$harness_shell_id" ]] || return 1
|
||||
current="$(instances_for_path "$harness")" || return 1
|
||||
grep -Fxq "$harness_pid|$harness_shell_id" <<<"$current"
|
||||
}
|
||||
|
||||
production_is_preserved() {
|
||||
local current record pid shell_id
|
||||
|
||||
current="$(instances_for_path "$production_config_path")" || return 1
|
||||
while IFS='|' read -r pid shell_id; do
|
||||
[[ -n "$pid" ]] || continue
|
||||
kill -0 "$pid" >/dev/null 2>&1 || return 1
|
||||
record="$pid|$shell_id"
|
||||
grep -Fxq "$record" <<<"$current" || return 1
|
||||
done <<<"$production_before"
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
local remaining
|
||||
|
||||
if harness_identity_matches; then
|
||||
kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 80); do
|
||||
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
||||
sleep 0.05
|
||||
done
|
||||
return 1
|
||||
if kill -0 "$harness_pid" >/dev/null 2>&1 && harness_identity_matches; then
|
||||
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 20); do
|
||||
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
fi
|
||||
remaining="$(instances_for_path "$harness")" || return 1
|
||||
harness_pid=""
|
||||
harness_shell_id=""
|
||||
[[ -z "$remaining" ]]
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||
if stop_test_shell; then
|
||||
local cleanup_ok=0
|
||||
|
||||
stop_harness || cleanup_ok=1
|
||||
production_is_preserved || cleanup_ok=1
|
||||
if (( cleanup_ok == 0 )); then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'settings pages contract: branch shell did not stop; retained %s\n' \
|
||||
printf 'settings pages contract: isolated harness cleanup failed; retained %s\n' \
|
||||
"$state_home" >&2
|
||||
fi
|
||||
return "$cleanup_ok"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_test_shell() {
|
||||
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||
local harness_instances production_pid production_shell_id
|
||||
|
||||
harness_instances="$(instances_for_path "$harness")" \
|
||||
|| fail 'could not inspect Quickshell instances before starting the runtime harness'
|
||||
[[ -z "$harness_instances" ]] \
|
||||
|| fail 'an unexpected process already uses the runtime harness path'
|
||||
for _attempt in 1 2; do
|
||||
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||
for _ in $(seq 1 80); do
|
||||
harness_instances="$(instances_for_path "$harness")" \
|
||||
|| fail 'could not inspect the runtime harness instance'
|
||||
[[ -n "$harness_instances" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then
|
||||
IFS='|' read -r harness_pid harness_shell_id <<<"$harness_instances"
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||
|| fail 'runtime harness did not expose a numeric PID'
|
||||
|
||||
while IFS='|' read -r production_pid production_shell_id; do
|
||||
[[ -n "$production_pid" ]] || continue
|
||||
[[ "$harness_shell_id" != "$production_shell_id" ]] || {
|
||||
stop_harness
|
||||
fail 'runtime harness shares a Shell ID with production'
|
||||
}
|
||||
done <<<"$production_before"
|
||||
production_is_preserved || {
|
||||
stop_harness
|
||||
fail 'production changed before isolated page routing began'
|
||||
}
|
||||
|
||||
for _ in $(seq 1 80); do
|
||||
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||
fi
|
||||
stop_harness || fail 'failed runtime harness attempt did not stop cleanly'
|
||||
done
|
||||
sed -n '1,200p' "$shell_log" >&2
|
||||
fail 'isolated branch shell did not start'
|
||||
}
|
||||
|
||||
production_before="$(instances_for_path "$production_config_path")" \
|
||||
|| fail 'could not list Quickshell instances for the production baseline'
|
||||
production_is_preserved || fail 'could not capture a stable production instance set'
|
||||
start_test_shell
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||
shell_pid="$harness_pid"
|
||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||
|
||||
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
|
||||
@@ -218,6 +327,8 @@ intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelli
|
||||
desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
cleanup || fail 'runtime harness did not stop without disturbing production'
|
||||
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
||||
printf 'settings pages contract: PASS\n'
|
||||
production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)"
|
||||
[[ -n "$production_pids" ]] || production_pids="none"
|
||||
printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids"
|
||||
|
||||
@@ -58,8 +58,13 @@ wallpaper|Wallpaper|appearance
|
||||
blur|Blur|appearance
|
||||
timezone|Timezone|datetime
|
||||
repeat delay|Repeat delay|shortcuts
|
||||
system health|System Health|services
|
||||
doctor|Copy health report|services
|
||||
CASES
|
||||
|
||||
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|
||||
|| fail 'search index still uses the retired Startup & Services name'
|
||||
|
||||
# ── Shortcuts are searchable by what they do ─────────────────────────────────
|
||||
[[ "$(find_top screenshot | jq -r .topPage)" == "shortcuts" ]] \
|
||||
|| fail 'searching a shortcut description did not route to the shortcuts page'
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Common XKB behavior should be discoverable without hiding the raw option
|
||||
# string from advanced users. This is source-only so it never remaps the live
|
||||
# keyboard while the desktop is in use.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/ShortcutsPage.qml"
|
||||
input="$repo_dir/config/dot/hypr/input.lua"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
|
||||
fail() {
|
||||
printf 'xkb presets contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for needle in \
|
||||
'function currentXkbOption(prefix: string): string' \
|
||||
'function setXkbOption(prefix: string, option: string): void' \
|
||||
'label: "Caps Lock"' \
|
||||
'value: "caps:escape_shifted_capslock"' \
|
||||
'value: "caps:ctrl_modifier"' \
|
||||
'label: "Compose key"' \
|
||||
'value: "compose:ralt"' \
|
||||
'label: "Layout switching"' \
|
||||
'value: "grp:win_space_toggle"' \
|
||||
'SystemSettings.commitPreference("keyboardOptions"' \
|
||||
'TextEntryRow { setting: "keyboardOptions"'; do
|
||||
rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle"
|
||||
done
|
||||
|
||||
# Picking one category must replace only that category, preserving advanced
|
||||
# options from every other group.
|
||||
rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|
||||
|| fail 'preset updates do not preserve unrelated XKB options'
|
||||
|
||||
rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \
|
||||
|| fail 'Hyprland does not replay the stored keyboard variant'
|
||||
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
|
||||
|| fail 'the shipped XKB default no longer matches the migrated GNOME behavior'
|
||||
rg -Fq 'kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock")' "$input" \
|
||||
|| fail 'Hyprland does not replay the stored keyboard options'
|
||||
|
||||
printf 'xkb presets contract: PASS\n'
|
||||
Reference in New Issue
Block a user