Author SHA1 Message Date
Gabriel Brown d87f4b6d6a Define Settings ownership boundaries 2026-08-18 13:27:52 -04:00
Gabriel Brown 9fc1fdbbbb Expose wallpaper health status 2026-08-18 13:07:23 -04:00
Gabriel Brown dd8d93c387 Ignore transient Quickshell clients in Health 2026-08-18 13:05:25 -04:00
Gabriel Brown b8832a0174 Preserve the GNOME Caps Lock behavior 2026-08-18 13:01:28 -04:00
Gabriel Brown 3f07d25858 Merge current Panama main 2026-08-18 12:58:40 -04:00
Gabriel Brown df1dcdfad5 Add effective desktop style controls 2026-08-18 12:57:54 -04:00
Gabriel Brown 91f7273f41 Keep pointer focus controls on Mouse 2026-08-18 12:56:24 -04:00
Gabriel Brown f9eba1e8c5 Add curated XKB option presets 2026-08-18 12:55:54 -04:00
Gabriel Brown b1edfb6fe4 Merge Panama health hardening 2026-08-18 12:51:14 -04:00
Gabriel Brown 1afa41526a Add startup application picker 2026-08-18 12:50:42 -04:00
Gabriel Brown 08b16fa03f Fix live network and phone discovery 2026-08-18 12:34:02 -04:00
Gabriel Brown ccf46c40ef Expose 19 more compositor options that only looks.lua could reach
Measured the gap first: of the 38 real Hyprland options Panama's own Lua
sets, only 16 were editable in Settings. Everything else required a text
editor, which is the thing this app exists to stop. This closes most of
that: 66 mapped options now, from 47.

Window shape and shadows on Appearance: corner shape (rounding_power),
focused and fullscreen opacity, shadow falloff and hard-edged shadows.
Window edges, master layout and Hyprland's own notices on Desktop & Dock.

Three of these are corrections rather than additions.

Master layout options existed nowhere, while Settings has offered "Master
and stack" as a choice since this morning -- a layout you can select and
cannot configure is barely a choice. Its card is hidden unless that
layout is actually selected, since settings that do nothing under the
layout you are running are worse than not offering the layout at all.

The four Hyprland notices -- logo, splash, update news, donation nag --
are all turned off by looks.lua on the user's behalf. Defensible as a
default, but not a decision anyone could reverse. They are stored
positively ("show this") and written as Hyprland's `disable_*` through a
new `invert` flag, because a switch labelled "Disable splash text" that
must be ON to hide something is a small cruelty. The Lua does the same
inversion so both sides agree.

Everything new also reads from prefs in looks.lua. Without that these
would apply live and silently revert on the next compositor reload,
which is the failure this codebase keeps designing against.

Two shapes the write path had never seen. Border colours are gradients
and shadow offsets are vec2, and the verifier understood neither -- it
returned false for anything outside int/bool/float/str/css, so both
would have reported every write as rejected. Gradients also need real
care: the stubs declare them as `string|{colors,angle}`, and the string
form carries only ONE stop, so writing "rgba(a) rgba(b) 45deg" as a
string is accepted and keeps the previous value. Verified that directly.
They are also written in one notation and read back in another
(`{colors={"rgba(3b426199)"},angle=45}` becomes `993b4261 45deg`), so
comparison normalises both sides.

Border COLOUR is deliberately not exposed yet. col.inactive_border is
written by ColorScheme on every scheme change, so a user's choice would
be silently overwritten, and col.active_border is the Prism gradient,
which needs a colour control this app does not have. Shadow offset is
left out for the same reason -- the vec2 support is in place for
whenever the widget exists.

Verified each new option applies and reverts against the live
compositor, and that the schema, enum-map, nav, write and commit/reset
contracts all pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 12:27:34 -04:00
Gabriel Brown 60a321e6f4 Harden Health verification isolation 2026-08-18 12:15:45 -04:00
Gabriel Brown 7d5c65be03 Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts:
#	config/dot/quickshell/modules/settings/HealthPage.qml
#	tests/quickshell/health-ui-contract.sh
2026-08-18 11:41:33 -04:00
Gabriel Brown f8e7512e01 Document Panama health and recovery 2026-08-18 11:36:06 -04:00
Gabriel Brown 4ef2f01baa Merge remote-tracking branch 'origin/main' into feat/panama-health
# Conflicts:
#	config/dot/quickshell/modules/settings/ServicesPage.qml
#	config/dot/quickshell/modules/settings/SettingsShell.qml
#	config/dot/quickshell/modules/settings/SettingsSidebar.qml
2026-08-18 11:23:07 -04:00
Gabriel Brown faa9a00716 Close Panama recovery race windows 2026-08-18 11:11:53 -04:00
Gabriel Brown 8bfae70284 Harden bounded Panama recovery actions 2026-08-18 10:58:48 -04:00
39 changed files with 2980 additions and 223 deletions
+25
View File
@@ -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 | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | 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 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 ## GNOME extension migration
+3 -1
View File
@@ -9,6 +9,8 @@
-- instead. They still work here; see the session notes in autostart.lua. -- instead. They still work here; see the session notes in autostart.lua.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── GPU selection ─────────────────────────────────────────────────────────── -- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU -- 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. -- (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), -- (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 -- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want. -- 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") hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ──────────────────────────────────────────────────────────────── -- ── Toolkits ────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -10,9 +10,9 @@ local prefs = require("prefs")
hl.config({ hl.config({
input = { input = {
kb_layout = prefs.get("keyboardLayout", "us"), kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "", kb_variant = prefs.get("keyboardVariant", ""),
kb_model = "", kb_model = "",
kb_options = "", kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
kb_rules = "", kb_rules = "",
numlock_by_default = prefs.get("numlockByDefault", true), numlock_by_default = prefs.get("numlockByDefault", true),
+49 -20
View File
@@ -22,21 +22,22 @@ hl.config({
border_size = prefs.get("borderSize", 2), border_size = prefs.get("borderSize", 2),
col = { col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair -- The focused accent role: blue leads, orchid follows, on a
-- is visible on both a tall and a wide window. Same two colours as -- diagonal so the pair is visible on both a tall and a wide
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the -- window. ColorScheme never writes this role; a future accent
-- tmux theme this palette came from. -- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means -- The neutral inactive role follows the colour scheme because a
-- something if exactly one window on screen is wearing it. -- dark neutral disappears against a light desktop.
-- Follows the colour scheme: a dark neutral is invisible against a -- services/ColorScheme.qml applies the same values live; this is
-- light desktop. services/ColorScheme.qml applies changes live; -- the value a fresh session starts from.
-- this is the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light" inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)", 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. -- Enables the per-window "immediate" rule used for games in rules.lua.
-- Harmless on its own; tearing only happens where a rule opts in. -- Harmless on its own; tearing only happens where a rule opts in.
@@ -44,16 +45,22 @@ hl.config({
layout = "dwindle", 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 = { decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting -- 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. -- next to each other read as the same object family.
rounding = prefs.get("windowRounding", 18), 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), inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = { blur = {
@@ -83,9 +90,13 @@ hl.config({
shadow = { shadow = {
enabled = prefs.get("shadowEnabled", true), enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20), range = prefs.get("shadowRange", 20),
render_power = 3, render_power = prefs.getInt("shadowRenderPower", 3),
sharp = false, sharp = prefs.get("shadowSharp", false),
color = "rgba(15161eee)", 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 }, offset = { 0, 4 },
scale = 1.0, scale = 1.0,
}, },
@@ -110,14 +121,32 @@ hl.config({
dwindle = { dwindle = {
-- Keep the split orientation a window was created with. Closest match -- Keep the split orientation a window was created with. Closest match
-- to how the Forge extension behaved on GNOME. -- to how the Forge extension behaved on GNOME.
preserve_split = true, preserve_split = prefs.get("preserveSplit", true),
smart_resizing = 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 = { misc = {
force_default_wallpaper = 0, force_default_wallpaper = 0,
disable_hyprland_logo = true, -- Stored as "show the logo / show the splash" and written as Hyprland's
disable_splash_rendering = true, -- `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 -- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree -- followed the preference, the compositor and the shell would disagree
@@ -172,8 +201,8 @@ hl.config({
}, },
ecosystem = { ecosystem = {
no_update_news = true, no_update_news = not prefs.get("hyprlandUpdateNews", false),
no_donation_nag = true, no_donation_nag = not prefs.get("hyprlandDonationNag", false),
}, },
xwayland = { xwayland = {
@@ -206,6 +206,147 @@ Singleton {
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" } 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 ───────────────────────────────────────────────────────── // ── Effects ─────────────────────────────────────────────────────────
{ {
key: "blurEnabled", type: "bool", def: true, group: "effects", key: "blurEnabled", type: "bool", def: true, group: "effects",
@@ -241,6 +382,19 @@ Singleton {
detail: "How far the shadow spreads from the window edge", detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" } 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", key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow", label: "Focus glow",
@@ -282,7 +436,7 @@ Singleton {
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" } 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 // XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape". // list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$", pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
@@ -349,7 +503,7 @@ Singleton {
{ {
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s", unit: "s",
group: "input", group: "pointer",
label: "Hide pointer after", label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it", detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
// Reported as a float even though it is only ever set to whole // Reported as a float even though it is only ever set to whole
@@ -394,6 +548,12 @@ Singleton {
detail: "Swap the primary and secondary buttons", detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" } 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 ──────────────────────────────────────────────────────── // ── 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 ────────────────────────────────────────────────────── // ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable // The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml. // only by editing Theme.qml.
@@ -681,6 +859,91 @@ Singleton {
label: "Interface text size", label: "Interface text size",
detail: "The base size the rest of the shell's type scales from" 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 ─────────────────────────────────────────────────── // ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and // Backed by gsettings so GTK applications agree with the shell, and
@@ -17,8 +17,10 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
property string expandedPicker: ""
title: "Appearance" 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 { header: Component {
Column { Column {
@@ -83,7 +85,7 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
title: "Typography" title: "Shell typography"
subtitle: Fonts.lastError !== "" subtitle: Fonts.lastError !== ""
? Fonts.lastError ? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name." : "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 { SettingsCard {
title: "Windows" title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." 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: "gapsIn" }
SliderRow { setting: "gapsOut" } SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" } 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 { SettingsCard {
@@ -143,6 +279,8 @@ SettingsPage {
SliderRow { setting: "blurPasses" } SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" } ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" } SliderRow { setting: "shadowRange"; zeroLabel: "None" }
SliderRow { setting: "shadowRenderPower" }
ToggleRow { setting: "shadowSharp" }
ToggleRow { setting: "glowEnabled" } ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" } SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false } 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." lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: "" property string expandedRole: ""
property bool addingAutostart: false
readonly property var applications: DesktopEntries.applications.values readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [ readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
@@ -178,7 +179,28 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "User autostart" 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 { TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 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. // 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 { SettingsCard {
title: "Workspaces & focus" title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set." 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: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" } ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" } ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" } ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
ChoiceRow { setting: "followMouse"; divider: false }
} }
SettingsCard { SettingsCard {
@@ -143,6 +143,7 @@ SettingsPage {
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.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 confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.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 { return {
renderedRows: rows.map(row => { renderedRows: rows.map(row => {
const objectName = String(row.objectName); const objectName = String(row.objectName);
@@ -162,6 +163,11 @@ SettingsPage {
focusChain: root.renderedFocusChain(), focusChain: root.renderedFocusChain(),
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)), activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)), 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, confirmationVisible: confirmationSheets.length === 1,
confirmationId: confirmationSheets.length === 1 confirmationId: confirmationSheets.length === 1
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length) ? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
@@ -384,14 +390,40 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Fedora system settings" 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." 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")
}
}
// One row per subject rather than a single "Open GNOME Settings"
// button. Naming five things and then opening the network panel
// regardless reads as a broken button rather than a deliberate
// hand-off, and left someone looking for printers to navigate once
// GNOME Settings appeared on the wrong page.
ActionRow { ActionRow {
objectName: "health-fedora-handoff:users"
label: "Users" label: "Users"
detail: "Accounts, passwords, and automatic login" detail: "Accounts, passwords, and automatic login"
action: "Open users" action: "Open users"
@@ -399,6 +431,7 @@ SettingsPage {
} }
ActionRow { ActionRow {
objectName: "health-fedora-handoff:sharing"
label: "Sharing" label: "Sharing"
detail: "Remote desktop, media sharing, and remote login" detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing" action: "Open sharing"
@@ -406,6 +439,7 @@ SettingsPage {
} }
ActionRow { ActionRow {
objectName: "health-fedora-handoff:color"
label: "Colour profiles" label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners" detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour" action: "Open colour"
@@ -413,6 +447,7 @@ SettingsPage {
} }
ActionRow { ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing" label: "Digital wellbeing"
detail: "Screen time and break reminders" detail: "Screen time and break reminders"
action: "Open wellbeing" action: "Open wellbeing"
@@ -31,7 +31,12 @@ SettingsPage {
ChoiceRow { setting: "accelProfile" } ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" } ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" } 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 { 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 hardware, accounts, printers — is delegated to GNOME Settings and labelled as
such rather than half-reimplemented. 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 ## Adding a setting
One schema entry. That is the whole job. One schema entry. That is the whole job.
@@ -32,6 +59,36 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings `hypr/looks.lua` so the Hyprland config still stands alone with no settings
file. file.
## Setting ownership
Every preference has **one primary page**, derived from its schema `group` and
the route in `services/SettingsSearch.qml`. Search results always open that
owner. A control may appear on a second page only when the same adaptation is
part of another established mental model; otherwise use a labelled handoff to
the owner instead of duplicating it.
### Intentional mirrors
| Setting | Primary page | Mirror | Why the mirror earns its place |
|---|---|---|---|
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker).
`ColorScheme.qml` must never write the focused border, so changing schemes
cannot erase a user-selected accent.
## The rows ## The rows
| Component | For | | Component | For |
@@ -83,11 +140,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 becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target. target.
**A copy of the Quickshell config shares the live shell's ID.** Quickshell **A content-identical Quickshell entry can share the live shell's ID.**
derives the Shell ID from config *content*, not path, so Quickshell derives the Shell ID from config *content*, not path. Runtime
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running harnesses therefore create a distinct semantic entry file, address that exact
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a file with `qs -p`, and discover its PID from the exact Config path in
single distinct `.qml` file are safe; copying the whole directory is not. `qs list --all`. They terminate only that recorded PID with `kill`; never use
`qs kill` from a copied configuration.
## Where state lives ## Where state lives
@@ -21,6 +21,25 @@ SettingsPage {
// when nothing is being captured. Held here rather than per row so that // when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other. // starting a new capture cancels any other.
property string capturingChord: "" 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" title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling." lede: "The Forge mental model, carried forward into native tiling."
@@ -35,6 +54,52 @@ SettingsPage {
// they are real controls. // they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" } TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" } 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" } TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" } SliderRow { setting: "keyRepeatRate" }
@@ -34,6 +34,7 @@ DateTimePage 1.0 DateTimePage.qml
AccessibilityPage 1.0 AccessibilityPage.qml AccessibilityPage 1.0 AccessibilityPage.qml
WallpaperPicker 1.0 WallpaperPicker.qml WallpaperPicker 1.0 WallpaperPicker.qml
ApplicationsPage 1.0 ApplicationsPage.qml ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
@@ -37,8 +37,8 @@ def xdg_data_roots() -> list[Path]:
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)] return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]: def discovered_desktop_files() -> dict[str, Path]:
desktop_ids: set[str] = set() desktop_files: dict[str, Path] = {}
for root in xdg_data_roots(): for root in xdg_data_roots():
applications = root / "applications" applications = root / "applications"
if not applications.is_dir(): if not applications.is_dir():
@@ -47,8 +47,12 @@ def discovered_desktop_ids() -> set[str]:
if not path.is_file(): if not path.is_file():
continue continue
relative = path.relative_to(applications) relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts)) desktop_files.setdefault("-".join(relative.parts), path)
return desktop_ids 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: 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) run(command)
def update_hidden(path: Path, *, hidden: bool) -> None: def with_hidden(original: str, *, hidden: bool) -> str:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines() lines = original.splitlines()
output: list[str] = [] output: list[str] = []
section = "" section = ""
@@ -216,24 +215,63 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
raise BoundaryError("That autostart entry is not a desktop file.") raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden: if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}") 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: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary: ) as temporary:
temporary.write("\n".join(output) + "\n") temporary.write(text)
temporary.flush() temporary.flush()
os.fsync(temporary.fileno()) os.fsync(temporary.fileno())
temporary_path = Path(temporary.name) temporary_path = Path(temporary.name)
temporary_path.chmod(mode) temporary_path.chmod(mode)
os.replace(temporary_path, path) os.replace(temporary_path, path)
except OSError as error: except OSError as error:
if "temporary_path" in locals(): if temporary_path is not None:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error 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: def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}: if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or 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]) set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart": elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2]) set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
else: else:
raise BoundaryError( raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | " "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: except BoundaryError as error:
print(str(error), file=sys.stderr) print(str(error), file=sys.stderr)
+98
View File
@@ -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())
+256 -39
View File
@@ -5,9 +5,13 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ctypes
import errno
import json import json
import os import os
import re import re
import secrets
import signal
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -21,6 +25,11 @@ from typing import Callable, Literal
Status = Literal["ok", "warning", "error", "unconfigured"] Status = Literal["ok", "warning", "error", "unconfigured"]
Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"] Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
ActionKind = Literal["repair", "open", "instructions"] 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) @dataclass(frozen=True)
@@ -121,7 +130,7 @@ RUNTIME_LINK_TARGETS = (
("vicinae", Path("config/dot/vicinae")), ("vicinae", Path("config/dot/vicinae")),
) )
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine")) REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle") PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b") 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) REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
PROBE_ENVIRONMENT_KEYS = ( PROBE_ENVIRONMENT_KEYS = (
@@ -409,8 +418,13 @@ def check_runtime_links(config: DoctorConfig) -> Check:
def check_vicinae_commands(config: DoctorConfig) -> Check: def check_vicinae_commands(config: DoctorConfig) -> Check:
source = config.root / "config/local/share/vicinae/scripts" source = config.root / "config/local/share/vicinae/scripts"
installed = config.home / ".local/share/vicinae/scripts" installed = config.home / ".local/share/vicinae/scripts/panama"
if source.is_dir() and installed.is_symlink() and installed.exists(): 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", "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")) return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link"))
@@ -422,7 +436,21 @@ def executable_check(check_id: str, title: str, executable: str, config: DoctorC
def check_processes(config: DoctorConfig) -> Check: def check_processes(config: DoctorConfig) -> Check:
counts: list[int] = [] # `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: for name in PROCESS_NAMES:
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config) result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
if result.state == "ok": if result.state == "ok":
@@ -445,20 +473,10 @@ def check_caffeine(config: DoctorConfig) -> Check:
result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config) result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config)
if result.state != "ok": if result.state != "ok":
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.") return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
uid = str(os.getuid()) inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid()))
inhibitors = 0 if inhibitor_rows is None:
malformed = False
for line in result.stdout.splitlines():
parts = line.split()
relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts
if not relevant:
continue
if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]:
inhibitors += 1
else:
malformed = True
if malformed:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.") 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: if inhibitors > 1:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors")) return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
if inhibitors == 1: if inhibitors == 1:
@@ -535,10 +553,138 @@ def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult
return RepairResult(check_id, True, exit_code, message) 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: def repair_runtime_links(config: DoctorConfig) -> RepairResult:
sources = [(name, config.root / relative_source) for name, relative_source in RUNTIME_LINK_TARGETS] 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): if any(not source.is_dir() for _, source in sources):
return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.") 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: try:
config.config_home.mkdir(parents=True, exist_ok=True) config.config_home.mkdir(parents=True, exist_ok=True)
@@ -551,23 +697,30 @@ def repair_runtime_links(config: DoctorConfig) -> RepairResult:
destination = config.config_home / name destination = config.config_home / name
try: try:
if destination.is_symlink(): if destination.is_symlink():
if destination.resolve(strict=False) == source.resolve(strict=True): current_target = lexical_link_target(destination)
if current_target == source:
continue continue
destination.unlink() if current_target not in authored_sources:
destination.symlink_to(source, target_is_directory=True) blocked = True
continue
outcome = exchange_owned_symlink(destination, source, authored_sources)
blocked = blocked or outcome == "blocked"
failed = failed or outcome == "failed"
elif destination.exists(): elif destination.exists():
# A regular file or directory is user-owned unless proven # A regular file or directory is user-owned unless proven
# otherwise. Report it, but never replace it. # otherwise. Report it, but never replace it.
blocked = True blocked = True
else: else:
destination.symlink_to(source, target_is_directory=True) outcome = install_absent_symlink(destination, source)
blocked = blocked or outcome == "blocked"
failed = failed or outcome == "failed"
except OSError: except OSError:
failed = True failed = True
if failed: if failed:
return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.") return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.")
if blocked: if blocked:
return RepairResult("panama.runtime-links", True, 1, "A user-owned file or directory is blocking a Panama runtime link.") return RepairResult("panama.runtime-links", True, 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.") return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.")
@@ -581,6 +734,57 @@ def repair_vicinae_commands(config: DoctorConfig) -> RepairResult:
return RepairResult("panama.vicinae-commands", True, exit_code, message) 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: def repair_caffeine(config: DoctorConfig) -> RepairResult:
list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend") list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend")
list_exit, output = run_repair_command(list_command, config) list_exit, output = run_repair_command(list_command, config)
@@ -588,26 +792,39 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult:
return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.") return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.")
uid = str(os.getuid()) uid = str(os.getuid())
inhibitor_pids: list[str] = [] inhibitor_rows = parse_caffeine_rows(output, uid)
for line in output.splitlines(): if inhibitor_rows is None:
parts = line.split() return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
continue
if len(parts) != 8:
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
if parts[6] != "Caffeine" or parts[7] != "block":
continue
if not parts[3].isdecimal():
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
inhibitor_pids.append(parts[3])
if len(inhibitor_pids) <= 1: if len(inhibitor_pids) <= 1:
return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.") return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.")
for pid in inhibitor_pids[1:]: duplicates = inhibitor_pids[1:]
exit_code, _ = run_repair_command(("kill", "--", pid), config) if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
if exit_code != 0: return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.")
return RepairResult("panama.caffeine", True, exit_code, "A duplicate Panama Caffeine inhibitor could not be released.")
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.") return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.")
+100 -5
View File
@@ -25,6 +25,9 @@ PLUGIN_ACTIONS = {
"kdeconnect_share": "share", "kdeconnect_share": "share",
} }
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices" 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]] 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 "" 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( def run_command(
command: list[str], 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 "" 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]: def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
try: try:
listing = run_command( listing = run_command(
@@ -200,15 +286,24 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
continue continue
device_id = match.group("id") device_id = match.group("id")
try: try:
device = normalize_device_line( plugins = device_plugins(device_id, runner)
line, device_type = reported_type(device_id, runner)
device_plugins(device_id, runner), except (FileNotFoundError, subprocess.TimeoutExpired):
reported_type(device_id, runner), plugins = []
) device_type = ""
try:
device = normalize_device_line(line, plugins, device_type)
except ValueError: except ValueError:
continue continue
devices.append(device) 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( devices.sort(
key=lambda device: ( key=lambda device: (
not bool(device["reachable"]), not bool(device["reachable"]),
@@ -25,25 +25,14 @@ import qs.config
Singleton { Singleton {
id: root id: root
property string cursorTheme: ""
property string lastError: "" 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 int cursorSize: DesktopPreferences.get("cursorSize")
readonly property real textScale: DesktopPreferences.get("textScale") 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 // A short queue, because applying one setting takes several commands and
// Process runs one at a time. // Process runs one at a time.
property var pending: [] property var pending: []
@@ -71,7 +60,6 @@ Singleton {
} }
Component.onCompleted: { Component.onCompleted: {
themeQuery.running = true;
settle.restart(); settle.restart();
} }
@@ -102,10 +90,7 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size], ["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)] ["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
]; ];
// setcursor needs a theme name; skip it rather than guess if gsettings commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
// has not answered yet. The next change will catch up.
if (root.cursorTheme !== "")
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
root.enqueue(commands); root.enqueue(commands);
} }
} }
@@ -24,6 +24,11 @@ Singleton {
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps" readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light" readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// A neutral contrast role, not an accent. The focused Prism border belongs
// to the visual theme and must remain untouched when this role changes.
readonly property string inactiveBorderDark: "rgba(3b426199)"
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
property string lastError: "" property string lastError: ""
// Applied one command at a time: Process runs a single command, and several // Applied one command at a time: Process runs a single command, and several
@@ -99,12 +104,10 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme] ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
]; ];
// Unfocused window borders. The focused border is the prism gradient and // Unfocused window borders need scheme-relative contrast. The focused
// is already scheme-independent; the inactive one is a flat neutral that // Prism border is deliberately owned by the accent/theme layer.
// would be invisible against the opposite background.
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
commands.push(["hyprctl", "eval", commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]); `hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
// Applications that predate org.freedesktop.appearance and carry their // Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal // own palettes -- terminals, chiefly. Everything that reads the portal
@@ -33,11 +33,16 @@ Singleton {
} }
readonly property var wiredDevice: { readonly property var wiredDevice: {
let fallback = null;
for (const device of Networking.devices.values) { for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wired) if (device.type !== DeviceType.Wired)
continue;
if (device.connected)
return device; return device;
if (!fallback)
fallback = device;
} }
return null; return fallback;
} }
readonly property var adapter: Bluetooth.defaultAdapter readonly property var adapter: Bluetooth.defaultAdapter
@@ -98,5 +98,16 @@ Singleton {
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]); 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() 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");
}
}
@@ -117,6 +117,8 @@ Singleton {
} }
function refresh(): bool { function refresh(): bool {
if (root.postRepairScanPending)
return false;
if (scanProcess.running || repairProcess.running) { if (scanProcess.running || repairProcess.running) {
root.queuedRefresh = true; root.queuedRefresh = true;
return false; return false;
@@ -239,6 +241,8 @@ Singleton {
const check = root.checks.find(candidate => candidate.id === id); const check = root.checks.find(candidate => candidate.id === id);
if (!check || !check.action || check.action.kind !== "repair") if (!check || !check.action || check.action.kind !== "repair")
return false; return false;
if (external && check.action.confirm)
return false;
root.repairingId = id; root.repairingId = id;
root.lastError = ""; root.lastError = "";
@@ -17,12 +17,20 @@ import qs.config
Singleton { Singleton {
id: root 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 // 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 // entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable. // dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({ readonly property var groupPages: ({
"clock": "appearance", "clock": "appearance",
"vitals": "appearance", "vitals": "appearance",
"typography": "appearance",
"themes": "appearance",
"titlebar": "appearance",
"windows": "appearance", "windows": "appearance",
"effects": "appearance", "effects": "appearance",
"wallpaper": "appearance", "wallpaper": "appearance",
@@ -36,6 +44,9 @@ Singleton {
"pointer": "mouse", "pointer": "mouse",
"touchpad": "mouse", "touchpad": "mouse",
"multitasking": "desktop", "multitasking": "desktop",
"edges": "desktop",
"master": "desktop",
"notices": "desktop",
"weather": "appearance", "weather": "appearance",
"notifications": "notifications", "notifications": "notifications",
"capture": "screen-intelligence" "capture": "screen-intelligence"
@@ -273,6 +273,15 @@ Singleton {
// decides, and config/dot/hypr/prefs.lua does the same conversion via // decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree. // prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var { 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") if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0; return value ? 1 : 0;
return value; return value;
@@ -300,6 +309,24 @@ Singleton {
return value ? "true" : "false"; return value ? "true" : "false";
if (typeof value === "number") if (typeof value === "number")
return String(value); 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 // Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defence. // belt-and-braces rather than the primary defence.
return `"${String(value).replace(/["\\]/g, "")}"`; return `"${String(value).replace(/["\\]/g, "")}"`;
@@ -309,7 +336,15 @@ Singleton {
const parts = []; const parts = [];
for (const name in node) { for (const name in node) {
const child = node[name]; 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(", ")} }`; return `{ ${parts.join(", ")} }`;
} }
@@ -353,6 +388,59 @@ Singleton {
root.drainQueue(); 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 { function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer) if (!answer)
return false; return false;
@@ -370,6 +458,12 @@ Singleton {
case "css": case "css":
// Gaps read back as a box, e.g. "10 10 10 10". // Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected; 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; return false;
} }
+17
View File
@@ -165,6 +165,23 @@ ShellRoot {
function repair(id: string): bool { return Health.repair(id, true); } 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. // A small diagnostics surface doubles as a deterministic contract harness.
// Real producers call StatusEvents.publish() directly; fixtures never run // Real producers call StatusEvents.publish() directly; fixtures never run
// unless explicitly requested over IPC by the test suite. // unless explicitly requested over IPC by the test suite.
@@ -496,7 +496,20 @@ git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/servic
git commit -m "Add bounded Panama recovery actions" git commit -m "Add bounded Panama recovery actions"
``` ```
### Task 7: Full verification, live read-only audit, and documentation ### 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:** **Files:**
- Modify: `config/dot/hypr/DESKTOP-PARITY.md` - Modify: `config/dot/hypr/DESKTOP-PARITY.md`
@@ -505,9 +518,11 @@ git commit -m "Add bounded Panama recovery actions"
**Interfaces:** **Interfaces:**
- Consumes: the complete feature and existing regression suite. - Consumes: the complete feature and existing regression suite.
- Produces: current user documentation, a redacted live health snapshot, and final verification evidence. - 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.
- [ ] **Step 1: Document boundaries and entry points** - [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. 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.
@@ -525,9 +540,21 @@ for test in tests/quickshell/*contract.sh; do "$test"; done
for test in tests/hypr/*contract.sh; do "$test"; done for test in tests/hypr/*contract.sh; do "$test"; done
``` ```
Expected: every command exits 0; Quickshell tests report 58 contracts after the three new contracts land. 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).
- [ ] **Step 3: Run a redacted live read-only comparison** 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: Run:
@@ -541,7 +568,10 @@ 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. Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations.
- [ ] **Step 4: Reload and inspect the live shell** - [ ] **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: Run:
@@ -33,6 +33,9 @@ done
assert_contains 'title: "Default applications"' assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"' assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"' assert_contains 'title: "Compositor autostart"'
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories' assert_contains 'categories'
assert_contains 'genericName' assert_contains 'genericName'
assert_contains '.sort(' assert_contains '.sort('
@@ -123,4 +126,14 @@ fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \ [[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable' || 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' printf 'applications settings contract: PASS\n'
+24
View File
@@ -29,6 +29,7 @@ assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void' assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): 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 setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'function addAutostart(desktopId: string): void'
assert_service_contains 'DesktopEntries.applications.values' assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array' 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' fail 'read-only compositor entry was accepted for mutation'
fi 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' printf 'default apps contract: PASS\n'
+210
View File
@@ -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'
+7
View File
@@ -4,6 +4,13 @@ set -euo pipefail
case "${1:-}" in case "${1:-}" in
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;; --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) ipc)
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n' printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
+153 -6
View File
@@ -7,10 +7,22 @@
set -euo pipefail set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/health-harness.qml" source_harness="$repo_dir/config/dot/quickshell/health-harness.qml"
service="$repo_dir/config/dot/quickshell/services/Health.qml" service="$repo_dir/config/dot/quickshell/services/Health.qml"
shell="$repo_dir/config/dot/quickshell/shell.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}}]}' 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 ' projection_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret" .fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret" | .summary.fixtureSecret = "fixture-secret"
@@ -33,8 +45,10 @@ fail() {
} }
[[ -f "$service" ]] || fail 'Health.qml is missing' [[ -f "$service" ]] || fail 'Health.qml is missing'
[[ -f "$harness" ]] || fail 'health harness is missing' [[ -f "$source_harness" ]] || fail 'health harness is missing'
[[ -f "$shell" ]] || fail 'shell.qml 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 # 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. # contract static while pinning the typed, redacted IPC boundary it exports.
@@ -72,12 +86,41 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
PY PY
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)" 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" helper="$fixture_dir/panama-doctor"
copy_bin="$fixture_dir/bin" copy_bin="$fixture_dir/bin"
copy_file="$fixture_dir/copied-report.json" copy_file="$fixture_dir/copied-report.json"
repair_mode_file="$fixture_dir/repair-mode" repair_mode_file="$fixture_dir/repair-mode"
repair_log="$fixture_dir/repair.log" repair_log="$fixture_dir/repair.log"
notification_log="$fixture_dir/notifications.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 'success\n' >"$repair_mode_file"
printf '%s\n' \ printf '%s\n' \
'#!/usr/bin/env bash' \ '#!/usr/bin/env bash' \
@@ -87,8 +130,12 @@ printf '%s\n' \
" printf '%s\\n' '$warning_snapshot'" \ " printf '%s\\n' '$warning_snapshot'" \
' exit 0' \ ' exit 0' \
'fi' \ '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' \ 'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \
' sleep 0.25' \
' case "$(cat "$PANAMA_HEALTH_REPAIR_MODE_FILE")" in' \ ' 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 ;;' \ ' 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 ;;' \ ' failed) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":7,\"message\":\"Duplicate inhibitors could not be released.\"}\\n"; exit 7 ;;' \
@@ -96,6 +143,10 @@ printf '%s\n' \
' *) printf "not-json\\n"; exit 0 ;;' \ ' *) printf "not-json\\n"; exit 0 ;;' \
' esac' \ ' esac' \
'fi' \ '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" 'exit 2' >"$helper"
chmod +x "$helper" chmod +x "$helper"
mkdir -p "$copy_bin" mkdir -p "$copy_bin"
@@ -110,12 +161,57 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
run() { run() {
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ 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_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" qs -p "$harness" "$@" 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_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() { cleanup() {
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true : >"$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" rm -rf "$fixture_dir"
} }
trap cleanup EXIT trap cleanup EXIT
@@ -123,6 +219,7 @@ trap cleanup EXIT
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ 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_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_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 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
run ipc show 2>/dev/null | rg -q '^target health-test$' && break run ipc show 2>/dev/null | rg -q '^target health-test$' && break
@@ -130,6 +227,9 @@ for _ in $(seq 1 40); do
done done
run ipc show 2>/dev/null | rg -q '^target health-test$' || fail 'test IPC target did not start' 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_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" ]] \ [[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|| fail 'valid warning snapshot was rejected' || fail 'valid warning snapshot was rejected'
@@ -186,14 +286,21 @@ jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == fal
>/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state" >/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state"
printf 'success\n' >"$repair_mode_file" printf 'success\n' >"$repair_mode_file"
rm -f "$repair_started_file" "$repair_release_file"
repair_generation="$(jq -r .generation <<<"$state")" repair_generation="$(jq -r .generation <<<"$state")"
[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \ [[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|| fail 'repairable check was refused' || 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 run ipc call health-test queue >/dev/null
working_state="$(run ipc call health-test status)" working_state="$(run ipc call health-test status)"
jq -e '.repairingId == "panama.caffeine" and .queuedRefresh == true jq -e '.repairingId == "panama.caffeine" and .queuedRefresh == true
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \ and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \
>/dev/null <<<"$working_state" || fail "repair did not retain the degraded row while working: $working_state" >/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 for _ in $(seq 1 120); do
state="$(run ipc call health-test status)" state="$(run ipc call health-test status)"
jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' --argjson before "$repair_generation" \ jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' --argjson before "$repair_generation" \
@@ -243,12 +350,52 @@ jq -e '.lastRepair.checkId == "panama.caffeine" and .lastRepair.accepted == fals
--argjson before "$mismatch_generation" >/dev/null <<<"$state" \ --argjson before "$mismatch_generation" >/dev/null <<<"$state" \
|| fail "mismatched repair JSON escaped containment: $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" ]] \ [[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \
|| fail 'unknown check started a repair' || fail 'unknown check started a repair'
[[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \ [[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \
|| fail 'non-repairable check started a repair' || fail 'non-repairable check started a repair'
state="$(run ipc call health-test status)" state="$(run ipc call health-test status)"
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$mismatch_generation" \ jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state" >/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded' python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
+19 -5
View File
@@ -58,11 +58,19 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \
|| fail 'health rows are below the approved 62px target' || fail 'health rows are below the approved 62px target'
rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|| fail 'opening System Health does not request a fresh scan' || fail 'opening System Health does not request a fresh scan'
# The Fedora hand-off is a row per subject, not one button that opened the rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
# network panel whatever it was labelled. What matters is that each row reaches || fail 'Fedora ownership boundary does not open GNOME Settings'
# the panel that owns it, so this checks the boundary still exists and that rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
# every panel it names is one openGnomePanel accepts -- a name outside that || fail 'Fedora ownership boundary lost the Users handoff'
# allow-list opens nothing and reports an error, i.e. a dead button. 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" \ rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \
|| fail 'the Fedora ownership boundary card is gone' || fail 'the Fedora ownership boundary card is gone'
@@ -296,6 +304,12 @@ jq -e '
and (.renderedRows | map(.id) | length) == 6 and (.renderedRows | map(.id) | length) == 6
and (.renderedRows | map(.id) | unique | length) == 6 and (.renderedRows | map(.id) | unique | length) == 6
and .emptyQuietGroups == ["desktop-foundation"] 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 .summaryHeight == 126
and (.rowHeights | length) == 6 and (.rowHeights | length) == 6
and (.rowHeights | all(. >= 62)) and (.rowHeights | all(. >= 62))
+128
View File
@@ -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__": if __name__ == "__main__":
unittest.main() unittest.main()
+353 -49
View File
@@ -16,7 +16,15 @@ fail() {
} }
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)" fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT 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" home="$fixture/home"
config_home="$home/.config" config_home="$home/.config"
@@ -25,7 +33,7 @@ runtime_dir="$fixture/runtime"
bin_dir="$fixture/bin" bin_dir="$fixture/bin"
data_home="$home/.local/share" data_home="$home/.local/share"
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae" mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts"
cp "$fixture_root/bin/"* "$bin_dir/" cp "$fixture_root/bin/"* "$bin_dir/"
chmod +x "$bin_dir"/* chmod +x "$bin_dir"/*
@@ -81,7 +89,7 @@ touch "$config_home/autostart/nextcloud.desktop"
for name in hypr quickshell uwsm vicinae; do for name in hypr quickshell uwsm vicinae; do
ln -s "$repo_dir/config/dot/$name" "$config_home/$name" ln -s "$repo_dir/config/dot/$name" "$config_home/$name"
done done
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts" ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama"
run_doctor() { run_doctor() {
HOME="$home" \ HOME="$home" \
@@ -130,6 +138,20 @@ check_status() {
snapshot="$(run_doctor --json)" snapshot="$(run_doctor --json)"
assert_schema_and_redaction "$snapshot" 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. # A healthy systemd-backed service stays healthy.
check_status "$snapshot" desktop.hyprpaper ok check_status "$snapshot" desktop.hyprpaper ok
@@ -194,11 +216,14 @@ jq -e '.checks[] | select(.id == "panama.caffeine")
|| fail 'Caffeine detail exposed inhibitor PIDs' || fail 'Caffeine detail exposed inhibitor PIDs'
# Process counts use only exact authored names and never expose command lines or PIDs. # Process counts use only exact authored names and never expose command lines or PIDs.
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=quickshell:duplicate run_doctor --json)" duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:duplicate run_doctor --json)"
check_status "$duplicated_processes" panama.processes warning check_status "$duplicated_processes" panama.processes warning
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \ ! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|| fail 'process detail exposed a PID' || 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 # Invalid output for a non-Quickshell authored process is not a normal zero
# count that can be hidden by the running Quickshell process. # count that can be hidden by the running Quickshell process.
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)" malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
@@ -237,12 +262,14 @@ summary="$(run_doctor --summary)"
# Repairs run against a second, disposable Panama root. Every process boundary # Repairs run against a second, disposable Panama root. Every process boundary
# records its argv, and every filesystem assertion is confined to this fixture. # records its argv, and every filesystem assertion is confined to this fixture.
repair_root="$fixture/repair-root" repair_root="$home/.local/share/Panama"
repair_log="$runtime_dir/repair.log" repair_log="$runtime_dir/repair.log"
mkdir -p "$repair_root/config/dot" "$repair_root/setup/scripts" 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 for name in hypr quickshell uwsm vicinae; do
mkdir -p "$repair_root/config/dot/$name" mkdir -p "$repair_root/config/dot/$name"
done done
cp "$repo_dir/setup/scripts/link-vicinae-scripts" "$repair_root/setup/scripts/link-vicinae-scripts"
mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe" mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe"
cat >"$bin_dir/systemctl" <<'EOF' cat >"$bin_dir/systemctl" <<'EOF'
@@ -266,51 +293,59 @@ printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
EOF EOF
cat >"$bin_dir/kill" <<'EOF'
#!/usr/bin/bash
set -euo pipefail
printf 'kill' >>"$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' cat >"$bin_dir/systemd-inhibit" <<'EOF'
#!/usr/bin/bash #!/usr/bin/bash
set -euo pipefail set -euo pipefail
printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log" printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log"
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
printf '\n' >>"$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)" uid="$(/usr/bin/id -u)"
printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid" mode="$(<"$XDG_RUNTIME_DIR/caffeine-mode")"
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid" 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 '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 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 4997 systemd-inhibit sleep:idle Other block\n' "$uid"
printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid" printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid"
EOF EOF
cat >"$repair_root/setup/scripts/link-vicinae-scripts" <<'EOF' chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \
#!/usr/bin/bash
set -euo pipefail
printf 'link-vicinae-scripts|%s' "$0" >>"$XDG_RUNTIME_DIR/repair.log"
if (( $# > 0 )); then
printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log"
fi
printf '\n' >>"$XDG_RUNTIME_DIR/repair.log"
EOF
chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" "$bin_dir/kill" \
"$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts" "$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts"
run_repair() { run_repair() {
HOME="$home" \ HOME="$home" \
PATH="$bin_dir" \ PATH="$bin_dir:/usr/bin" \
XDG_CURRENT_DESKTOP=Hyprland \ XDG_CURRENT_DESKTOP=Hyprland \
PANAMA_DOCTOR_ROOT="$repair_root" \ PANAMA_DOCTOR_ROOT="$repair_root" \
PANAMA_DOCTOR_HOME="$home" \ PANAMA_DOCTOR_HOME="$home" \
PANAMA_DOCTOR_CONFIG_HOME="$config_home" \ PANAMA_DOCTOR_CONFIG_HOME="$config_home" \
PANAMA_DOCTOR_STATE_HOME="$state_home" \ PANAMA_DOCTOR_STATE_HOME="$state_home" \
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \ PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
PANAMA_DOCTOR_PATH="$bin_dir" \ PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \
PANAMA_DOCTOR_TIMEOUT=0.2 \ PANAMA_DOCTOR_TIMEOUT=0.2 \
/usr/bin/python3 "$doctor" "$@" /usr/bin/python3 "$doctor" "$@"
} }
@@ -364,58 +399,327 @@ assert_repair_result desktop.vicinae true 5
[[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \ [[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \
|| fail 'failed repair changed the authored argv' || fail 'failed repair changed the authored argv'
# The Vicinae repair executes only the authored setup helper with no arguments. # The real authored Vicinae helper converges the exact child link diagnosed by
: >"$repair_log" # 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 invoke_repair panama.vicinae-commands
[[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status" [[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status"
assert_repair_result panama.vicinae-commands true 0 assert_repair_result panama.vicinae-commands true 0
[[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \ after_vicinae_repair="$(run_repair --json)"
|| fail "Vicinae command repair argv was not exact: $(<"$repair_log")" 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 the four authored symlink names. Broken # Runtime-link repair may replace only absent links or symlinks whose lexical
# or absent links are recreated toward authored tracked destinations; regular # target proves Panama ownership. Every other object remains untouched.
# files and directories remain untouched and make the result incomplete.
for name in hypr quickshell uwsm vicinae; do for name in hypr quickshell uwsm vicinae; do
path="$config_home/$name" path="$config_home/$name"
if [[ -e "$path" || -L "$path" ]]; then if [[ -e "$path" || -L "$path" ]]; then
mv "$path" "$fixture/pre-repair-$name" mv "$path" "$fixture/pre-repair-$name"
fi fi
done done
ln -s "$fixture/missing-hypr" "$config_home/hypr" ln -s "$repair_root/config/dot/hypr" "$config_home/hypr"
ln -s "$fixture/missing-quickshell" "$config_home/quickshell" correct_inode="$(stat -c %i "$config_home/hypr")"
printf 'user-owned file\n' >"$config_home/uwsm" ln -s "$repair_root/config/dot/quickshell" "$config_home/uwsm"
mkdir "$config_home/vicinae" ln -s "$fixture/external-broken-link" "$config_home/vicinae"
ln -s "$fixture/untouched" "$config_home/not-panama" ln -s "$fixture/untouched" "$config_home/not-panama"
: >"$repair_log" : >"$repair_log"
invoke_repair panama.runtime-links invoke_repair panama.runtime-links
[[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status" [[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status"
assert_repair_result panama.runtime-links true 1 assert_repair_result panama.runtime-links true 1
[[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \ [[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \
|| fail 'hypr link was not recreated toward its authored destination' || 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" ]] \ [[ -L "$config_home/quickshell" && "$(readlink "$config_home/quickshell")" == "$repair_root/config/dot/quickshell" ]] \
|| fail 'quickshell link was not recreated toward its authored destination' || fail 'absent quickshell link was not created'
[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \ [[ -L "$config_home/uwsm" && "$(readlink "$config_home/uwsm")" == "$repair_root/config/dot/uwsm" ]] \
|| fail 'runtime-link repair replaced a regular file' || fail 'provably Panama-owned stale link was not repaired'
[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ [[ -L "$config_home/vicinae" && "$(readlink "$config_home/vicinae")" == "$fixture/external-broken-link" ]] \
|| fail 'runtime-link repair replaced a user-owned directory' || fail 'external broken symlink was replaced'
[[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \ [[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \
|| fail 'runtime-link repair touched an unauthored link name' || fail 'runtime-link repair touched an unauthored link name'
[[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process' [[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process'
# Caffeine repair parses exact authored metadata, keeps the first valid lock, # Regular files and directories also remain untouched.
# and releases only later exact matches. 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" : >"$repair_log"
invoke_repair panama.caffeine invoke_repair panama.caffeine
[[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status" [[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status"
assert_repair_result panama.caffeine true 0 assert_repair_result panama.caffeine true 0
expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nkill|--|4102' expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nsystemd-inhibit|--list|--no-pager|--no-legend'
[[ "$(<"$repair_log")" == "$expected_caffeine" ]] \ [[ "$(<"$repair_log")" == "$expected_caffeine" ]] \
|| fail "Caffeine repair did not preserve/filter exact inhibitors: $(<"$repair_log")" || 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 # Rejected IDs are complete JSON, exit 2, and cause neither a process launch
# nor a filesystem mutation. # nor a filesystem mutation.
fixture_state() { fixture_state() {
find "$config_home" -mindepth 1 -printf '%P|%y|%l\n' | sort | sha256sum | awk '{print $1}' /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 \ for rejected_id in unknown.check integration.home-assistant input.brightness \
desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# A setting has one schema-routed owner. A second page may mirror it only when
# this contract names the exact owner and mirror set. The same ownership rule
# keeps colour scheme propagation away from the focused Prism border.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
looks="$repo_dir/config/dot/hypr/looks.lua"
readme="$pages_dir/README.md"
fail() {
printf 'settings ownership contract: %s\n' "$1" >&2
exit 1
}
python3 - "$pages_dir" "$schema" "$search" <<'PY' \
|| fail 'page ownership or intentional mirrors drifted'
from __future__ import annotations
import re
import sys
from collections import defaultdict
from pathlib import Path
pages_dir = Path(sys.argv[1])
schema_text = Path(sys.argv[2]).read_text(encoding="utf-8")
search_text = Path(sys.argv[3]).read_text(encoding="utf-8")
expected = {
"animationsEnabled": {"owner": "appearance", "mirrors": {"accessibility"}},
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
"lockMinutes": {"owner": "power", "mirrors": {"privacy"}},
"lockOnSleep": {"owner": "power", "mirrors": {"privacy"}},
}
def strip_comments(text: str) -> str:
return re.sub(r"//.*", "", text)
def page_name(path: Path) -> str:
stem = path.stem.removesuffix("Page")
return re.sub(r"(?<!^)(?=[A-Z])", "-", stem).lower()
rows: dict[str, list[str]] = defaultdict(list)
row_pattern = re.compile(
r"(?:ToggleRow|SliderRow|ChoiceRow|TextEntryRow|TimeOfDayRow)\s*\{(?P<body>.*?)\}",
re.S,
)
for page_path in pages_dir.glob("*Page.qml"):
text = strip_comments(page_path.read_text(encoding="utf-8"))
for match in row_pattern.finditer(text):
setting = re.search(r'setting\s*:\s*"([^"]+)"', match.group("body"))
if setting:
rows[setting.group(1)].append(page_name(page_path))
duplicates = {key: set(pages) for key, pages in rows.items() if len(pages) > 1}
if set(duplicates) != set(expected):
raise SystemExit(
f"duplicate keys are {sorted(duplicates)}, expected {sorted(expected)}"
)
group_pages = dict(re.findall(r'"([^"]+)"\s*:\s*"([^"]+)"', search_text))
for key, policy in expected.items():
wanted_pages = {policy["owner"], *policy["mirrors"]}
if duplicates[key] != wanted_pages:
raise SystemExit(f"{key} appears on {sorted(duplicates[key])}, expected {sorted(wanted_pages)}")
block = re.search(
r'\{\s*\n\s*key:\s*"' + re.escape(key) + r'"(?P<body>.*?)\n\s*\}',
schema_text,
re.S,
)
if not block:
raise SystemExit(f"schema entry missing for {key}")
group = re.search(r'group:\s*"([^"]+)"', block.group("body"))
if not group:
raise SystemExit(f"schema group missing for {key}")
routed = group_pages.get(group.group(1))
if routed != policy["owner"]:
raise SystemExit(
f"{key} routes to {routed!r}, expected primary owner {policy['owner']!r}"
)
PY
for needle in \
'## Setting ownership' \
'one primary page' \
'Intentional mirrors' \
'`animationsEnabled`' \
'`cursorInactiveTimeout`' \
'`cursorSize`' \
'`inactiveOpacity`' \
'`lockMinutes`' \
'`lockOnSleep`' \
'scheme-relative role'; do
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
done
python3 - "$scheme" "$looks" <<'PY' \
|| fail 'scheme-relative border ownership drifted'
import re
import sys
scheme = open(sys.argv[1], encoding="utf-8").read()
looks = open(sys.argv[2], encoding="utf-8").read()
dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme)
light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme)
effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme)
if not dark or not light or not effective:
raise SystemExit("ColorScheme does not expose the two inactive-border roles")
if dark.group(1) not in looks or light.group(1) not in looks:
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
without_comments = re.sub(r"//.*", "", scheme)
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
raise SystemExit("ColorScheme writes the focused border")
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
raise SystemExit("ColorScheme does not apply its effective inactive role")
PY
printf 'settings ownership contract: PASS\n'
+134 -23
View File
@@ -85,8 +85,13 @@ fi
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell" source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell" config_path="$state_home/quickshell"
harness="$config_path/settings-pages-harness.qml"
test_bin="$state_home/bin" test_bin="$state_home/bin"
shell_log="$state_home/quickshell.log" shell_log="$state_home/quickshell.log"
production_config_path="$HOME/.config/quickshell/shell.qml"
harness_pid=""
harness_shell_id=""
production_before=""
cleanup_bootstrap() { cleanup_bootstrap() {
rm -rf "$state_home" rm -rf "$state_home"
@@ -95,6 +100,21 @@ trap cleanup_bootstrap EXIT
mkdir -p "$test_bin" mkdir -p "$test_bin"
cp -a "$source_config_path" "$config_path" 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' cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -138,52 +158,141 @@ EOF
chmod +x "$test_bin/flatpak" chmod +x "$test_bin/flatpak"
qs_for_test() { qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
qs -p "$config_path" "$@" 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() { instances_for_path() {
qs_for_test kill >/dev/null 2>&1 || true local expected_path="$1" listing
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ listing="$(qs list --all 2>/dev/null)" || return 1
&& ! qs_for_test ipc show >/dev/null 2>&1; then awk -v expected="$expected_path" '
return 0 /^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
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
sleep 0.05
done
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
sleep 0.1 fi
done remaining="$(instances_for_path "$harness")" || return 1
return 1 harness_pid=""
harness_shell_id=""
[[ -z "$remaining" ]]
} }
cleanup() { cleanup() {
qs_for_test ipc call settings close >/dev/null 2>&1 || true local cleanup_ok=0
if stop_test_shell; then
stop_harness || cleanup_ok=1
production_is_preserved || cleanup_ok=1
if (( cleanup_ok == 0 )); then
rm -rf "$state_home" rm -rf "$state_home"
else 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 "$state_home" >&2
fi fi
return "$cleanup_ok"
} }
trap cleanup EXIT trap cleanup EXIT
start_test_shell() { 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 for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1 qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then harness_instances="$(instances_for_path "$harness")" \
return || fail 'could not inspect the runtime harness instance'
fi [[ -n "$harness_instances" ]] && break
sleep 0.1 sleep 0.05
done done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' 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
fi
stop_harness || fail 'failed runtime harness attempt did not stop cleanly'
done done
sed -n '1,200p' "$shell_log" >&2 sed -n '1,200p' "$shell_log" >&2
fail 'isolated branch shell did not start' 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 start_test_shell
qs_for_test ipc call home-assistant fixture ready >/dev/null 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' [[ "$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) 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' desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid'
trap - EXIT 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' [[ ! -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"
+46
View File
@@ -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'