Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccf46c40ef |
@@ -37,31 +37,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+36
-11
@@ -36,7 +36,9 @@ hl.config({
|
|||||||
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 +46,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 +91,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 +122,27 @@ 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),
|
||||||
|
|
||||||
-- 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 +197,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",
|
||||||
|
|||||||
@@ -131,7 +131,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 +146,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 }
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ 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);
|
||||||
@@ -163,11 +162,6 @@ 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)
|
||||||
@@ -390,40 +384,14 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Fedora system settings"
|
title: "Fedora system settings"
|
||||||
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
|
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."
|
||||||
|
|
||||||
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"
|
||||||
@@ -431,7 +399,6 @@ 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"
|
||||||
@@ -439,7 +406,6 @@ 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"
|
||||||
@@ -447,7 +413,6 @@ 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"
|
||||||
|
|||||||
@@ -4,33 +4,6 @@ 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.
|
||||||
@@ -110,12 +83,11 @@ 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 content-identical Quickshell entry can share the live shell's ID.**
|
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
|
||||||
Quickshell derives the Shell ID from config *content*, not path. Runtime
|
derives the Shell ID from config *content*, not path, so
|
||||||
harnesses therefore create a distinct semantic entry file, address that exact
|
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
|
||||||
file with `qs -p`, and discover its PID from the exact Config path in
|
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
|
||||||
`qs list --all`. They terminate only that recorded PID with `kill`; never use
|
single distinct `.qml` file are safe; copying the whole directory is not.
|
||||||
`qs kill` from a copied configuration.
|
|
||||||
|
|
||||||
## Where state lives
|
## Where state lives
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,9 @@
|
|||||||
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
|
||||||
@@ -25,11 +21,6 @@ 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)
|
||||||
@@ -418,13 +409,8 @@ 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/panama"
|
installed = config.home / ".local/share/vicinae/scripts"
|
||||||
try:
|
if source.is_dir() and installed.is_symlink() and installed.exists():
|
||||||
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"))
|
||||||
|
|
||||||
@@ -459,10 +445,20 @@ 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.")
|
||||||
inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid()))
|
uid = str(os.getuid())
|
||||||
if inhibitor_rows is None:
|
inhibitors = 0
|
||||||
|
malformed = False
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts
|
||||||
|
if not relevant:
|
||||||
|
continue
|
||||||
|
if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]:
|
||||||
|
inhibitors += 1
|
||||||
|
else:
|
||||||
|
malformed = True
|
||||||
|
if malformed:
|
||||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
|
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:
|
||||||
@@ -539,138 +535,10 @@ 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:
|
||||||
root = lexical_path(config.root)
|
sources = [(name, config.root / relative_source) for name, relative_source in RUNTIME_LINK_TARGETS]
|
||||||
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)
|
||||||
@@ -683,30 +551,23 @@ 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():
|
||||||
current_target = lexical_link_target(destination)
|
if destination.resolve(strict=False) == source.resolve(strict=True):
|
||||||
if current_target == source:
|
|
||||||
continue
|
continue
|
||||||
if current_target not in authored_sources:
|
destination.unlink()
|
||||||
blocked = True
|
destination.symlink_to(source, target_is_directory=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:
|
||||||
outcome = install_absent_symlink(destination, source)
|
destination.symlink_to(source, target_is_directory=True)
|
||||||
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 runtime path is blocking a Panama link.")
|
return RepairResult("panama.runtime-links", True, 1, "A user-owned file or directory is blocking a Panama runtime link.")
|
||||||
return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.")
|
return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.")
|
||||||
|
|
||||||
|
|
||||||
@@ -720,57 +581,6 @@ 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)
|
||||||
@@ -778,39 +588,26 @@ 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_rows = parse_caffeine_rows(output, uid)
|
inhibitor_pids: list[str] = []
|
||||||
if inhibitor_rows is None:
|
for line in output.splitlines():
|
||||||
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
parts = line.split()
|
||||||
inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows))
|
if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid:
|
||||||
|
continue
|
||||||
|
if len(parts) != 8:
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
||||||
|
if parts[6] != "Caffeine" or parts[7] != "block":
|
||||||
|
continue
|
||||||
|
if not parts[3].isdecimal():
|
||||||
|
return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.")
|
||||||
|
inhibitor_pids.append(parts[3])
|
||||||
|
|
||||||
if len(inhibitor_pids) <= 1:
|
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.")
|
||||||
|
|
||||||
duplicates = inhibitor_pids[1:]
|
for pid in inhibitor_pids[1:]:
|
||||||
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
|
exit_code, _ = run_repair_command(("kill", "--", pid), config)
|
||||||
return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.")
|
if exit_code != 0:
|
||||||
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ 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]]
|
||||||
|
|
||||||
|
|
||||||
@@ -110,13 +107,6 @@ 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],
|
||||||
*,
|
*,
|
||||||
@@ -189,82 +179,6 @@ 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(
|
||||||
@@ -286,24 +200,15 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
|||||||
continue
|
continue
|
||||||
device_id = match.group("id")
|
device_id = match.group("id")
|
||||||
try:
|
try:
|
||||||
plugins = device_plugins(device_id, runner)
|
device = normalize_device_line(
|
||||||
device_type = reported_type(device_id, runner)
|
line,
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
device_plugins(device_id, runner),
|
||||||
plugins = []
|
reported_type(device_id, runner),
|
||||||
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"]),
|
||||||
|
|||||||
@@ -33,16 +33,11 @@ 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 fallback;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var adapter: Bluetooth.defaultAdapter
|
readonly property var adapter: Bluetooth.defaultAdapter
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ 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;
|
||||||
@@ -241,8 +239,6 @@ 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 = "";
|
||||||
|
|||||||
@@ -36,6 +36,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,20 +496,7 @@ 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, controller-deferred live audit, and documentation
|
### Task 7: Full verification, live read-only 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`
|
||||||
@@ -518,11 +505,9 @@ doctor/IPC comparison. Those checks remain for the controller after code review.
|
|||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: the complete feature and existing regression suite.
|
- Consumes: the complete feature and existing regression suite.
|
||||||
- Produces: current user documentation and final contract evidence. The
|
- Produces: current user documentation, a redacted live health snapshot, and final verification evidence.
|
||||||
redacted live health snapshot and live shell audit are deferred to the
|
|
||||||
controller after code review.
|
|
||||||
|
|
||||||
- [x] **Step 1: Document boundaries and entry points**
|
- [ ] **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.
|
||||||
|
|
||||||
@@ -540,21 +525,9 @@ 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. After the latest Settings and installer work,
|
Expected: every command exits 0; Quickshell tests report 58 contracts after the three new contracts land.
|
||||||
the current inventory is 66 Quickshell contracts and 2 Hyprland contracts (the original
|
|
||||||
pre-merge estimate was 58).
|
|
||||||
|
|
||||||
Integration result: syntax and all focused Health/Settings contracts pass. Two
|
- [ ] **Step 3: Run a redacted live read-only comparison**
|
||||||
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:
|
||||||
|
|
||||||
@@ -568,10 +541,7 @@ 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: Controller reloads and inspects the live shell**
|
- [ ] **Step 4: Reload and inspect the live shell**
|
||||||
|
|
||||||
Deferred to the controller after code review; Task 7 does not reload or inspect
|
|
||||||
the daily-driver shell.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,10 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
source_harness="$repo_dir/config/dot/quickshell/health-harness.qml"
|
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"
|
||||||
@@ -45,7 +33,7 @@ fail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||||
[[ -f "$source_harness" ]] || fail 'health harness is missing'
|
[[ -f "$harness" ]] || fail 'health harness is missing'
|
||||||
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
||||||
|
|
||||||
# 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
|
||||||
@@ -84,41 +72,12 @@ 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' \
|
||||||
@@ -128,12 +87,8 @@ 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 ;;' \
|
||||||
@@ -141,10 +96,6 @@ 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"
|
||||||
@@ -159,57 +110,12 @@ 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" \
|
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" qs -p "$harness" "$@"
|
||||||
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() {
|
||||||
: >"$repair_release_file"
|
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
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
|
||||||
@@ -217,7 +123,6 @@ 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
|
||||||
@@ -225,9 +130,6 @@ 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'
|
||||||
@@ -284,21 +186,14 @@ 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" \
|
||||||
@@ -348,52 +243,12 @@ 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 "$confirm_generation" \
|
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$mismatch_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'
|
||||||
|
|||||||
@@ -58,19 +58,11 @@ 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'
|
||||||
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|
# The Fedora hand-off is a row per subject, not one button that opened the
|
||||||
|| fail 'Fedora ownership boundary does not open GNOME Settings'
|
# network panel whatever it was labelled. What matters is that each row reaches
|
||||||
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
|
# the panel that owns it, so this checks the boundary still exists and that
|
||||||
|| fail 'Fedora ownership boundary lost the Users handoff'
|
# every panel it names is one openGnomePanel accepts -- a name outside that
|
||||||
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|
# allow-list opens nothing and reports an error, i.e. a dead button.
|
||||||
|| 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'
|
||||||
|
|
||||||
@@ -304,12 +296,6 @@ 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))
|
||||||
|
|||||||
@@ -130,134 +130,6 @@ 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()
|
||||||
|
|||||||
@@ -16,15 +16,7 @@ fail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
|
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
|
||||||
child_pids=()
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
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"
|
||||||
@@ -33,7 +25,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/scripts"
|
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae"
|
||||||
cp "$fixture_root/bin/"* "$bin_dir/"
|
cp "$fixture_root/bin/"* "$bin_dir/"
|
||||||
chmod +x "$bin_dir"/*
|
chmod +x "$bin_dir"/*
|
||||||
|
|
||||||
@@ -89,7 +81,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/panama"
|
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts"
|
||||||
|
|
||||||
run_doctor() {
|
run_doctor() {
|
||||||
HOME="$home" \
|
HOME="$home" \
|
||||||
@@ -138,20 +130,6 @@ 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
|
||||||
@@ -259,14 +237,12 @@ 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="$home/.local/share/Panama"
|
repair_root="$fixture/repair-root"
|
||||||
repair_log="$runtime_dir/repair.log"
|
repair_log="$runtime_dir/repair.log"
|
||||||
mkdir -p "$repair_root/config/dot" "$repair_root/config/local/share/vicinae/scripts" \
|
mkdir -p "$repair_root/config/dot" "$repair_root/setup/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'
|
||||||
@@ -290,59 +266,51 @@ 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)"
|
||||||
mode="$(<"$XDG_RUNTIME_DIR/caffeine-mode")"
|
printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||||
if [[ "$mode" == disappear && "$count" -ge 2 ]]; then
|
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||||
/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
|
||||||
|
|
||||||
chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \
|
cat >"$repair_root/setup/scripts/link-vicinae-scripts" <<'EOF'
|
||||||
|
#!/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:/usr/bin" \
|
PATH="$bin_dir" \
|
||||||
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:/usr/bin" \
|
PANAMA_DOCTOR_PATH="$bin_dir" \
|
||||||
PANAMA_DOCTOR_TIMEOUT=0.2 \
|
PANAMA_DOCTOR_TIMEOUT=0.2 \
|
||||||
/usr/bin/python3 "$doctor" "$@"
|
/usr/bin/python3 "$doctor" "$@"
|
||||||
}
|
}
|
||||||
@@ -396,327 +364,58 @@ 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 real authored Vicinae helper converges the exact child link diagnosed by
|
# The Vicinae repair executes only the authored setup helper with no arguments.
|
||||||
# panama-doctor under the isolated HOME.
|
: >"$repair_log"
|
||||||
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
|
||||||
after_vicinae_repair="$(run_repair --json)"
|
[[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \
|
||||||
check_status "$after_vicinae_repair" panama.vicinae-commands ok
|
|| fail "Vicinae command repair argv was not exact: $(<"$repair_log")"
|
||||||
[[ -L "$data_home/vicinae/scripts/panama" \
|
|
||||||
&& "$(readlink "$data_home/vicinae/scripts/panama")" == "$repair_root/config/local/share/vicinae/scripts" ]] \
|
|
||||||
|| fail 'Vicinae repair did not install the diagnosed child link'
|
|
||||||
|
|
||||||
# Runtime-link repair may replace only absent links or symlinks whose lexical
|
# Runtime-link repair may replace only the four authored symlink names. Broken
|
||||||
# target proves Panama ownership. Every other object remains untouched.
|
# or absent links are recreated toward authored tracked destinations; regular
|
||||||
|
# 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 "$repair_root/config/dot/hypr" "$config_home/hypr"
|
ln -s "$fixture/missing-hypr" "$config_home/hypr"
|
||||||
correct_inode="$(stat -c %i "$config_home/hypr")"
|
ln -s "$fixture/missing-quickshell" "$config_home/quickshell"
|
||||||
ln -s "$repair_root/config/dot/quickshell" "$config_home/uwsm"
|
printf 'user-owned file\n' >"$config_home/uwsm"
|
||||||
ln -s "$fixture/external-broken-link" "$config_home/vicinae"
|
mkdir "$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 'correct runtime link changed'
|
|| fail 'hypr link was not recreated toward its authored destination'
|
||||||
[[ "$(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 'absent quickshell link was not created'
|
|| fail 'quickshell link was not recreated toward its authored destination'
|
||||||
[[ -L "$config_home/uwsm" && "$(readlink "$config_home/uwsm")" == "$repair_root/config/dot/uwsm" ]] \
|
|
||||||
|| fail 'provably Panama-owned stale link was not repaired'
|
|
||||||
[[ -L "$config_home/vicinae" && "$(readlink "$config_home/vicinae")" == "$fixture/external-broken-link" ]] \
|
|
||||||
|| fail 'external broken symlink was replaced'
|
|
||||||
[[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \
|
|
||||||
|| fail 'runtime-link repair touched an unauthored link name'
|
|
||||||
[[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process'
|
|
||||||
|
|
||||||
# Regular files and directories also remain untouched.
|
|
||||||
rm "$config_home/vicinae"
|
|
||||||
rm "$config_home/uwsm"
|
|
||||||
printf 'user-owned file\n' >"$config_home/uwsm"
|
|
||||||
mkdir "$config_home/vicinae"
|
|
||||||
invoke_repair panama.runtime-links
|
|
||||||
[[ "$repair_status" == 1 ]] || fail 'file/directory blockers did not make repair incomplete'
|
|
||||||
[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \
|
[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \
|
||||||
|| fail 'runtime-link repair replaced a regular file'
|
|| fail 'runtime-link repair replaced a regular file'
|
||||||
[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \
|
[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \
|
||||||
|| fail 'runtime-link repair replaced a user-owned directory'
|
|| fail 'runtime-link repair replaced a user-owned directory'
|
||||||
|
[[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \
|
||||||
|
|| fail 'runtime-link repair touched an unauthored link name'
|
||||||
|
[[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process'
|
||||||
|
|
||||||
# An injected exchange failure occurs after the authored candidate symlink is
|
# Caffeine repair parses exact authored metadata, keeps the first valid lock,
|
||||||
# made; the original link must still be intact.
|
# and releases only later exact matches.
|
||||||
/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\nsystemd-inhibit|--list|--no-pager|--no-legend'
|
expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nkill|--|4102'
|
||||||
[[ "$(<"$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() {
|
||||||
/usr/bin/python3 - "$fixture" <<'PY'
|
find "$config_home" -mindepth 1 -printf '%P|%y|%l\n' | sort | sha256sum | awk '{print $1}'
|
||||||
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
|
||||||
|
|||||||
@@ -85,13 +85,8 @@ 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"
|
||||||
@@ -100,21 +95,6 @@ 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
|
||||||
@@ -158,141 +138,52 @@ EOF
|
|||||||
chmod +x "$test_bin/flatpak"
|
chmod +x "$test_bin/flatpak"
|
||||||
|
|
||||||
qs_for_test() {
|
qs_for_test() {
|
||||||
if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
|
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||||
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
qs -p "$config_path" "$@"
|
||||||
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
|
||||||
else
|
|
||||||
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
||||||
qs -p "$harness" "$@"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
instances_for_path() {
|
stop_test_shell() {
|
||||||
local expected_path="$1" listing
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
listing="$(qs list --all 2>/dev/null)" || return 1
|
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||||
awk -v expected="$expected_path" '
|
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||||
/^Instance / { pid = ""; shell_id = "" }
|
return 0
|
||||||
/^[[: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
|
||||||
fi
|
sleep 0.1
|
||||||
remaining="$(instances_for_path "$harness")" || return 1
|
done
|
||||||
harness_pid=""
|
return 1
|
||||||
harness_shell_id=""
|
|
||||||
[[ -z "$remaining" ]]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local cleanup_ok=0
|
qs_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||||
|
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: isolated harness cleanup failed; retained %s\n' \
|
printf 'settings pages contract: branch shell did not stop; 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() {
|
||||||
local harness_instances production_pid production_shell_id
|
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||||
|
|
||||||
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
|
||||||
harness_instances="$(instances_for_path "$harness")" \
|
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
||||||
|| fail 'could not inspect the runtime harness instance'
|
return
|
||||||
[[ -n "$harness_instances" ]] && break
|
fi
|
||||||
sleep 0.05
|
sleep 0.1
|
||||||
done
|
done
|
||||||
if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then
|
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||||
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="$harness_pid"
|
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
[[ "$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)
|
||||||
@@ -327,8 +218,6 @@ 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 || fail 'runtime harness did not stop without disturbing production'
|
cleanup
|
||||||
[[ ! -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'
|
||||||
production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)"
|
printf 'settings pages contract: PASS\n'
|
||||||
[[ -n "$production_pids" ]] || production_pids="none"
|
|
||||||
printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user