Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21aa9223df | ||
|
|
e2e03252e4 | ||
|
|
0ca83f74c7 | ||
|
|
d58c431199 | ||
|
|
77f8102148 | ||
|
|
24d8bfd641 | ||
|
|
af0ba13573 | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 |
@@ -13,3 +13,6 @@
|
||||
# Python helper bytecode is local runtime state.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
@@ -183,7 +183,6 @@ font_size 14.0
|
||||
|
||||
#: Cursor customization {{{
|
||||
|
||||
cursor #c8d3f5
|
||||
|
||||
#: Default cursor color. If set to the special value none the cursor
|
||||
#: will be rendered with a "reverse video" effect. It's color will be
|
||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
||||
#: precedence. Also, the cursor colors are modified if the cell
|
||||
#: background and foreground colors have very low contrast.
|
||||
|
||||
cursor_text_color #111325
|
||||
|
||||
#: The color of text under the cursor. If you want it rendered with
|
||||
#: the background color of the cell underneath instead, use the
|
||||
@@ -312,7 +310,6 @@ cursor_shape block
|
||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||
#: much effort.
|
||||
|
||||
url_color #4fd6be
|
||||
# url_style curly
|
||||
|
||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
||||
#: placed centrally. A value of top-left means the padding will be
|
||||
#: only at the bottom and right edges.
|
||||
|
||||
active_border_color #82aaff
|
||||
|
||||
#: The color for the border of the active window. Set this to none to
|
||||
#: not draw borders around the active window.
|
||||
|
||||
inactive_border_color #2f334d
|
||||
|
||||
#: The color for the border of inactive windows.
|
||||
|
||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
||||
#: Template to use for active tabs. If not specified falls back to
|
||||
#: tab_title_template.
|
||||
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
# active_tab_font_style bold-italic
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
# inactive_tab_font_style normal
|
||||
|
||||
#: Tab bar colors and styles.
|
||||
|
||||
tab_bar_background #222436
|
||||
|
||||
#: Background color for the tab bar. Defaults to using the terminal
|
||||
#: background color.
|
||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
||||
|
||||
#: Color scheme {{{
|
||||
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
|
||||
include current-theme.conf
|
||||
|
||||
#: The foreground and background colors.
|
||||
|
||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
|
||||
#: The foreground and background colors for text selected with the
|
||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
||||
#: dull and bright version, for the first 16 colors. You can set the
|
||||
#: remaining 240 colors as color16 to color255.
|
||||
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
|
||||
#: black
|
||||
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
|
||||
#: red
|
||||
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
|
||||
#: green
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
|
||||
#: yellow
|
||||
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
|
||||
#: blue
|
||||
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
|
||||
#: magenta
|
||||
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
|
||||
#: cyan
|
||||
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
|
||||
#: white
|
||||
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
|
||||
# mark1_foreground black
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tokyo Night Day — the palette's own light variant.
|
||||
#
|
||||
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||
# still belongs to this desktop rather than looking like a different machine.
|
||||
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||
|
||||
cursor #3760bf
|
||||
cursor_text_color #e1e2e7
|
||||
url_color #118c74
|
||||
active_border_color #2e7de9
|
||||
inactive_border_color #c4c8da
|
||||
active_tab_foreground #e1e2e7
|
||||
active_tab_background #2e7de9
|
||||
inactive_tab_foreground #3760bf
|
||||
inactive_tab_background #c4c8da
|
||||
tab_bar_background #e1e2e7
|
||||
foreground #3760bf
|
||||
background #e1e2e7
|
||||
selection_foreground #e1e2e7
|
||||
selection_background #99a7df
|
||||
color0 #e9e9ed
|
||||
color8 #a1a6c5
|
||||
color1 #f52a65
|
||||
color9 #f52a65
|
||||
color2 #587539
|
||||
color10 #587539
|
||||
color3 #8c6c3e
|
||||
color11 #8c6c3e
|
||||
color4 #2e7de9
|
||||
color12 #2e7de9
|
||||
color5 #9854f1
|
||||
color13 #9854f1
|
||||
color6 #007197
|
||||
color14 #007197
|
||||
color7 #6172b0
|
||||
color15 #3760bf
|
||||
color16 #b15c00
|
||||
color17 #c64343
|
||||
@@ -0,0 +1,42 @@
|
||||
# Tokyo Night Moon — the dark theme Panama ships.
|
||||
#
|
||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||
# setting; that generated file is gitignored because it is machine state.
|
||||
#
|
||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||
# remote control. Without it a scheme change would only reach terminals opened
|
||||
# afterwards.
|
||||
|
||||
cursor #c8d3f5
|
||||
cursor_text_color #111325
|
||||
url_color #4fd6be
|
||||
active_border_color #82aaff
|
||||
inactive_border_color #2f334d
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
tab_bar_background #222436
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
vim.fn.winrestview(view)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||
require("config.panama").watch()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Panama desktop integration.
|
||||
--
|
||||
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||
-- has a control socket open by default, so it reads the shared settings file
|
||||
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||
-- Hyprland config read.
|
||||
--
|
||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||
-- user their colour scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function settings_path()
|
||||
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||
if config_home == nil or config_home == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return nil
|
||||
end
|
||||
config_home = home .. "/.config"
|
||||
end
|
||||
return config_home .. "/panama/settings.json"
|
||||
end
|
||||
|
||||
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||
function M.color_scheme()
|
||||
local path = settings_path()
|
||||
if not path then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local ok, contents = pcall(function()
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return nil
|
||||
end
|
||||
local text = file:read("*a")
|
||||
file:close()
|
||||
return text
|
||||
end)
|
||||
if not ok or not contents or contents == "" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||
if not decoded_ok or type(decoded) ~= "table" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
return decoded.colorScheme == "light" and "light" or "dark"
|
||||
end
|
||||
|
||||
function M.is_light()
|
||||
return M.color_scheme() == "light"
|
||||
end
|
||||
|
||||
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||
-- same identity rather than becoming a different editor theme.
|
||||
function M.tokyonight_style()
|
||||
return M.is_light() and "day" or "moon"
|
||||
end
|
||||
|
||||
-- Re-apply the scheme when the window regains focus.
|
||||
--
|
||||
-- Neovim reads the setting once at startup and has no control socket open by
|
||||
-- default, so an editor already running when the desktop scheme flips would
|
||||
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||
-- at all unless the scheme actually changed.
|
||||
function M.watch()
|
||||
local applied = M.color_scheme()
|
||||
|
||||
vim.api.nvim_create_autocmd("FocusGained", {
|
||||
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||
callback = function()
|
||||
local current = M.color_scheme()
|
||||
if current == applied then
|
||||
return
|
||||
end
|
||||
applied = current
|
||||
vim.o.background = current
|
||||
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,31 +1,68 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
--
|
||||
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||
-- becoming a different-looking application when the scheme flips.
|
||||
--
|
||||
-- The readability fixes below are deliberately dark-only. They were written
|
||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||
-- applying them to Day would put light grey text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
return {
|
||||
{
|
||||
"folke/tokyonight.nvim",
|
||||
opts = {
|
||||
style = "moon",
|
||||
opts = function()
|
||||
local light = panama.is_light()
|
||||
|
||||
return {
|
||||
style = panama.tokyonight_style(),
|
||||
light_style = "day",
|
||||
transparent = true,
|
||||
|
||||
on_colors = function(colors)
|
||||
if light then
|
||||
-- Day's defaults are tuned for a light ground and mostly need no
|
||||
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||
colors.comment = "#7079a8"
|
||||
return
|
||||
end
|
||||
|
||||
colors.comment = "#a0a7c5"
|
||||
colors.fg_gutter = "#787f93"
|
||||
colors.terminal_black = "#828bb8"
|
||||
end,
|
||||
|
||||
on_highlights = function(highlights, colors)
|
||||
-- Fix inline code visibility in markdown
|
||||
-- Inline code in markdown is invisible at both lightnesses without
|
||||
-- an explicit background, because the theme leaves it unset.
|
||||
highlights["@markup.raw.markdown_inline"] = {
|
||||
bg = colors.terminal_black,
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["RenderMarkdownCodeInline"] = {
|
||||
bg = colors.terminal_black,
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's background
|
||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
|
||||
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's
|
||||
-- background.
|
||||
local reference = {
|
||||
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["LspReferenceText"] = reference
|
||||
highlights["LspReferenceRead"] = reference
|
||||
highlights["LspReferenceWrite"] = reference
|
||||
end,
|
||||
}
|
||||
end,
|
||||
},
|
||||
},
|
||||
{
|
||||
"LazyVim/LazyVim",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
// The production singleton still performs its delayed startup scan. The
|
||||
// harness turns it off before its 2200 ms deadline so each fixture drives
|
||||
// only the state transition it is asserting.
|
||||
Component.onCompleted: Health.startupScanEnabled = false
|
||||
|
||||
IpcHandler {
|
||||
target: "health-test"
|
||||
|
||||
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||
function repair(id: string): bool { return Health.repair(id, false); }
|
||||
function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
|
||||
function copy(): bool { return Health.copyReport(); }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,44 @@
|
||||
// Backlight slider, via brightnessctl.
|
||||
// Brightness, from whichever source this machine actually has.
|
||||
//
|
||||
// This machine drives an external DisplayPort monitor and has no backlight
|
||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
||||
// row removes itself rather than sitting there as a dead control. Probed once
|
||||
// at startup — backlight devices do not appear and disappear.
|
||||
// Two exist and they are not interchangeable:
|
||||
//
|
||||
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||
//
|
||||
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||
// only brightness an external display has, and it is per-monitor.
|
||||
//
|
||||
// A machine may have neither, either, or both, so this renders a row per source
|
||||
// found and removes itself entirely when there are none, rather than sitting
|
||||
// there as a dead control.
|
||||
//
|
||||
// Connector labels appear only when there is more than one row. A single
|
||||
// slider needs no explanation of which screen it dims.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property real value: 0
|
||||
property bool backlightAvailable: false
|
||||
property real backlightValue: 0
|
||||
|
||||
visible: root.available
|
||||
implicitHeight: root.available ? 32 : 0
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
@@ -34,18 +54,85 @@ Item {
|
||||
const fields = line.split(",");
|
||||
if (fields.length < 5 || fields[1] !== "backlight")
|
||||
continue;
|
||||
root.available = true;
|
||||
root.value = parseInt(fields[3]) / 100;
|
||||
root.backlightAvailable = true;
|
||||
root.backlightValue = parseInt(fields[3]) / 100;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(v: real): void {
|
||||
root.value = v;
|
||||
function applyBacklight(v: real): void {
|
||||
root.backlightValue = v;
|
||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||
}
|
||||
|
||||
Column {
|
||||
id: rows
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 4
|
||||
|
||||
BrightnessRow {
|
||||
width: rows.width
|
||||
visible: root.backlightAvailable
|
||||
label: "Built-in"
|
||||
value: root.backlightValue
|
||||
onMoved: v => root.applyBacklight(v)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
BrightnessRow {
|
||||
required property var modelData
|
||||
width: rows.width
|
||||
// Hyprland already knows what each output is called, so the
|
||||
// name comes from there rather than from a second source that
|
||||
// could disagree with the Displays page. The connector is the
|
||||
// fallback, so a display is never an unlabelled slider.
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
value: modelData.value / 100
|
||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BrightnessRow: Item {
|
||||
id: row
|
||||
|
||||
property string label: ""
|
||||
property real value: 0
|
||||
signal moved(real value)
|
||||
|
||||
implicitHeight: caption.height + control.height
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// The slider and its glyph share one strip so the two stay aligned
|
||||
// whether or not a caption sits above them. Anchoring the glyph to
|
||||
// both a caption and a centre line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: caption.bottom
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
@@ -63,7 +150,9 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.value
|
||||
onMoved: v => root.apply(v)
|
||||
value: row.value
|
||||
onMoved: v => row.moved(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Displays.
|
||||
//
|
||||
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||
// policy that was already here.
|
||||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||
// gaming display policy that was already here.
|
||||
//
|
||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||
@@ -32,7 +32,14 @@ SettingsPage {
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
}
|
||||
|
||||
Component.onCompleted: root.syncSelectedOutput()
|
||||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||
// it runs when this page is opened rather than at shell startup. Monitors
|
||||
// do not appear while you are looking at a settings page, so once is enough.
|
||||
Component.onCompleted: {
|
||||
root.syncSelectedOutput();
|
||||
if (!Brightness.scanned)
|
||||
Brightness.refresh();
|
||||
}
|
||||
Connections {
|
||||
target: Displays
|
||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||
@@ -200,6 +207,45 @@ SettingsPage {
|
||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||
}
|
||||
|
||||
// Panel brightness, over DDC/CI.
|
||||
//
|
||||
// This is hardware state rather than a stored preference: the monitor
|
||||
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||
// writing it into settings.json would mean restoring a value the panel had
|
||||
// already moved on from. So there is no schema key here and no SliderRow --
|
||||
// the rows read and write the display directly.
|
||||
SettingsCard {
|
||||
visible: Brightness.available || Brightness.lastError !== ""
|
||||
title: "Brightness"
|
||||
subtitle: Brightness.available
|
||||
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||||
: Brightness.lastError
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
SettingRow {
|
||||
id: brightnessRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||
: modelData.value + "%"
|
||||
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
ValueSlider {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
width: parent.width
|
||||
value: brightnessRow.modelData.value / 100
|
||||
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when Panama starts."
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# External monitor brightness over DDC/CI.
|
||||
#
|
||||
# A desktop with no backlight class device has no brightness control at all --
|
||||
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
|
||||
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
|
||||
# 0x10), which is what the buttons on the bezel drive.
|
||||
#
|
||||
# Usage:
|
||||
# panama-brightness list -> {"displays":[...],"error":""}
|
||||
# panama-brightness get <bus> -> integer percent
|
||||
# panama-brightness set <bus> <pct> -> applies, prints nothing
|
||||
#
|
||||
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
|
||||
# kernel publishes the connector-to-I2C-bus mapping directly, as
|
||||
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
|
||||
# in. That is better than parsing detect output in three ways: the format is
|
||||
# stable where detect's brief output is undocumented, the connector name comes
|
||||
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
|
||||
# only connectors with a monitor attached get probed -- one bus on this machine
|
||||
# instead of fourteen, which is the difference between a fast scan and a slow
|
||||
# one, since each probe of an empty bus waits for a timeout.
|
||||
#
|
||||
# No model name is reported. Hyprland already knows the human-readable
|
||||
# description of every output, so the UI joins on the connector name rather than
|
||||
# having two sources of truth for what a monitor is called.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
readonly VCP_BRIGHTNESS=0x10
|
||||
|
||||
# Test seams. The contract needs to exercise enumeration and parsing on a
|
||||
# machine whose real monitors it must not touch, so both roots this script
|
||||
# reads are overridable. Nothing sets them in normal use.
|
||||
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
|
||||
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
|
||||
|
||||
emit_error() {
|
||||
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
|
||||
|
||||
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
|
||||
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
|
||||
# to devices created after the rule was installed -- so a machine that installed
|
||||
# ddcutil without rebooting has the rule in place and no access to show for it.
|
||||
# That is by far the most likely reason for an empty list, and it is fixable in
|
||||
# one command, so say so rather than reporting "no displays".
|
||||
has_accessible_bus() {
|
||||
local dev
|
||||
for dev in "$DEV_ROOT"/i2c-*; do
|
||||
[[ -r "$dev" && -w "$dev" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
|
||||
|
||||
local rows=() connector bus value path
|
||||
for path in "$DRM_ROOT"/card*-*; do
|
||||
[[ -e "$path/ddc" ]] || continue
|
||||
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
|
||||
|
||||
# card1-DP-2 -> DP-2, the name Hyprland uses.
|
||||
connector="$(basename "$path")"
|
||||
connector="${connector#card*-}"
|
||||
|
||||
bus="$(basename "$(readlink -f "$path/ddc")")"
|
||||
bus="${bus#i2c-}"
|
||||
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||
|
||||
# A monitor that does not implement 0x10 is not an error; it simply
|
||||
# cannot be controlled, and is left out rather than shown as a slider
|
||||
# that does nothing.
|
||||
value="$(cmd_get "$bus")" || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
|
||||
rows+=("$(jq -cn \
|
||||
--argjson bus "$bus" \
|
||||
--arg connector "$connector" \
|
||||
--argjson value "$value" \
|
||||
'{bus: $bus, connector: $connector, value: $value}')")
|
||||
done
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
|
||||
fi
|
||||
|
||||
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||
}
|
||||
|
||||
# Prints the current brightness as a whole percent, or nothing when the display
|
||||
# cannot report it. `getvcp --brief` is documented as machine readable and
|
||||
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
|
||||
# guaranteed to be, so it is read rather than assumed.
|
||||
cmd_get() {
|
||||
local bus="$1" out current max
|
||||
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||
read -r _ _ _ current max <<<"$out"
|
||||
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
|
||||
printf '%s' "$(( current * 100 / max ))"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local bus="$1" percent="$2" max out
|
||||
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
|
||||
(( percent > 100 )) && percent=100
|
||||
|
||||
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||
read -r _ _ _ _ max <<<"$out"
|
||||
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
|
||||
|
||||
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
list) cmd_list ;;
|
||||
get) cmd_get "${2:?bus required}" ;;
|
||||
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
|
||||
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+486
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Read-only, redacted diagnostics for Panama-owned desktop functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal
|
||||
|
||||
Status = Literal["ok", "warning", "error", "unconfigured"]
|
||||
Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
ActionKind = Literal["repair", "open", "instructions"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
kind: ActionKind
|
||||
label: str
|
||||
confirm: bool = False
|
||||
# Only authored Settings page IDs and instruction IDs are allowed here.
|
||||
# Repair commands never receive a caller-controlled target.
|
||||
target: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Check:
|
||||
id: str
|
||||
group: Group
|
||||
title: str
|
||||
status: Status
|
||||
detail: str
|
||||
action: Action | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DoctorConfig:
|
||||
root: Path
|
||||
home: Path
|
||||
config_home: Path
|
||||
state_home: Path
|
||||
runtime_dir: Path
|
||||
path: str
|
||||
timeout: float
|
||||
|
||||
@property
|
||||
def command_env(self) -> dict[str, str]:
|
||||
environment = {
|
||||
"PATH": self.path,
|
||||
"HOME": str(self.home),
|
||||
"XDG_CONFIG_HOME": str(self.config_home),
|
||||
"XDG_STATE_HOME": str(self.state_home),
|
||||
"XDG_RUNTIME_DIR": str(self.runtime_dir),
|
||||
}
|
||||
for name in PROBE_ENVIRONMENT_KEYS:
|
||||
if value := os.environ.get(name):
|
||||
environment[name] = value
|
||||
return environment
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
state: Literal["ok", "missing", "timeout", "failed", "unavailable"]
|
||||
stdout: str = ""
|
||||
|
||||
|
||||
CHECK_ORDER = (
|
||||
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.vicinae", "input.pipewire",
|
||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
||||
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
|
||||
)
|
||||
|
||||
SYSTEMCTL_COMMANDS = {
|
||||
"hyprpaper": ("systemctl", "--user", "is-active", "--quiet", "hyprpaper.service"),
|
||||
"hypridle": ("systemctl", "--user", "is-active", "--quiet", "hypridle.service"),
|
||||
"vicinae": ("systemctl", "--user", "is-active", "--quiet", "vicinae.service"),
|
||||
"pipewire": ("systemctl", "--user", "is-active", "--quiet", "pipewire.service"),
|
||||
"nextcloud": ("systemctl", "--user", "is-active", "--quiet", "nextcloud.service"),
|
||||
"rustdesk": ("systemctl", "--user", "is-active", "--quiet", "rustdesk.service"),
|
||||
}
|
||||
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
||||
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||
PROBE_ENVIRONMENT_KEYS = (
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"WAYLAND_DISPLAY",
|
||||
"DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"PANAMA_DOCTOR_FIXTURE_STOPPED",
|
||||
"PANAMA_DOCTOR_FIXTURE_PROCESSES",
|
||||
"PANAMA_DOCTOR_FIXTURE_BUS",
|
||||
"PANAMA_DOCTOR_FIXTURE_QS",
|
||||
"PANAMA_DOCTOR_FIXTURE_QS_VERSION",
|
||||
"PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES",
|
||||
"PANAMA_DOCTOR_FIXTURE_CALENDAR",
|
||||
"PANAMA_DOCTOR_FIXTURE_BRIGHTNESS",
|
||||
"PANAMA_DOCTOR_FIXTURE_CAFFEINE",
|
||||
)
|
||||
CHECK_TITLES = {
|
||||
"desktop.hyprland": "Hyprland",
|
||||
"desktop.quickshell": "Quickshell",
|
||||
"desktop.notifications": "Notifications",
|
||||
"desktop.portals": "Desktop portals",
|
||||
"desktop.hyprpaper": "Hyprpaper",
|
||||
"desktop.hypridle": "Hypridle",
|
||||
"desktop.vicinae": "Vicinae",
|
||||
"input.pipewire": "PipeWire",
|
||||
"input.clipboard": "Clipboard",
|
||||
"input.wallpaper": "Wallpaper",
|
||||
"input.capture": "Capture",
|
||||
"input.ocr": "OCR",
|
||||
"input.brightness": "External monitor brightness",
|
||||
"integration.nextcloud": "Nextcloud",
|
||||
"integration.rustdesk": "RustDesk",
|
||||
"integration.kdeconnect": "KDE Connect",
|
||||
"integration.bluebubbles": "BlueBubbles",
|
||||
"integration.home-assistant": "Home Assistant",
|
||||
"integration.calendar": "Calendar",
|
||||
"panama.runtime-links": "Panama runtime links",
|
||||
"panama.vicinae-commands": "Panama commands",
|
||||
"panama.selected-terminal": "Selected terminal",
|
||||
"panama.selected-launcher": "Selected launcher",
|
||||
"panama.processes": "Panama processes",
|
||||
"panama.caffeine": "Caffeine inhibitor",
|
||||
}
|
||||
|
||||
|
||||
def environment_path(name: str, default: Path) -> Path:
|
||||
value = os.environ.get(name)
|
||||
return Path(value).expanduser() if value else default
|
||||
|
||||
|
||||
def config_from_environment() -> DoctorConfig:
|
||||
home = environment_path("PANAMA_DOCTOR_HOME", Path.home())
|
||||
config_home = environment_path("PANAMA_DOCTOR_CONFIG_HOME", Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")))
|
||||
state_home = environment_path("PANAMA_DOCTOR_STATE_HOME", Path(os.environ.get("XDG_STATE_HOME", home / ".local/state")))
|
||||
runtime_dir = environment_path("PANAMA_DOCTOR_RUNTIME_DIR", Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/0")))
|
||||
root = environment_path("PANAMA_DOCTOR_ROOT", Path(__file__).resolve().parents[4])
|
||||
try:
|
||||
timeout = float(os.environ.get("PANAMA_DOCTOR_TIMEOUT", "3"))
|
||||
except ValueError:
|
||||
timeout = 3.0
|
||||
return DoctorConfig(root, home, config_home, state_home, runtime_dir, os.environ.get("PANAMA_DOCTOR_PATH", os.environ.get("PATH", "")), max(0.05, min(timeout, 15.0)))
|
||||
|
||||
|
||||
def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None = None) -> CommandResult:
|
||||
"""Run an authored read-only command without reporting its unparsed output."""
|
||||
try:
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=config.timeout, check=False, env=config.command_env, cwd=cwd)
|
||||
except FileNotFoundError:
|
||||
return CommandResult("missing")
|
||||
except subprocess.TimeoutExpired:
|
||||
return CommandResult("timeout")
|
||||
except OSError:
|
||||
return CommandResult("unavailable")
|
||||
if completed.returncode != 0:
|
||||
return CommandResult("failed")
|
||||
return CommandResult("ok", completed.stdout)
|
||||
|
||||
|
||||
def executable_exists(name: str, config: DoctorConfig) -> bool:
|
||||
return shutil.which(name, path=config.path) is not None
|
||||
|
||||
|
||||
def action_json(action: Action) -> dict[str, object]:
|
||||
result: dict[str, object] = {"kind": action.kind, "label": action.label, "confirm": action.confirm}
|
||||
if action.target is not None:
|
||||
result["target"] = action.target
|
||||
return result
|
||||
|
||||
|
||||
def check_json(check: Check) -> dict[str, object]:
|
||||
result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail}
|
||||
if check.action is not None:
|
||||
result["action"] = action_json(check.action)
|
||||
return result
|
||||
|
||||
|
||||
def group_for(check_id: str) -> Group:
|
||||
if check_id.startswith("desktop."):
|
||||
return "desktop-foundation"
|
||||
if check_id.startswith("input."):
|
||||
return "input-media"
|
||||
if check_id.startswith("integration."):
|
||||
return "integrations"
|
||||
return "panama-tools"
|
||||
|
||||
|
||||
def service_check(check_id: str, title: str, service: str, config: DoctorConfig, action: Action | None = None) -> Check:
|
||||
result = run_command(SYSTEMCTL_COMMANDS[service], config)
|
||||
if result.state == "ok":
|
||||
return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
|
||||
if result.state in {"missing", "unavailable"}:
|
||||
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
|
||||
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
|
||||
|
||||
|
||||
def ipc_target(config: DoctorConfig, target: str) -> CommandResult:
|
||||
result = run_command(("qs", "ipc", "show"), config)
|
||||
if result.state != "ok":
|
||||
return result
|
||||
return CommandResult("ok") if f"target {target}" in result.stdout.splitlines() else CommandResult("failed")
|
||||
|
||||
|
||||
def simple_ipc_check(check_id: str, title: str, target: str, config: DoctorConfig) -> Check:
|
||||
result = ipc_target(config, target)
|
||||
if result.state == "ok":
|
||||
return Check(check_id, "input-media", title, "ok", "Panama IPC target is available.")
|
||||
if result.state == "missing":
|
||||
return Check(check_id, "input-media", title, "error", "Required Quickshell executable is unavailable.")
|
||||
if result.state == "timeout":
|
||||
return Check(check_id, "input-media", title, "warning", "Panama IPC probe timed out.")
|
||||
return Check(check_id, "input-media", title, "warning", "Panama IPC target is unavailable.")
|
||||
|
||||
|
||||
def check_hyprland(config: DoctorConfig) -> Check:
|
||||
if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold():
|
||||
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "ok", "Hyprland session detected.")
|
||||
return Check("desktop.hyprland", "desktop-foundation", "Hyprland", "error", "Hyprland session is not active.")
|
||||
|
||||
|
||||
def check_quickshell(config: DoctorConfig) -> Check:
|
||||
result = run_command(("qs", "--version"), config)
|
||||
repair = Action("repair", "Restart Panama", True)
|
||||
if result.state == "ok" and VERSION_PATTERN.search(result.stdout):
|
||||
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "ok", "Quickshell executable is available.")
|
||||
if result.state == "missing":
|
||||
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "error", "Required Quickshell executable is unavailable.", repair)
|
||||
return Check("desktop.quickshell", "desktop-foundation", "Quickshell", "warning", "Quickshell probe returned an invalid result.", repair)
|
||||
|
||||
|
||||
def check_notifications(config: DoctorConfig) -> Check:
|
||||
result = ipc_target(config, "notifications")
|
||||
return Check("desktop.notifications", "desktop-foundation", "Notifications", "ok", "Notification service is available.") if result.state == "ok" else Check("desktop.notifications", "desktop-foundation", "Notifications", "warning", "Notification service is unavailable.")
|
||||
|
||||
|
||||
def check_portals(config: DoctorConfig) -> Check:
|
||||
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
|
||||
if result.state == "ok" and any(line.startswith("org.freedesktop.portal.Desktop ") for line in result.stdout.splitlines()):
|
||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "ok", "Desktop portal service is available.")
|
||||
detail = "Desktop portal probe timed out." if result.state == "timeout" else "Desktop portal probe is unavailable." if result.state == "missing" else "Desktop portal service is unavailable."
|
||||
return Check("desktop.portals", "desktop-foundation", "Desktop portals", "warning", detail)
|
||||
|
||||
|
||||
def check_brightness(config: DoctorConfig) -> Check:
|
||||
result = run_command(("panama-brightness", "list"), config)
|
||||
instructions = Action("instructions", "View setup instructions", target="ddc-permissions")
|
||||
if result.state == "timeout":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe timed out.", instructions)
|
||||
if result.state != "ok":
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI support is unavailable.", instructions)
|
||||
try:
|
||||
listing = json.loads(result.stdout)
|
||||
displays, error = listing["displays"], listing["error"]
|
||||
if not isinstance(displays, list) or not isinstance(error, str):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe returned an invalid result.", instructions)
|
||||
if error:
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "No accessible DDC/CI bus.", instructions)
|
||||
if not displays:
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "unconfigured", "No DDC/CI display is configured.")
|
||||
return Check("input.brightness", "input-media", "External monitor brightness", "ok", f"{len(displays)} DDC/CI display{'s' if len(displays) != 1 else ''} available.")
|
||||
|
||||
|
||||
def check_nextcloud(config: DoctorConfig) -> Check:
|
||||
if not (config.config_home / "autostart" / "nextcloud.desktop").is_file():
|
||||
return Check("integration.nextcloud", "integrations", "Nextcloud", "unconfigured", "Nextcloud autostart is not configured.")
|
||||
return service_check("integration.nextcloud", "Nextcloud", "nextcloud", config, Action("open", "Open Nextcloud"))
|
||||
|
||||
|
||||
def check_rustdesk(config: DoctorConfig) -> Check:
|
||||
if not executable_exists("rustdesk", config):
|
||||
return Check("integration.rustdesk", "integrations", "RustDesk", "unconfigured", "RustDesk is not installed.")
|
||||
return service_check("integration.rustdesk", "RustDesk", "rustdesk", config, Action("open", "Open RustDesk"))
|
||||
|
||||
|
||||
def check_kdeconnect(config: DoctorConfig) -> Check:
|
||||
if not executable_exists("kdeconnect-cli", config):
|
||||
return Check("integration.kdeconnect", "integrations", "KDE Connect", "unconfigured", "KDE Connect is not installed.")
|
||||
result = run_command(("busctl", "--user", "--no-pager", "list"), config)
|
||||
if result.state == "ok" and any(line.startswith("org.kde.kdeconnect ") for line in result.stdout.splitlines()):
|
||||
return Check("integration.kdeconnect", "integrations", "KDE Connect", "ok", "KDE Connect service is available.")
|
||||
return Check("integration.kdeconnect", "integrations", "KDE Connect", "warning", "KDE Connect service is unavailable.", Action("open", "Open KDE Connect"))
|
||||
|
||||
|
||||
def check_bluebubbles(config: DoctorConfig) -> Check:
|
||||
result = run_command(("flatpak", "info", "app.bluebubbles.BlueBubbles"), config)
|
||||
if result.state == "ok":
|
||||
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "ok", "BlueBubbles is installed.")
|
||||
if result.state in {"missing", "failed"}:
|
||||
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "unconfigured", "BlueBubbles is not installed.")
|
||||
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles"))
|
||||
|
||||
|
||||
def check_home_assistant(config: DoctorConfig) -> Check:
|
||||
configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN"))
|
||||
helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant"
|
||||
if not configured:
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.")
|
||||
if not helper.is_file():
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone"))
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.")
|
||||
|
||||
|
||||
def check_calendar(config: DoctorConfig) -> Check:
|
||||
result = run_command(("calendar-agenda", "probe"), config)
|
||||
action = Action("open", "Open Date & Time", target="datetime")
|
||||
if result.state == "missing":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "Calendar integration is not installed.")
|
||||
if result.state == "timeout":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe timed out.", action)
|
||||
if result.state != "ok":
|
||||
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe failed.", action)
|
||||
try:
|
||||
enabled_sources = json.loads(result.stdout)["enabledSources"]
|
||||
if not isinstance(enabled_sources, int) or isinstance(enabled_sources, bool):
|
||||
raise ValueError
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return Check("integration.calendar", "integrations", "Calendar", "warning", "Calendar probe returned an invalid result.", action)
|
||||
if enabled_sources <= 0:
|
||||
return Check("integration.calendar", "integrations", "Calendar", "unconfigured", "No enabled calendar source is configured.")
|
||||
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
|
||||
|
||||
|
||||
def check_runtime_links(config: DoctorConfig) -> Check:
|
||||
names = ("hypr", "quickshell", "uwsm", "vicinae")
|
||||
if any(not (config.config_home / name).is_symlink() or not (config.config_home / name).exists() for name in names):
|
||||
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "warning", "One or more Panama runtime links are unavailable.", Action("repair", "Repair runtime links"))
|
||||
return Check("panama.runtime-links", "panama-tools", "Panama runtime links", "ok", "Panama runtime links are available.")
|
||||
|
||||
|
||||
def check_vicinae_commands(config: DoctorConfig) -> Check:
|
||||
source = config.root / "config/local/share/vicinae/scripts"
|
||||
installed = config.home / ".local/share/vicinae/scripts"
|
||||
if source.is_dir() and installed.is_symlink() and installed.exists():
|
||||
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.")
|
||||
return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link"))
|
||||
|
||||
|
||||
def executable_check(check_id: str, title: str, executable: str, config: DoctorConfig) -> Check:
|
||||
if executable_exists(executable, config):
|
||||
return Check(check_id, group_for(check_id), title, "ok", f"{title} executable is available.")
|
||||
return Check(check_id, group_for(check_id), title, "warning", f"{title} executable is unavailable.")
|
||||
|
||||
|
||||
def check_processes(config: DoctorConfig) -> Check:
|
||||
counts: list[int] = []
|
||||
for name in PROCESS_NAMES:
|
||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||
if result.state == "ok":
|
||||
pids = result.stdout.splitlines()
|
||||
if not pids or any(not pid.isdecimal() for pid in pids):
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.")
|
||||
counts.append(len(pids))
|
||||
elif result.state == "failed":
|
||||
counts.append(0)
|
||||
else:
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||
if any(count > 1 for count in counts):
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Duplicate Panama desktop processes detected.")
|
||||
if counts[0] == 0:
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "error", "Quickshell process is not running.")
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "ok", "Panama desktop process counts are normal.")
|
||||
|
||||
|
||||
def check_caffeine(config: DoctorConfig) -> Check:
|
||||
result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config)
|
||||
if result.state != "ok":
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
|
||||
uid = str(os.getuid())
|
||||
inhibitors = 0
|
||||
malformed = False
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts
|
||||
if not relevant:
|
||||
continue
|
||||
if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]:
|
||||
inhibitors += 1
|
||||
else:
|
||||
malformed = True
|
||||
if malformed:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
|
||||
if inhibitors > 1:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
|
||||
if inhibitors == 1:
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "One Panama Caffeine inhibitor is active.")
|
||||
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "ok", "No Panama Caffeine inhibitor is active.")
|
||||
|
||||
|
||||
def parse_version(result: CommandResult, pattern: re.Pattern[str] = VERSION_PATTERN) -> str:
|
||||
match = pattern.search(result.stdout) if result.state == "ok" else None
|
||||
return match.group(0) if match else "unavailable"
|
||||
|
||||
|
||||
def context_versions(config: DoctorConfig) -> list[dict[str, str]]:
|
||||
hyprland = run_command(("hyprctl", "version"), config)
|
||||
quickshell = run_command(("qs", "--version"), config)
|
||||
revision = run_command(("git", "rev-parse", "--short", "HEAD"), config, config.root)
|
||||
fedora = "unavailable"
|
||||
try:
|
||||
match = re.search(r"^VERSION_ID=\"?([^\n\"]+)", Path("/etc/os-release").read_text(encoding="utf-8"), re.MULTILINE)
|
||||
if match and re.fullmatch(r"[0-9.]+", match.group(1)):
|
||||
fedora = match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
|
||||
|
||||
|
||||
def unavailable_check(check_id: str) -> Check:
|
||||
return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.")
|
||||
|
||||
|
||||
def unavailable_versions() -> list[dict[str, str]]:
|
||||
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
|
||||
|
||||
|
||||
def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||
probes: dict[str, Callable[[], Check]] = {
|
||||
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
|
||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
||||
checks: list[Check] = []
|
||||
for check_id in CHECK_ORDER:
|
||||
try:
|
||||
checks.append(futures[check_id].result())
|
||||
except Exception:
|
||||
checks.append(unavailable_check(check_id))
|
||||
return checks
|
||||
|
||||
|
||||
def snapshot(config: DoctorConfig) -> dict[str, object]:
|
||||
try:
|
||||
checks = collect_checks(config)
|
||||
except Exception:
|
||||
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
|
||||
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
|
||||
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
|
||||
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
|
||||
try:
|
||||
versions = context_versions(config)
|
||||
except Exception:
|
||||
versions = unavailable_versions()
|
||||
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Read-only Panama system diagnostics")
|
||||
output = parser.add_mutually_exclusive_group()
|
||||
output.add_argument("--json", action="store_true")
|
||||
output.add_argument("--summary", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
result = snapshot(config_from_environment())
|
||||
if args.summary:
|
||||
summary = result["summary"]
|
||||
assert isinstance(summary, dict)
|
||||
print(f"Panama system health: {summary['status']} ({summary['healthy']} ok, {summary['warnings']} warnings, {summary['errors']} errors, {summary['unconfigured']} unconfigured)")
|
||||
else:
|
||||
print(json.dumps(result, separators=(",", ":"), sort_keys=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Propagates the colour scheme to applications that do not read the desktop
|
||||
# portal.
|
||||
#
|
||||
# Most modern applications DO follow org.freedesktop.portal.Settings and need
|
||||
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
|
||||
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
|
||||
# from gsettings. Panama sets that, so those follow automatically.
|
||||
#
|
||||
# Terminals are the notable exception -- they predate the standard and carry
|
||||
# their own palettes. kitty is handled here.
|
||||
#
|
||||
# panama-theme-apps dark|light
|
||||
#
|
||||
# kitty gets it twice: the generated include file so terminals opened later
|
||||
# start correct, and a live `set-colors` over its control socket so terminals
|
||||
# already open change now. Without the second, a scheme change appears to do
|
||||
# nothing until you open a new window.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
scheme="${1:-dark}"
|
||||
case "$scheme" in
|
||||
dark|light) ;;
|
||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
||||
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||
|
||||
status_kitty="skipped"
|
||||
if [[ -r "$theme_file" ]]; then
|
||||
# Written atomically: kitty may read this while a new window is starting.
|
||||
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
||||
status_kitty="written"
|
||||
fi
|
||||
|
||||
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
|
||||
# alone reaches nothing, which is exactly how this looked like remote
|
||||
# control being disabled when it was not.
|
||||
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
|
||||
applied=0
|
||||
while read -r socket; do
|
||||
[[ -n "$socket" ]] || continue
|
||||
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
|
||||
applied=$((applied + 1))
|
||||
fi
|
||||
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||
(( applied > 0 )) && status_kitty="applied to $applied"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
|
||||
@@ -0,0 +1,163 @@
|
||||
pragma Singleton
|
||||
|
||||
// Panel brightness for external monitors, over DDC/CI.
|
||||
//
|
||||
// brightnessctl covers laptop panels through the kernel's backlight class. A
|
||||
// desktop driving a DisplayPort monitor has no such device, so it has no
|
||||
// brightness control at all -- the only way to dim the screen is the buttons on
|
||||
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
|
||||
// over the same I2C lines that carry EDID.
|
||||
//
|
||||
// Two things shape everything here:
|
||||
//
|
||||
// Detection is slow. Probing every I2C bus takes on the order of a second,
|
||||
// which is far too slow to sit in front of a settings page opening. It runs
|
||||
// once, on demand, and afterwards each display is addressed by its bus number
|
||||
// directly.
|
||||
//
|
||||
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
|
||||
// emits values continuously; sending each one produces a queue the panel
|
||||
// works through seconds after the user let go, and some monitors drop or
|
||||
// garble writes that arrive too fast. So `value` updates immediately for the
|
||||
// UI and the hardware write is debounced, with only the latest value sent.
|
||||
//
|
||||
// Displays are keyed by DRM connector name (DP-2) so they line up with what
|
||||
// Hyprland, the Displays page, and the monitor list already call them.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
|
||||
|
||||
// [{ bus, connector, model, value }] where value is 0..100.
|
||||
property var displays: []
|
||||
property bool scanning: false
|
||||
|
||||
// Empty when everything is fine. Carries the helper's explanation
|
||||
// otherwise -- most usefully the udev command that grants I2C access,
|
||||
// which is the difference between "brightness is unavailable" and
|
||||
// "brightness is one command away".
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool available: root.displays.length > 0
|
||||
|
||||
// True once a scan has completed, however it went. Lets the UI tell "not
|
||||
// looked yet" apart from "looked and found nothing", which otherwise render
|
||||
// identically and leave a permanently empty panel with no explanation.
|
||||
property bool scanned: false
|
||||
|
||||
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
|
||||
// one entry no matter how many values the slider emits.
|
||||
property var pending: ({})
|
||||
|
||||
function refresh(): void {
|
||||
if (root.scanning)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
function displayFor(connector: string): var {
|
||||
return root.displays.find(display => display.connector === connector) ?? null;
|
||||
}
|
||||
|
||||
// Sets brightness for one display. The stored value moves at once so the
|
||||
// slider tracks the pointer; the hardware follows when the drag settles.
|
||||
function set(bus: int, percent: int): void {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
|
||||
|
||||
const next = Object.assign({}, root.pending);
|
||||
next[String(bus)] = clamped;
|
||||
root.pending = next;
|
||||
writeDebounce.restart();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: [root.helperPath, "list"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.displays = [];
|
||||
root.lastError = "Could not read the brightness helper's output.";
|
||||
console.warn("Brightness: could not parse helper output:", error);
|
||||
}
|
||||
root.scanning = false;
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Long enough that a drag produces one write rather than dozens, short
|
||||
// enough that a single click still feels immediate.
|
||||
Timer {
|
||||
id: writeDebounce
|
||||
interval: 120
|
||||
onTriggered: root.pump()
|
||||
}
|
||||
|
||||
// Writes run one at a time, and each is read back.
|
||||
//
|
||||
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
|
||||
// processes talking to the same monitor interleave their exchanges and both
|
||||
// can come back with garbage. Read back because a write is not a promise --
|
||||
// panels clamp to their own range, ignore values while waking from standby,
|
||||
// and drop writes that arrive too quickly. Without the read the slider shows
|
||||
// what Panama asked for rather than what the monitor did, which is the same
|
||||
// class of lie as trusting `hyprctl keyword` to have applied something.
|
||||
property int writingBus: -1
|
||||
|
||||
function pump(): void {
|
||||
if (writer.running || reader.running)
|
||||
return;
|
||||
|
||||
for (const bus in root.pending) {
|
||||
const value = root.pending[bus];
|
||||
const remaining = Object.assign({}, root.pending);
|
||||
delete remaining[bus];
|
||||
root.pending = remaining;
|
||||
|
||||
root.writingBus = parseInt(bus);
|
||||
writer.command = [root.helperPath, "set", bus, String(value)];
|
||||
writer.running = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writer
|
||||
onExited: {
|
||||
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||
reader.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const actual = parseInt(this.text.trim());
|
||||
if (!isNaN(actual)) {
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === root.writingBus
|
||||
? Object.assign({}, display, { value: actual })
|
||||
: display);
|
||||
}
|
||||
root.writingBus = -1;
|
||||
// Anything queued while this write was in flight goes now.
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import qs.config
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
property string lastError: ""
|
||||
|
||||
@@ -92,6 +94,12 @@ Singleton {
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry their
|
||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
||||
// write above and needs nothing here.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
||||
|
||||
root.enqueue(commands);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
pragma Singleton
|
||||
|
||||
// The health helper is deliberately not a state owner. This singleton accepts
|
||||
// complete, typed snapshots and keeps the last valid one available while a
|
||||
// later scan or repair fails.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var snapshot: ({})
|
||||
property var checks: []
|
||||
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
|
||||
property string status: "healthy"
|
||||
property bool diagnosticUnavailable: false
|
||||
property bool queuedRefresh: false
|
||||
property int generation: 0
|
||||
property int acceptedGeneration: 0
|
||||
property string lastError: ""
|
||||
property string repairingId: ""
|
||||
property var lastRepair: ({})
|
||||
property string lastCopyResult: ""
|
||||
property bool startupScanEnabled: true
|
||||
|
||||
readonly property bool actionable: root.status === "warning" || root.status === "error"
|
||||
readonly property bool busy: scanProcess.running || repairProcess.running
|
||||
readonly property string helperPath: Quickshell.env("PANAMA_HEALTH_HELPER")
|
||||
|| Quickshell.shellDir + "/scripts/panama-doctor"
|
||||
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
|
||||
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
readonly property var overallStatuses: ["healthy", "warning", "error"]
|
||||
readonly property var settingsTargets: ["home-phone", "datetime"]
|
||||
readonly property var instructionTargets: ["ddc-permissions"]
|
||||
|
||||
Process {
|
||||
id: scanProcess
|
||||
|
||||
property int scanGeneration: 0
|
||||
property string outputText: ""
|
||||
property int exitCode: -1
|
||||
property bool exited: false
|
||||
property bool streamFinished: false
|
||||
property bool settled: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
id: scanOutput
|
||||
property int generation: 0
|
||||
onStreamFinished: {
|
||||
scanProcess.outputText = this.text;
|
||||
scanProcess.scanGeneration = generation;
|
||||
scanProcess.streamFinished = true;
|
||||
root.settleScan();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
scanProcess.exitCode = exitCode;
|
||||
scanProcess.exited = true;
|
||||
root.settleScan();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: repairProcess
|
||||
|
||||
property string checkId: ""
|
||||
property bool external: false
|
||||
|
||||
onExited: (exitCode, exitStatus) => root.finishRepair(exitCode, checkId, external)
|
||||
}
|
||||
|
||||
Process {
|
||||
id: copyProcess
|
||||
|
||||
property string payload: ""
|
||||
|
||||
onStarted: copyProcess.write(copyProcess.payload)
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.lastCopyResult = exitCode === 0
|
||||
? "Report copied."
|
||||
: "Could not copy the health report."
|
||||
copyProcess.payload = ""
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: failureNotification
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: startupScan
|
||||
interval: 2200
|
||||
repeat: false
|
||||
running: root.startupScanEnabled
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
function refresh(): bool {
|
||||
if (scanProcess.running || repairProcess.running) {
|
||||
root.queuedRefresh = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
root.generation += 1;
|
||||
scanProcess.scanGeneration = root.generation;
|
||||
scanProcess.outputText = "";
|
||||
scanProcess.exitCode = -1;
|
||||
scanProcess.exited = false;
|
||||
scanProcess.streamFinished = false;
|
||||
scanProcess.settled = false;
|
||||
scanOutput.generation = root.generation;
|
||||
scanProcess.exec([root.helperPath, "--json"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function settleScan(): void {
|
||||
if (scanProcess.settled || !scanProcess.exited || !scanProcess.streamFinished)
|
||||
return;
|
||||
scanProcess.settled = true;
|
||||
root.finishScan(scanProcess.exitCode, scanProcess.scanGeneration, scanProcess.outputText);
|
||||
}
|
||||
|
||||
function finishScan(exitCode: int, scanGeneration: int, text: string): void {
|
||||
if (exitCode === 0)
|
||||
root.consumeSnapshot(text, scanGeneration);
|
||||
else
|
||||
root.rejectSnapshot("Panama diagnostics could not be read. Try refreshing.");
|
||||
|
||||
if (!root.queuedRefresh)
|
||||
return;
|
||||
root.queuedRefresh = false;
|
||||
root.refresh();
|
||||
}
|
||||
|
||||
function consumeSnapshot(text: string, scanGeneration: int): bool {
|
||||
if (scanGeneration < root.acceptedGeneration)
|
||||
return false;
|
||||
|
||||
let candidate;
|
||||
try {
|
||||
candidate = JSON.parse(text.trim());
|
||||
} catch (error) {
|
||||
root.rejectSnapshot("Panama diagnostics returned an unreadable response.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root.validSnapshot(candidate)) {
|
||||
root.rejectSnapshot("Panama diagnostics returned an invalid response.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const accepted = root.safeSnapshot(candidate);
|
||||
root.snapshot = accepted;
|
||||
root.checks = accepted.checks;
|
||||
root.summary = accepted.summary;
|
||||
root.status = accepted.summary.status;
|
||||
root.acceptedGeneration = scanGeneration;
|
||||
root.diagnosticUnavailable = false;
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectSnapshot(message: string): void {
|
||||
root.diagnosticUnavailable = true;
|
||||
root.lastError = message;
|
||||
}
|
||||
|
||||
function safeSnapshot(candidate: var): var {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: candidate.generatedAt,
|
||||
summary: {
|
||||
status: candidate.summary.status,
|
||||
healthy: candidate.summary.healthy,
|
||||
warnings: candidate.summary.warnings,
|
||||
errors: candidate.summary.errors,
|
||||
unconfigured: candidate.summary.unconfigured
|
||||
},
|
||||
context: {
|
||||
session: candidate.context.session,
|
||||
versions: candidate.context.versions.map(version => ({
|
||||
id: version.id,
|
||||
version: version.version
|
||||
}))
|
||||
},
|
||||
checks: candidate.checks.map(check => root.safeCheck(check))
|
||||
};
|
||||
}
|
||||
|
||||
function safeCheck(candidate: var): var {
|
||||
const check = {
|
||||
id: candidate.id,
|
||||
group: candidate.group,
|
||||
title: candidate.title,
|
||||
status: candidate.status,
|
||||
detail: candidate.detail
|
||||
};
|
||||
if (candidate.action !== undefined)
|
||||
check.action = root.safeAction(candidate.action);
|
||||
return check;
|
||||
}
|
||||
|
||||
function safeAction(candidate: var): var {
|
||||
const action = {
|
||||
kind: candidate.kind,
|
||||
label: candidate.label,
|
||||
confirm: candidate.confirm
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(candidate, "target"))
|
||||
action.target = candidate.target;
|
||||
return action;
|
||||
}
|
||||
|
||||
function repair(id: string, external: bool): bool {
|
||||
if (root.busy)
|
||||
return false;
|
||||
|
||||
const check = root.checks.find(candidate => candidate.id === id);
|
||||
if (!check || !check.action || check.action.kind !== "repair")
|
||||
return false;
|
||||
|
||||
root.repairingId = id;
|
||||
root.lastError = "";
|
||||
repairProcess.checkId = id;
|
||||
repairProcess.external = external;
|
||||
repairProcess.exec([root.helperPath, "--repair", id, "--json"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishRepair(exitCode: int, id: string, external: bool): void {
|
||||
const succeeded = exitCode === 0;
|
||||
root.repairingId = "";
|
||||
root.lastRepair = ({ id: id, succeeded: succeeded });
|
||||
if (!succeeded) {
|
||||
root.lastError = "Panama could not repair this item. Try refreshing or use the recommended setup steps.";
|
||||
if (external) {
|
||||
failureNotification.exec([
|
||||
"notify-send", "-a", "Panama", "-i", "dialog-error-symbolic",
|
||||
"Panama action failed", "The requested health repair could not be completed."
|
||||
]);
|
||||
}
|
||||
}
|
||||
root.refresh();
|
||||
}
|
||||
|
||||
function copyReport(): bool {
|
||||
if (copyProcess.running)
|
||||
return false;
|
||||
copyProcess.payload = JSON.stringify(root.snapshot, null, 2);
|
||||
root.lastCopyResult = "";
|
||||
copyProcess.exec(["wl-copy"]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function diagnostics(): var {
|
||||
return {
|
||||
status: root.status,
|
||||
summary: root.summary,
|
||||
busy: root.busy,
|
||||
diagnosticUnavailable: root.diagnosticUnavailable,
|
||||
queuedRefresh: root.queuedRefresh,
|
||||
generation: root.generation,
|
||||
acceptedGeneration: root.acceptedGeneration,
|
||||
repairingId: root.repairingId,
|
||||
checks: root.checks.map(check => check.id),
|
||||
checkStates: root.checks.map(check => ({ id: check.id, status: check.status }))
|
||||
};
|
||||
}
|
||||
|
||||
function validSnapshot(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| candidate.schemaVersion !== 1
|
||||
|| typeof candidate.generatedAt !== "string" || candidate.generatedAt.length === 0
|
||||
|| !root.validSummary(candidate.summary)
|
||||
|| !root.validContext(candidate.context)
|
||||
|| !Array.isArray(candidate.checks) || candidate.checks.length === 0)
|
||||
return false;
|
||||
|
||||
const ids = {};
|
||||
const counts = { ok: 0, warning: 0, error: 0, unconfigured: 0 };
|
||||
for (const check of candidate.checks) {
|
||||
if (!root.validCheck(check) || ids[check.id])
|
||||
return false;
|
||||
ids[check.id] = true;
|
||||
counts[check.status] += 1;
|
||||
}
|
||||
|
||||
const computedStatus = counts.error > 0 ? "error" : counts.warning > 0 ? "warning" : "healthy";
|
||||
return candidate.summary.healthy === counts.ok
|
||||
&& candidate.summary.warnings === counts.warning
|
||||
&& candidate.summary.errors === counts.error
|
||||
&& candidate.summary.unconfigured === counts.unconfigured
|
||||
&& candidate.summary.status === computedStatus;
|
||||
}
|
||||
|
||||
function validSummary(candidate: var): bool {
|
||||
if (!root.plainObject(candidate) || root.overallStatuses.indexOf(candidate.status) < 0)
|
||||
return false;
|
||||
for (const key of ["healthy", "warnings", "errors", "unconfigured"]) {
|
||||
if (!Number.isInteger(candidate[key]) || candidate[key] < 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validContext(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| ["hyprland", "other"].indexOf(candidate.session) < 0
|
||||
|| !Array.isArray(candidate.versions))
|
||||
return false;
|
||||
return candidate.versions.every(version => root.plainObject(version)
|
||||
&& typeof version.id === "string" && version.id.length > 0
|
||||
&& typeof version.version === "string" && version.version.length > 0);
|
||||
}
|
||||
|
||||
function validCheck(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(candidate.id)
|
||||
|| root.groups.indexOf(candidate.group) < 0
|
||||
|| root.statuses.indexOf(candidate.status) < 0
|
||||
|| typeof candidate.title !== "string" || candidate.title.length === 0
|
||||
|| typeof candidate.detail !== "string" || candidate.detail.length === 0)
|
||||
return false;
|
||||
return candidate.action === undefined || root.validAction(candidate.action);
|
||||
}
|
||||
|
||||
function validAction(candidate: var): bool {
|
||||
if (!root.plainObject(candidate)
|
||||
|| ["repair", "open", "instructions"].indexOf(candidate.kind) < 0
|
||||
|| typeof candidate.label !== "string" || candidate.label.length === 0
|
||||
|| typeof candidate.confirm !== "boolean")
|
||||
return false;
|
||||
|
||||
const allowedKeys = ["kind", "label", "confirm", "target"];
|
||||
if (!Object.keys(candidate).every(key => allowedKeys.indexOf(key) >= 0))
|
||||
return false;
|
||||
|
||||
const hasTarget = Object.prototype.hasOwnProperty.call(candidate, "target");
|
||||
if (!hasTarget)
|
||||
return true;
|
||||
if (typeof candidate.target !== "string" || candidate.target.length === 0 || candidate.kind === "repair")
|
||||
return false;
|
||||
return candidate.kind === "open"
|
||||
? root.settingsTargets.indexOf(candidate.target) >= 0
|
||||
: root.instructionTargets.indexOf(candidate.target) >= 0;
|
||||
}
|
||||
|
||||
function plainObject(value: var): bool {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,26 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "health"
|
||||
|
||||
function refresh(): bool { return Health.refresh(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
summary: Health.summary,
|
||||
busy: Health.busy,
|
||||
generation: Health.generation,
|
||||
acceptedGeneration: Health.acceptedGeneration,
|
||||
checks: Health.checks.map(check => ({ id: check.id, status: check.status }))
|
||||
});
|
||||
}
|
||||
function open(): void {
|
||||
ShellState.openSettings("services");
|
||||
Health.refresh();
|
||||
}
|
||||
function repair(id: string): bool { return Health.repair(id, true); }
|
||||
}
|
||||
|
||||
// A small diagnostics surface doubles as a deterministic contract harness.
|
||||
// Real producers call StatusEvents.publish() directly; fixtures never run
|
||||
// unless explicitly requested over IPC by the test suite.
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
# Panama Health & Recovery Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a quiet, trustworthy System Health surface that diagnoses Panama-owned desktop functionality, exposes redacted reports, and offers only narrow allow-listed repairs.
|
||||
|
||||
**Architecture:** An executable Python helper, `panama-doctor`, is the only operating-system boundary and emits one deterministic JSON schema. A `Health.qml` singleton owns accepted snapshots, scan generations, repair state, and report copying; Settings, the bar, IPC, and Vicinae consume that typed state without constructing commands.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, Bash contract tests, Quickshell/QML, QtQuick, Hyprland IPC, Vicinae script commands, Prism design tokens.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-panama-health-recovery-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Healthy background scans are silent: no notifications, Signal Glass events, animations, or permanent bar ornament.
|
||||
- Allowed statuses are exactly `ok`, `warning`, `error`, and `unconfigured`; overall status is `healthy`, `warning`, or `error`.
|
||||
- Optional integrations that have never been configured are `unconfigured`, never warnings.
|
||||
- The helper never reads or reports secret values, clipboard contents, notification bodies, calendar event data, SSIDs, addresses, or arbitrary command output.
|
||||
- The helper never installs packages, invokes `sudo`, deletes user data, rewrites arbitrary configuration, or repairs services Panama does not own.
|
||||
- Probe-derived values may populate observations only; check IDs, groups, titles, actions, commands, and arguments are authored constants.
|
||||
- All process launches use argument arrays. UI text and report content never become commands.
|
||||
- Preserve the last valid snapshot on helper failure or malformed JSON.
|
||||
- Repairs are judged by a fresh observed scan, not by process exit status alone.
|
||||
- Do not run a state-changing live repair without a genuinely degraded disposable target or explicit user approval.
|
||||
|
||||
## File map and stable interfaces
|
||||
|
||||
- `config/dot/quickshell/scripts/panama-doctor`: Python CLI and sole diagnostic/repair OS boundary.
|
||||
- `config/dot/quickshell/services/Health.qml`: snapshot state machine, scan/repair processes, report copy, and fixture seams.
|
||||
- `config/dot/quickshell/health-harness.qml`: deterministic IPC harness for generations, malformed data, coalescing, and repairs.
|
||||
- `config/dot/quickshell/modules/settings/HealthPage.qml`: System Health page composition.
|
||||
- `config/dot/quickshell/modules/settings/HealthSummary.qml`: stable-height summary hero and primary controls.
|
||||
- `config/dot/quickshell/modules/settings/HealthCheckRow.qml`: one accessible check row with one action.
|
||||
- `config/dot/quickshell/modules/bar/HealthIndicator.qml`: degraded-only bar entry point.
|
||||
- `config/local/share/vicinae/scripts/check-system-health.sh`: searchable launcher command.
|
||||
- `tests/quickshell/fixtures/doctor/`: isolated command, config, state, and runtime fixtures containing no real workstation data.
|
||||
- `tests/quickshell/panama-doctor-contract.sh`: schema, status, redaction, timeout, ordering, and repair allow-list contract.
|
||||
- `tests/quickshell/health-service-contract.sh`: QML state-machine contract.
|
||||
- `tests/quickshell/health-ui-contract.sh`: Settings, footer, report, indicator, IPC, and Vicinae integration contract.
|
||||
|
||||
The helper's authored check order is:
|
||||
|
||||
```text
|
||||
desktop.hyprland
|
||||
desktop.quickshell
|
||||
desktop.notifications
|
||||
desktop.portals
|
||||
desktop.hyprpaper
|
||||
desktop.hypridle
|
||||
desktop.vicinae
|
||||
input.pipewire
|
||||
input.clipboard
|
||||
input.wallpaper
|
||||
input.capture
|
||||
input.ocr
|
||||
input.brightness
|
||||
integration.nextcloud
|
||||
integration.rustdesk
|
||||
integration.kdeconnect
|
||||
integration.bluebubbles
|
||||
integration.home-assistant
|
||||
integration.calendar
|
||||
panama.runtime-links
|
||||
panama.vicinae-commands
|
||||
panama.selected-terminal
|
||||
panama.selected-launcher
|
||||
panama.processes
|
||||
panama.caffeine
|
||||
```
|
||||
|
||||
Only these repair IDs are executable in release one:
|
||||
|
||||
```text
|
||||
desktop.hyprpaper -> systemctl --user restart hyprpaper.service
|
||||
desktop.hypridle -> systemctl --user restart hypridle.service
|
||||
desktop.vicinae -> systemctl --user restart vicinae.service
|
||||
desktop.quickshell -> panama-action restart-shell (confirmation required)
|
||||
panama.runtime-links -> recreate only known Panama-owned broken symlinks
|
||||
panama.vicinae-commands -> setup/scripts/link-vicinae-scripts
|
||||
panama.caffeine -> release duplicate Panama/Caffeine inhibitor PIDs only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Prism health mocks and visual approval
|
||||
|
||||
**Approved visual:** A — Diagnostic Ledger. Preserve its restrained issue rail, stable summary hero, two-column healthy ledgers, live sidebar footer, and degraded-only bar capsule.
|
||||
|
||||
**Files:**
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/index.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/panama.css`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/mock.js`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/a-ledger.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/b-focus.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/c-compact.html`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing 272 px Settings sidebar, 48 px titlebar, Tokyo Night Moon Prism tokens, and the approved information architecture.
|
||||
- Produces: one approved visual composition for healthy, warning, checking, and error states plus the degraded-only bar indicator.
|
||||
|
||||
- [ ] **Step 1: Build three static compositions from real copy**
|
||||
|
||||
Use the same warning fixture in all three: `Vicinae` is stopped with action `Restart Vicinae`; `External monitor brightness` needs permission with action `View setup instructions`; `BlueBubbles` is `Not set up`. Keep every variant inside the real Settings geometry and include the footer and bar indicator.
|
||||
|
||||
```text
|
||||
A — Diagnostic ledger: one restrained amber issue rail beside calm grouped rows.
|
||||
B — Focus card: issues receive the visual focus; healthy groups collapse into quieter ledgers below.
|
||||
C — Compact matrix: dense two-column group cards with the same issue-first ordering.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Serve and visually inspect the mocks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 52780 --directory .superpowers/mocks/system-health
|
||||
```
|
||||
|
||||
Expected: all three variants render at `http://localhost:52780`, keyboard focus is visible, no element overflows at 1360x900, and reduced-motion mode has no continuous animation.
|
||||
|
||||
- [ ] **Step 3: Capture the approved direction in the plan**
|
||||
|
||||
Add a short `Approved visual: <variant>` note beneath this task after user selection. Production UI work in Task 4 must reproduce that composition using existing QML tokens rather than copying browser-only effects.
|
||||
|
||||
### Task 2: Deterministic read-only doctor
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Create: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/systemctl`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/pgrep`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/busctl`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/qs`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/vicinae`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/systemd-inhibit`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PANAMA_DOCTOR_ROOT`, `PANAMA_DOCTOR_HOME`, `PANAMA_DOCTOR_CONFIG_HOME`, `PANAMA_DOCTOR_STATE_HOME`, `PANAMA_DOCTOR_RUNTIME_DIR`, `PANAMA_DOCTOR_PATH`, and `PANAMA_DOCTOR_TIMEOUT` test seams; production defaults resolve from the real process environment.
|
||||
- Produces: `panama-doctor --json`, `panama-doctor --summary`, and a versioned schema with `summary` plus the 25 ordered check objects listed above.
|
||||
|
||||
- [ ] **Step 1: Write the failing schema and redaction contract**
|
||||
|
||||
The contract must create an isolated home, tracked source tree, runtime tree, and fake command directory, then assert:
|
||||
|
||||
```bash
|
||||
snapshot="$($doctor --json)"
|
||||
jq -e '.schemaVersion == 1
|
||||
and (.generatedAt | type == "string")
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 25)
|
||||
and ([.checks[].id] | unique | length == 25)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' <<<"$snapshot"
|
||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]]
|
||||
! grep -Fq 'fixture-secret-token' <<<"$snapshot"
|
||||
! grep -Fq 'fixture clipboard body' <<<"$snapshot"
|
||||
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot"
|
||||
```
|
||||
|
||||
Cover a healthy required service, a missing required executable, an unconfigured optional integration, a configured-but-stopped integration, an inaccessible DDC bus, a timed-out probe, duplicate Caffeine inhibitors, malformed probe output, and concise `--summary` output.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify the helper is absent**
|
||||
|
||||
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: FAIL because `config/dot/quickshell/scripts/panama-doctor` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement authored checks and concurrent bounded probes**
|
||||
|
||||
Use Python standard-library types and deterministic assembly:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
kind: Literal["repair", "open", "instructions"]
|
||||
label: str
|
||||
confirm: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Check:
|
||||
id: str
|
||||
group: Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
title: str
|
||||
status: Literal["ok", "warning", "error", "unconfigured"]
|
||||
detail: str
|
||||
action: Action | None = None
|
||||
```
|
||||
|
||||
Run independent probes through `ThreadPoolExecutor(max_workers=8)`. Every subprocess call must use a constant argument tuple, `capture_output=True`, `text=True`, and the configured timeout. Convert timeout, non-zero status, and parse failure into a check result. Assemble checks by the authored ID tuple after futures settle; never emit completion order.
|
||||
|
||||
Configuration checks may inspect existence and file type only. Home Assistant is configured when both expected variable names are present, but their values are never retained. Calendar is configured from enabled EDS source count only; event commands are never called. BlueBubbles is configured from the Flatpak installation check. DDC uses only `panama-brightness list` and retains display count plus its authored error classification, never connector names.
|
||||
|
||||
The top-level `context` contains only an authored session class and an ordered array of parsed Hyprland, Quickshell, Fedora, and Panama revision versions. `panama.processes` counts only exact authored process names and flags duplicate Quickshell, Vicinae, Hyprpaper, or Hypridle instances without exposing command lines. Integration actions are authored too: Nextcloud, RustDesk, KDE Connect, and BlueBubbles may offer their exact Open action; Home Assistant and calendar failures route to `home-phone` and `datetime`; never derive an application or page name from probe output.
|
||||
|
||||
- [ ] **Step 4: Run the doctor contract**
|
||||
|
||||
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: `panama doctor contract: PASS`.
|
||||
|
||||
- [ ] **Step 5: Commit the read-only engine**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/scripts/panama-doctor tests/quickshell/panama-doctor-contract.sh tests/quickshell/fixtures/doctor
|
||||
git commit -m "Add Panama system health diagnostics"
|
||||
```
|
||||
|
||||
### Task 3: Health singleton and typed IPC state machine
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/services/Health.qml`
|
||||
- Create: `config/dot/quickshell/health-harness.qml`
|
||||
- Create: `tests/quickshell/health-service-contract.sh`
|
||||
- Modify: `config/dot/quickshell/shell.qml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `panama-doctor --json` and `panama-doctor --repair CHECK_ID --json`.
|
||||
- Produces: `Health.snapshot`, `Health.checks`, `Health.summary`, `Health.status`, `Health.actionable`, `Health.busy`, `Health.diagnosticUnavailable`, `Health.lastError`, `Health.repairingId`, `Health.lastRepair`, `Health.lastCopyResult`, `Health.refresh()`, `Health.repair(id, external)`, `Health.copyReport()`, and IPC target `health` with `refresh`, `status`, `open`, and `repair(id)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing QML state contract**
|
||||
|
||||
The harness exposes fixture methods that call the real singleton's pure consumption seams:
|
||||
|
||||
```qml
|
||||
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||
```
|
||||
|
||||
Assert that a valid warning snapshot is accepted, an older generation is ignored, malformed JSON preserves the prior checks and marks the engine unavailable, two refreshes while running schedule exactly one follow-up, a valid repair triggers one rescan, and unknown/non-repairable IDs start no process.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify it fails**
|
||||
|
||||
Run: `tests/quickshell/health-service-contract.sh`
|
||||
|
||||
Expected: FAIL because `Health.qml` and the harness do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the singleton state machine**
|
||||
|
||||
Define the stable state shape:
|
||||
|
||||
```qml
|
||||
property var snapshot: ({})
|
||||
property var checks: []
|
||||
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
|
||||
property string status: "healthy"
|
||||
property bool diagnosticUnavailable: false
|
||||
property bool queuedRefresh: false
|
||||
property int generation: 0
|
||||
property int acceptedGeneration: 0
|
||||
property string repairingId: ""
|
||||
property var lastRepair: ({})
|
||||
property string lastCopyResult: ""
|
||||
readonly property bool actionable: status === "warning" || status === "error"
|
||||
readonly property bool busy: scanProcess.running || repairProcess.running
|
||||
```
|
||||
|
||||
Use `Process.exec([root.helperPath, "--json"])`; attach the current generation to the collector before launch. `consumeSnapshot(text, generation)` validates schema version, summary keys, context shape, unique IDs, groups, statuses, titles, details, and action shapes before replacing state. A 2200 ms one-shot startup timer requests the initial scan. A running scan sets `queuedRefresh`; exit consumes at most one queued follow-up. `copyReport()` sends only `JSON.stringify(root.snapshot, null, 2)` to `wl-copy` through a `Process` stdin buffer and writes success or failure to `lastCopyResult` without touching the clipboard service's history model.
|
||||
|
||||
The `health` IPC `status()` returns only the already-redacted summary, busy flags, generation, and check IDs/statuses. `open()` calls `ShellState.openSettings("services")` then refreshes. IPC `repair(id)` calls `Health.repair(id, true)` and returns a Boolean acceptance result; Settings calls `Health.repair(id, false)`. A failed externally-originated repair uses an argument-array `notify-send` process with the existing `Panama action failed` title, while Settings failures remain inline.
|
||||
|
||||
- [ ] **Step 4: Run service and IPC contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/settings-window-contract.sh
|
||||
```
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 5: Commit the service layer**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Health.qml config/dot/quickshell/health-harness.qml tests/quickshell/health-service-contract.sh config/dot/quickshell/shell.qml
|
||||
git commit -m "Add Panama health state service"
|
||||
```
|
||||
|
||||
### Task 4: Approved System Health Settings page
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthPage.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthSummary.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Delete: `config/dot/quickshell/modules/settings/ServicesPage.qml`
|
||||
- Create: `tests/quickshell/health-ui-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all read-only state and methods from `Health.qml`; route remains the stable internal name `services`.
|
||||
- Produces: System Health summary, issues-first cards, four grouped ledgers, live clickable sidebar footer, Copy Report feedback, and confirmation requests for disruptive repairs.
|
||||
|
||||
- [ ] **Step 1: Write failing Settings and accessibility assertions**
|
||||
|
||||
Assert static structure and fixture-rendered state:
|
||||
|
||||
```bash
|
||||
rg -Fq 'label: "System Health"' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||
rg -Fq 'onClicked: Health.copyReport()' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||
rg -Fq 'onTapped: root.pageRequested("services")' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||
rg -Fq 'text: "Checking…"' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||
rg -Fq 'Health.refresh()' config/dot/quickshell/modules/settings/HealthPage.qml
|
||||
```
|
||||
|
||||
The runtime harness must prove warning rows appear before healthy groups, unconfigured is visible as `Not set up`, every status has text in addition to color, refresh preserves row geometry, keyboard focus reaches both hero actions and row actions, and a Quickshell-restart repair opens a confirmation sheet.
|
||||
|
||||
- [ ] **Step 2: Run UI contracts and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because the approved Health components are absent.
|
||||
|
||||
- [ ] **Step 3: Implement the approved composition**
|
||||
|
||||
Use `SettingsPage`, `SettingsCard`, `SettingsButton`, `Theme`, and `PrismEdge`. Keep the summary hero stable at 126 px and each check row at least 62 px. Derive labels exactly:
|
||||
|
||||
```qml
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "ok") return "Healthy";
|
||||
if (status === "warning") return "Needs attention";
|
||||
if (status === "error") return "Action required";
|
||||
return "Not set up";
|
||||
}
|
||||
```
|
||||
|
||||
`HealthPage.Component.onCompleted` calls `Health.refresh()`. Issues are checks with `warning` or `error`. Group cards preserve helper order. Each row exposes at most one action. `open` actions route to exact Settings pages; `instructions` actions reveal authored inline instructions; `repair` actions call `Health.repair(id, false)` after confirmation only when `action.confirm === true`.
|
||||
|
||||
The sidebar footer is a 54 px `TapHandler` target with status text derived from Health, not a hardcoded string. It opens `services`; when no scan has completed it says `Checking Panama desktop`. A malformed or failed doctor run retains the last rows, changes only the hero to `Health check unavailable`, and exposes one bounded `Retry` action. The page ends with the approved boundary note and an `Open GNOME Settings` action for networking, printers, users, and other Fedora-owned areas.
|
||||
|
||||
- [ ] **Step 4: Run UI contracts and inspect the rendered fixture**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS with zero QML warnings.
|
||||
|
||||
- [ ] **Step 5: Commit the Settings experience**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/health-ui-contract.sh tests/quickshell/settings-pages-contract.sh tests/quickshell/settings-search-contract.sh
|
||||
git commit -m "Build the System Health settings page"
|
||||
```
|
||||
|
||||
### Task 5: Quiet bar indicator and launcher entry point
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/bar/HealthIndicator.qml`
|
||||
- Modify: `config/dot/quickshell/modules/bar/Bar.qml`
|
||||
- Create: `config/local/share/vicinae/scripts/check-system-health.sh`
|
||||
- Modify: `config/dot/quickshell/scripts/panama-action`
|
||||
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-action-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-commands-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Health.actionable`, `Health.status`, and `Health.summary`; existing `panama-action` dispatcher and Settings IPC.
|
||||
- Produces: one degraded-only bar affordance and Vicinae command `Panama: Check System Health`.
|
||||
|
||||
- [ ] **Step 1: Extend contracts before production files**
|
||||
|
||||
Assert the indicator is absent for healthy/unconfigured-only fixtures, visible amber for warnings, visible red for errors, includes a textual accessible label, and opens `services`. Extend command fixtures so:
|
||||
|
||||
```text
|
||||
panama-action health -> qs ipc call health open
|
||||
check-system-health.sh title -> Panama: Check System Health
|
||||
check-system-health.sh exec -> $HOME/.config/quickshell/scripts/panama-action health
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/panama-action-contract.sh
|
||||
tests/quickshell/panama-commands-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL on the missing indicator and command.
|
||||
|
||||
- [ ] **Step 3: Implement the quiet entry points**
|
||||
|
||||
Place `HealthIndicator` in the right-side bar row before `ActivityIndicator`. It has no reserved width while hidden, no animation, and one compact shield/wrench glyph with an issue-count tooltip or accessible description. Use `Theme.warn` only for warnings and `Theme.danger` only for errors. Clicking calls `ShellState.openSettings("services")` and `Health.refresh()`.
|
||||
|
||||
Add this dispatcher case and usage token:
|
||||
|
||||
```bash
|
||||
health) qs ipc call health open ;;
|
||||
```
|
||||
|
||||
Create a Vicinae script with schema version 1, silent mode, Panama Settings icon, keywords `health`, `doctor`, `repair`, `services`, and the stable `panama-action health` execution path.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run the three commands from Step 2.
|
||||
|
||||
Expected: all PASS; command count increases from 17 to 18.
|
||||
|
||||
- [ ] **Step 5: Commit the entry points**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/bar config/local/share/vicinae/scripts/check-system-health.sh config/dot/quickshell/scripts/panama-action tests/quickshell
|
||||
git commit -m "Add quiet System Health entry points"
|
||||
```
|
||||
|
||||
### Task 6: Allow-listed repairs and observed recovery
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Modify: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Modify: `config/dot/quickshell/services/Health.qml`
|
||||
- Modify: `tests/quickshell/health-service-contract.sh`
|
||||
- Modify: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the fixed repair matrix in this plan and current accepted checks from `Health.qml`.
|
||||
- Produces: `panama-doctor --repair CHECK_ID --json` result `{schemaVersion, checkId, accepted, exitCode, message}`, inline repair state, and one post-repair scan.
|
||||
|
||||
- [ ] **Step 1: Add exact repair-command tests**
|
||||
|
||||
For every repair ID, use fake commands and isolated paths to assert the exact argv. Assert all of these are rejected before any process or filesystem write:
|
||||
|
||||
```text
|
||||
unknown.check
|
||||
integration.home-assistant
|
||||
input.brightness
|
||||
desktop.notifications
|
||||
../../escape
|
||||
desktop.vicinae;touch injected
|
||||
```
|
||||
|
||||
For runtime links, fixtures must prove only these link names are eligible: `hypr`, `quickshell`, `uwsm`, and `vicinae`; a regular user-owned directory is reported but never replaced. For Caffeine, only duplicate rows with application `Panama`, current UID, reason `Caffeine`, and mode `block` may yield numeric PIDs; leave one valid inhibitor alive and release extras.
|
||||
|
||||
- [ ] **Step 2: Run repair contracts and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because `--repair` is not implemented.
|
||||
|
||||
- [ ] **Step 3: Implement the authored repair registry**
|
||||
|
||||
Represent commands as immutable constant tuples or dedicated functions:
|
||||
|
||||
```python
|
||||
REPAIR_COMMANDS = {
|
||||
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
|
||||
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
|
||||
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
|
||||
"desktop.quickshell": ("panama-action", "restart-shell"),
|
||||
}
|
||||
```
|
||||
|
||||
Handle runtime links, Vicinae command linking, and duplicate inhibitors in dedicated functions that accept no caller-controlled path or command. Return JSON on every known failure. Unknown IDs exit 2 with `accepted: false` and do not invoke any runner.
|
||||
|
||||
`Health.repair(id, external)` requires the ID to exist in the current snapshot with `action.kind === "repair"`, records `repairingId`, runs the helper with an argument array, parses the result, clears the busy row, and requests exactly one fresh scan. Keep the row degraded until that scan reports recovery.
|
||||
|
||||
- [ ] **Step 4: Run repair and UI contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit repairs**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/services/Health.qml config/dot/quickshell/modules/settings/HealthCheckRow.qml tests/quickshell
|
||||
git commit -m "Add bounded Panama recovery actions"
|
||||
```
|
||||
|
||||
### Task 7: Full verification, live read-only audit, and documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/hypr/DESKTOP-PARITY.md`
|
||||
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||
- Modify: `docs/superpowers/plans/2026-08-18-panama-health-recovery.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the complete feature and existing regression suite.
|
||||
- Produces: current user documentation, a redacted live health snapshot, and final verification evidence.
|
||||
|
||||
- [ ] **Step 1: Document boundaries and entry points**
|
||||
|
||||
Document `Panama: Check System Health`, Settings → System Health, the degraded-only bar indicator, `panama-doctor --summary`, the no-`sudo`/no-package-install boundary, and the fact that GNOME/Fedora tools remain responsible for generic system configuration.
|
||||
|
||||
- [ ] **Step 2: Run syntax, focused, and full contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile config/dot/quickshell/scripts/panama-doctor
|
||||
bash -n config/dot/quickshell/scripts/panama-action
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
for test in tests/quickshell/*contract.sh; do "$test"; done
|
||||
for test in tests/hypr/*contract.sh; do "$test"; done
|
||||
```
|
||||
|
||||
Expected: every command exits 0; Quickshell tests report 58 contracts after the three new contracts land.
|
||||
|
||||
- [ ] **Step 3: Run a redacted live read-only comparison**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
config/dot/quickshell/scripts/panama-doctor --json >"$(mktemp)"
|
||||
config/dot/quickshell/scripts/panama-doctor --summary
|
||||
systemctl --user is-active hyprpaper.service hypridle.service vicinae.service pipewire.service
|
||||
qs ipc call health refresh
|
||||
qs ipc call health status | jq '{status, busy, checks: [.checks[] | {id, status}]}'
|
||||
```
|
||||
|
||||
Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations.
|
||||
|
||||
- [ ] **Step 4: Reload and inspect the live shell**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
qs reload
|
||||
sleep 4
|
||||
journalctl --user --since '-2 minutes' --no-pager | rg -i 'quickshell|qml|panama' | tail -200
|
||||
```
|
||||
|
||||
Expected: the shell returns, System Health opens, the healthy state is silent, and there are no new QML errors or binding-loop warnings. Do not invoke a repair during this step.
|
||||
|
||||
- [ ] **Step 5: Final diff and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
git diff --stat origin/main...HEAD
|
||||
git add config/dot/hypr/DESKTOP-PARITY.md config/dot/quickshell/modules/settings/README.md docs/superpowers/plans/2026-08-18-panama-health-recovery.md
|
||||
git commit -m "Document Panama health and recovery"
|
||||
```
|
||||
|
||||
Expected: only intentional Health & Recovery files are present and no workstation-specific values appear in the diff.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Panama Health & Recovery Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Panama Health & Recovery makes the desktop explain itself. It verifies the
|
||||
local services, dependencies, links, and integrations that Panama relies on,
|
||||
then presents useful recovery actions without asking the user to read logs or
|
||||
diagnose a collection of unrelated Linux processes.
|
||||
|
||||
The feature is intentionally quiet. A healthy desktop produces no notification,
|
||||
banner, or permanent bar ornament. Problems appear in Panama Settings and, when
|
||||
actionable, as one restrained bar indicator. User-initiated repairs receive
|
||||
immediate Prism OSD or inline feedback.
|
||||
|
||||
## Product boundaries
|
||||
|
||||
The first release covers Panama-owned or Panama-integrated functionality:
|
||||
|
||||
- Hyprland, Quickshell, the notification server, XDG desktop portals, PipeWire,
|
||||
Vicinae, the clipboard watcher, wallpaper, idle policy, and Panama's runtime
|
||||
configuration links.
|
||||
- The Panama command collection, screenshot and OCR dependencies, DDC
|
||||
brightness support, and the currently selected terminal and launcher.
|
||||
- Nextcloud, RustDesk, KDE Connect, BlueBubbles, Home Assistant, calendar
|
||||
aggregation, and the configured autostart entries.
|
||||
- Orphaned Panama processes and inhibitors, including duplicate Caffeine locks.
|
||||
- Versions and non-sensitive diagnostic context needed for a useful copied
|
||||
report.
|
||||
|
||||
It does not become a package manager, a generic system monitor, or a replacement
|
||||
for Fedora's troubleshooting tools. It never installs packages, invokes `sudo`,
|
||||
deletes user data, rewrites arbitrary configuration, or repairs services Panama
|
||||
does not own.
|
||||
|
||||
An optional integration that has never been configured is neutral **Not set
|
||||
up**, not a warning. A configured integration that cannot operate is degraded.
|
||||
This distinction prevents the health UI from pressuring the user to enable
|
||||
features they do not want.
|
||||
|
||||
## Information architecture
|
||||
|
||||
The existing **Startup & Services** destination becomes **System Health**. This
|
||||
avoids two pages reporting the same background services. Its existing Open and
|
||||
Refresh actions remain available through the richer health rows.
|
||||
|
||||
The page has four levels:
|
||||
|
||||
1. A compact summary hero: **Healthy**, **Needs attention**, or **Action
|
||||
required**, the last completed scan time, Refresh, and Copy Report.
|
||||
2. An issues-first section shown only when one or more checks are degraded.
|
||||
3. Grouped cards for Desktop Foundation, Input & Media, Integrations, and Panama
|
||||
Tools. Healthy rows remain visible but visually quiet.
|
||||
4. A short boundary note linking to GNOME or Fedora tools for system areas Panama
|
||||
does not own.
|
||||
|
||||
Each row contains a stable title, one-sentence observation, status label, and at
|
||||
most one primary action. Actions use concrete language such as **Restart
|
||||
Vicinae**, **Repair command link**, **Open Home settings**, or **View setup
|
||||
instructions**. There is no generic Fix Everything button.
|
||||
|
||||
The Settings sidebar's existing health footer becomes real and clickable. It
|
||||
shows the aggregate state and opens System Health. The top bar gains a small
|
||||
`HealthIndicator` only while an actionable warning or error exists; clicking it
|
||||
opens the same page. Background scans never publish Signal Glass events or
|
||||
desktop notifications.
|
||||
|
||||
## Diagnostic engine
|
||||
|
||||
`config/dot/quickshell/scripts/panama-doctor` is the single operating-system
|
||||
boundary. It supports:
|
||||
|
||||
- `panama-doctor --json` for a complete versioned snapshot.
|
||||
- `panama-doctor --summary` for a concise human-readable installer or terminal
|
||||
result.
|
||||
- `panama-doctor --repair CHECK_ID --json` for an explicitly allow-listed repair.
|
||||
|
||||
The helper emits one schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-08-18T12:00:00Z",
|
||||
"summary": {
|
||||
"status": "warning",
|
||||
"healthy": 18,
|
||||
"warnings": 1,
|
||||
"errors": 0,
|
||||
"unconfigured": 2
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"id": "launcher.panama-commands",
|
||||
"group": "panama-tools",
|
||||
"title": "Panama Commands",
|
||||
"status": "warning",
|
||||
"detail": "16 of 17 commands are loaded",
|
||||
"action": {
|
||||
"kind": "repair",
|
||||
"label": "Repair command link"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Allowed statuses are `ok`, `warning`, `error`, and `unconfigured`. Check IDs,
|
||||
group IDs, titles, and repair mappings are authored constants. Probe output may
|
||||
populate observations but can never become a command or executable argument.
|
||||
|
||||
Checks run concurrently where doing so is safe, with short per-probe timeouts.
|
||||
A failed or timed-out probe yields a check result rather than aborting the whole
|
||||
snapshot. Output order is deterministic so tests, copied reports, and visual
|
||||
rows do not jump between scans.
|
||||
|
||||
No secrets are read. The report may state whether a Home Assistant URL or token
|
||||
is configured, but never includes either value. It excludes clipboard contents,
|
||||
notification bodies, calendar event data, SSIDs, device addresses, environment
|
||||
values, file contents, and command output that has not been explicitly parsed.
|
||||
|
||||
## Quickshell state and refresh model
|
||||
|
||||
`services/Health.qml` owns the latest accepted snapshot, aggregate severity,
|
||||
busy state, last scan time, and the result of the most recent repair. It invokes
|
||||
`panama-doctor` with argument arrays through `Process`; UI components never
|
||||
construct shell commands.
|
||||
|
||||
Health performs one delayed scan after the shell reaches a stable startup state.
|
||||
It scans again when the System Health page is opened, when the user presses
|
||||
Refresh, and after a repair settles. There is no periodic polling loop while the
|
||||
desktop is idle. Services that already expose event-driven state remain the
|
||||
authoritative source for their own interactive controls; Health is a diagnostic
|
||||
snapshot, not a competing live service model.
|
||||
|
||||
Every scan receives a monotonically increasing generation. Late output from an
|
||||
older scan is discarded. A malformed snapshot leaves the last valid result in
|
||||
place, marks the diagnostic engine unavailable, and offers a bounded Retry.
|
||||
|
||||
The shell exposes a typed `health` IPC target with `refresh`, `status`, `open`,
|
||||
and `repair(id)` operations. Vicinae gains **Panama: Check System Health**, which
|
||||
opens the page and requests a fresh scan through the existing `panama-action`
|
||||
dispatcher.
|
||||
|
||||
## Repair policy
|
||||
|
||||
Repairs are narrow, reversible, and attached to one check. The first release may:
|
||||
|
||||
- Restart Panama's user services such as Vicinae, Hyprpaper, or Hypridle.
|
||||
- Recreate Panama-owned symlinks when their destination is known and tracked.
|
||||
- Reload Vicinae's Panama command collection.
|
||||
- Release duplicate user-owned inhibitors whose metadata identifies Panama and
|
||||
Caffeine.
|
||||
- Restart Quickshell through the verified `panama-action restart-shell` path.
|
||||
- Open the exact Panama Settings page required to finish credentials or entity
|
||||
selection.
|
||||
|
||||
Restarting a working service is not presented as a repair. Repairs that interrupt
|
||||
visible desktop chrome require a confirmation sheet in Settings. Navigation and
|
||||
setup actions do not. Package installation, privileged service changes, display
|
||||
mode writes, and destructive cleanup are never automatic; the UI shows concise
|
||||
instructions instead.
|
||||
|
||||
After a repair, Health rescans and judges success from the observed result. A
|
||||
zero exit status alone never turns a row green. Failure remains inline on the
|
||||
affected row and also produces the existing Panama action-failure notification
|
||||
when the action originated outside Settings.
|
||||
|
||||
## Visual language and interaction
|
||||
|
||||
System Health uses the established Settings cards and Prism tokens. Healthy
|
||||
states use a small muted green dot and subdued **Healthy** copy. Warnings use
|
||||
amber; red is reserved for functionality that is configured, required, and
|
||||
currently broken. `unconfigured` rows use neutral gray.
|
||||
|
||||
The summary hero does not use a decorative gauge, percentage score, pulse,
|
||||
shimmer, or animated gradient. A desktop is not “82% healthy.” The headline and
|
||||
issue count are more understandable and do not create false precision.
|
||||
|
||||
Rows keep their height stable while refreshing. The previous snapshot remains
|
||||
visible with a quiet **Checking…** label rather than replacing the page with a
|
||||
spinner. Keyboard focus order reaches Refresh, Copy Report, issue rows, repair
|
||||
actions, and external handoffs. Status is always expressed in text as well as
|
||||
color.
|
||||
|
||||
Before production components are edited, the page and degraded bar indicator
|
||||
will be shown in several static mocks using the existing Settings geometry. The
|
||||
chosen mock must preserve this information architecture and Panama's current
|
||||
Prism language rather than introduce a new visual system.
|
||||
|
||||
## Failure handling
|
||||
|
||||
- Missing required executables become actionable check results.
|
||||
- Missing optional applications remain neutral until configured.
|
||||
- A doctor crash, timeout, or malformed JSON does not clear the last good
|
||||
snapshot or crash Quickshell.
|
||||
- Concurrent refresh requests coalesce into one follow-up scan.
|
||||
- A repair request for an unknown or non-repairable ID is rejected before any
|
||||
process starts.
|
||||
- Copy Report uses only the already-redacted snapshot and reports clipboard
|
||||
failure inline.
|
||||
- If the Settings window is closed during a scan or repair, the process may
|
||||
finish; reopening the page shows the settled result.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the real helper against isolated fake command, config, state, and runtime
|
||||
directories and prove every status transition deterministically.
|
||||
- Validate the JSON schema, stable check IDs, deterministic ordering, and
|
||||
uniqueness of each ID.
|
||||
- Prove unconfigured integrations remain neutral while configured failures are
|
||||
degraded.
|
||||
- Prove reports contain no fixture secrets, clipboard text, calendar data,
|
||||
addresses, or unparsed environment values.
|
||||
- Exercise every repair through the allow-list, assert its exact command, and
|
||||
prove unknown IDs cannot execute anything.
|
||||
- Test scan generations, malformed snapshots, refresh coalescing, repair
|
||||
rescans, and preservation of the last valid state in a Quickshell harness.
|
||||
- Verify Settings routing, search entries, the live sidebar footer, and the
|
||||
degraded-only bar indicator without QML warnings.
|
||||
- Validate the Vicinae command and typed IPC surface.
|
||||
- Run a read-only doctor scan on the real workstation and compare key results to
|
||||
direct service checks. State-changing live repair tests require an actually
|
||||
degraded disposable target or explicit user approval.
|
||||
- Restart the live shell, inspect the fresh log, and visually review healthy,
|
||||
warning, error, unconfigured, refreshing, and repair-result states.
|
||||
|
||||
## Delivery slices
|
||||
|
||||
1. Diagnostic schema, read-only probes, redaction, and contract tests.
|
||||
2. `Health.qml`, typed IPC, startup/manual refresh, and fixture harness.
|
||||
3. System Health Settings page, live sidebar footer, search, and report copy.
|
||||
4. Degraded-only bar indicator and Vicinae command.
|
||||
5. Allow-listed repairs, confirmations, post-repair verification, and live audit.
|
||||
|
||||
The slices are one feature and land together. Their order keeps the UI backed by
|
||||
real diagnostics from its first production render.
|
||||
@@ -70,6 +70,27 @@ for dir in "${dirs[@]}"; do
|
||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||
done
|
||||
|
||||
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
||||
# from the desktop colour scheme rather than committed -- it is machine state.
|
||||
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
||||
# about a missing include and falling back to its stock colours. Seed it from
|
||||
# the scheme in settings.json (dark unless the user has chosen otherwise) so a
|
||||
# first launch is themed; ColorScheme.qml overwrites it on every change after.
|
||||
KITTY_THEME="$PANAMA_DOT/kitty/current-theme.conf"
|
||||
if [ -e "$KITTY_THEME" ]; then
|
||||
log "Keeping existing kitty theme at $KITTY_THEME"
|
||||
else
|
||||
scheme="dark"
|
||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
if [ -r "$settings" ]; then
|
||||
stored="$(jq -r '.colorScheme // "dark"' "$settings" 2>/dev/null || echo dark)"
|
||||
[ "$stored" = "light" ] && scheme="light"
|
||||
fi
|
||||
[ "$scheme" = "light" ] && theme="tokyonight-day" || theme="tokyonight-moon"
|
||||
cp "$PANAMA_DOT/kitty/themes/$theme.conf" "$KITTY_THEME"
|
||||
log "Seeded kitty $scheme theme ($theme) → $KITTY_THEME"
|
||||
fi
|
||||
|
||||
# Vicinae 0.26 discovers user themes from its XDG data directory rather than
|
||||
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
||||
# the launcher config and expose only that file at Vicinae's runtime path.
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
|
||||
#
|
||||
# The parts worth pinning down are the ones that decide whether a slider appears
|
||||
# at all, and whether it appears attached to the right screen:
|
||||
#
|
||||
# * only connectors with something plugged in are probed, because probing an
|
||||
# empty bus costs a timeout each and there are fourteen of them here;
|
||||
# * a panel that cannot report brightness is omitted rather than shown as a
|
||||
# control that does nothing;
|
||||
# * the connector name matches what Hyprland calls the output, since the UI
|
||||
# joins on it to get the monitor's description;
|
||||
# * no I2C access produces the udev command that fixes it, not "no displays".
|
||||
#
|
||||
# Runs entirely against fixtures. Real monitors are never touched: both the
|
||||
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
|
||||
|
||||
fail() {
|
||||
printf 'brightness helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
||||
|
||||
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||
# must never be probed at all.
|
||||
make_connector() {
|
||||
local name="$1" bus="$2" status="$3"
|
||||
mkdir -p "$fixture/drm/$name"
|
||||
printf '%s\n' "$status" >"$fixture/drm/$name/status"
|
||||
mkdir -p "$fixture/i2c/i2c-$bus"
|
||||
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
|
||||
}
|
||||
make_connector card1-DP-1 4 disconnected
|
||||
make_connector card1-DP-2 5 connected
|
||||
make_connector card1-DP-3 6 connected
|
||||
make_connector card1-HDMI-A-1 7 disconnected
|
||||
|
||||
# has_accessible_bus only needs one readable/writable node to exist.
|
||||
touch "$fixture/dev/i2c-5"
|
||||
|
||||
# Stub ddcutil. Records every bus it is asked about so the test can prove the
|
||||
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
|
||||
# VCP 0x10.
|
||||
#
|
||||
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
|
||||
# 100, which is exactly the problem: with a maximum of 100 the scaling
|
||||
# arithmetic is the identity, so a helper that ignored the reported maximum
|
||||
# entirely would pass every assertion. 200 makes reads and writes that skip the
|
||||
# conversion visibly wrong.
|
||||
cat >"$fixture/bin/ddcutil" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
bus=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
|
||||
done
|
||||
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "setvcp" ]]; then
|
||||
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
case "$bus" in
|
||||
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$fixture/bin/ddcutil"
|
||||
|
||||
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
|
||||
export DDCUTIL_SET_LOG="$fixture/sets.log"
|
||||
: >"$DDCUTIL_PROBE_LOG"
|
||||
: >"$DDCUTIL_SET_LOG"
|
||||
|
||||
run_helper() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
|
||||
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# ── Enumeration ──────────────────────────────────────────────────────────────
|
||||
listing="$(run_helper list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|
||||
|| fail "expected exactly one controllable display, got: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|
||||
|| fail "the display was mapped to the wrong I2C bus: $listing"
|
||||
|
||||
# 120 of a maximum of 200 is 60%.
|
||||
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||
|| fail "brightness was not read as a percent of the reported maximum: $listing"
|
||||
|
||||
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|
||||
|| fail "a successful listing must not carry an error: $listing"
|
||||
|
||||
# A connected panel that cannot report brightness is dropped, not listed.
|
||||
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|
||||
|| fail 'a display without VCP 0x10 was listed as controllable'
|
||||
|
||||
# ── Disconnected connectors are never probed ─────────────────────────────────
|
||||
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
||||
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
|
||||
fi
|
||||
|
||||
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||
run_helper set 5 40
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|
||||
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
run_helper set 5 500
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|
||||
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||
rm -f "$fixture/dev"/i2c-*
|
||||
denied="$(run_helper list)"
|
||||
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|
||||
|| fail "displays were reported without I2C access: $denied"
|
||||
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|
||||
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
|
||||
|
||||
printf 'brightness helper contract: PASS\n'
|
||||
@@ -113,6 +113,25 @@ config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||
status() { run ipc call displays-test status; }
|
||||
|
||||
# The shipped geometry, read from the Hyprland config rather than from the
|
||||
# running compositor.
|
||||
#
|
||||
# Everything below captures "original" from what it observes at start, which is
|
||||
# correct only if the display is already in a good state. A previous run that
|
||||
# failed mid-revert leaves the display changed, and the next run then captures
|
||||
# THAT as the original and faithfully restores the desktop to a broken value.
|
||||
# One flake becomes permanent. So refuse to run from a state that does not match
|
||||
# what the config says, rather than laundering it.
|
||||
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
|
||||
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
|
||||
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
|
||||
if [[ -n "$shipped_scale" ]]; then
|
||||
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
|
||||
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
|
||||
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
|
||||
fi
|
||||
fi
|
||||
|
||||
original_mode=""
|
||||
original_scale=""
|
||||
original_transform=""
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "invalid-utf8" ]]; then
|
||||
printf '\377\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'org.freedesktop.portal.Desktop 1000 portal' \
|
||||
'org.kde.kdeconnect 1000 kdeconnect' \
|
||||
'fixture clipboard body AA:BB:CC:DD:EE:FF'
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
name="${!#}"
|
||||
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
||||
*",$name:duplicate,"*) printf '4101\n4102\n' ;;
|
||||
*",$name:malformed,"*) printf 'not-a-pid\n' ;;
|
||||
*",$name:missing,"*) exit 1 ;;
|
||||
*) printf '4101\n' ;;
|
||||
esac
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;;
|
||||
ipc)
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
|
||||
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
|
||||
else
|
||||
printf '%s\n' 'target notifications' 'target clipboard' 'target wallpaper' 'target capture'
|
||||
fi
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n ${PANAMA_DOCTOR_FIXTURE_PROBE_SECRET+x} ]]; then
|
||||
exit 97
|
||||
fi
|
||||
|
||||
service="${4:-}"
|
||||
case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in
|
||||
*",$service,"*) exit 3 ;;
|
||||
esac
|
||||
printf 'fixture-secret-token\n'
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
uid="$(/usr/bin/id -u)"
|
||||
printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then
|
||||
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
|
||||
fi
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "malformed" ]]; then
|
||||
printf 'Panama %s fixture-user invalid Caffeine\n' "$uid"
|
||||
fi
|
||||
printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid"
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
--version) printf 'Vicinae 0.26.0 fixture-secret-token\n' ;;
|
||||
ping) exit 0 ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
@@ -13,6 +13,20 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
qs ipc call focus end >/dev/null 2>&1 || true
|
||||
|
||||
# focus start records the caffeine state it found so end() can hand it back, and
|
||||
# the assertion below requires caffeine to finish off. Both are only true if
|
||||
# caffeine starts off.
|
||||
#
|
||||
# This test kills and restarts the live shell, so an interrupted run leaves
|
||||
# caffeine on with nothing to turn it back off. The next run then records
|
||||
# "previously on", faithfully restores it, and fails -- and every run after
|
||||
# that fails identically, with the desktop quietly unable to idle or lock the
|
||||
# whole time. Refuse to start from that state rather than latching it in.
|
||||
if [[ "$(qs ipc call caffeine status 2>/dev/null)" != "false" ]]; then
|
||||
fail 'caffeine is on before the session starts -- this test cannot distinguish "restored correctly" from "never released". Turn it off first: qs ipc call caffeine toggle'
|
||||
fi
|
||||
|
||||
qs ipc call focus start >/dev/null
|
||||
|
||||
state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit)
|
||||
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Health owns the accepted diagnostic snapshot. A newer unreadable response
|
||||
# must degrade diagnostics without discarding the last report that Settings
|
||||
# and future health surfaces will render.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/health-harness.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Health.qml"
|
||||
shell="$repo_dir/config/dot/quickshell/shell.qml"
|
||||
warning_snapshot='{"schemaVersion":1,"generatedAt":"2026-08-18T00:00:00Z","summary":{"status":"warning","healthy":0,"warnings":2,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"warning","detail":"Duplicate inhibitors are active.","action":{"kind":"repair","label":"Release duplicate inhibitors","confirm":false}}]}'
|
||||
projection_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
| .context.fixtureSecret = "fixture-secret"
|
||||
| .context.versions[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].fixtureSecret = "fixture-secret"
|
||||
' <<<"$warning_snapshot")"
|
||||
adversarial_snapshot="$(jq -c '
|
||||
.fixtureSecret = "fixture-secret"
|
||||
| .summary.fixtureSecret = "fixture-secret"
|
||||
| .context.fixtureSecret = "fixture-secret"
|
||||
| .context.versions[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].fixtureSecret = "fixture-secret"
|
||||
| .checks[0].action.fixtureSecret = "fixture-secret"
|
||||
' <<<"$warning_snapshot")"
|
||||
|
||||
fail() {
|
||||
printf 'health service contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'health harness is missing'
|
||||
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
||||
|
||||
# shell.qml is not started here: it is the active desktop shell. Keep this
|
||||
# contract static while pinning the typed, redacted IPC boundary it exports.
|
||||
python3 - "$shell" <<'PY' || fail 'health IPC contract is missing or exposes unsafe state'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r'IpcHandler \{\s*target: "health"(?P<body>.*?)\n \}', text, re.S)
|
||||
if not match:
|
||||
raise SystemExit(1)
|
||||
body = match.group("body")
|
||||
required = (
|
||||
'function refresh(): bool { return Health.refresh(); }',
|
||||
'function status(): string {',
|
||||
'summary: Health.summary,',
|
||||
'busy: Health.busy,',
|
||||
'generation: Health.generation,',
|
||||
'acceptedGeneration: Health.acceptedGeneration,',
|
||||
'checks: Health.checks.map(check => ({ id: check.id, status: check.status }))',
|
||||
'ShellState.openSettings("services");',
|
||||
'Health.refresh();',
|
||||
'function repair(id: string): bool { return Health.repair(id, true); }',
|
||||
)
|
||||
if any(entry not in body for entry in required):
|
||||
raise SystemExit(1)
|
||||
if 'Health.snapshot' in body or 'Health.diagnostics' in body:
|
||||
raise SystemExit(1)
|
||||
status = re.search(r'function status\(\): string \{\s*return JSON\.stringify\(\{(?P<fields>.*?)\n \}\);', body, re.S)
|
||||
if not status:
|
||||
raise SystemExit(1)
|
||||
keys = re.findall(r'^\s*([A-Za-z][A-Za-z0-9]*):', status.group("fields"), re.M)
|
||||
if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
|
||||
helper="$fixture_dir/panama-doctor"
|
||||
copy_bin="$fixture_dir/bin"
|
||||
copy_file="$fixture_dir/copied-report.json"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'if [[ "$1" == "--json" ]]; then' \
|
||||
' sleep 0.2' \
|
||||
" printf '%s\\n' '$warning_snapshot'" \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \
|
||||
' exit 0' \
|
||||
'fi' \
|
||||
'exit 2' >"$helper"
|
||||
chmod +x "$helper"
|
||||
mkdir -p "$copy_bin"
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'/usr/bin/cat > "$PANAMA_HEALTH_COPY_FILE"' >"$copy_bin/wl-copy"
|
||||
chmod +x "$copy_bin/wl-copy"
|
||||
|
||||
run() { PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" qs -p "$harness" "$@"; }
|
||||
harness_pid=""
|
||||
|
||||
cleanup() {
|
||||
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
rm -rf "$fixture_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
|
||||
qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
run ipc show 2>/dev/null | rg -q '^target health-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target health-test$' || fail 'test IPC target did not start'
|
||||
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'valid warning snapshot was rejected'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
|
||||
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \
|
||||
|| fail 'snapshot with unknown non-action fields was rejected instead of safely projected'
|
||||
stored_report="$(run ipc call health-test report)"
|
||||
! grep -Fq 'fixture-secret' <<<"$stored_report" \
|
||||
|| fail "accepted snapshot retained an unknown secret field: $stored_report"
|
||||
[[ "$(run ipc call health-test copy)" == "true" ]] \
|
||||
|| fail 'copy report was refused'
|
||||
for _ in $(seq 1 40); do
|
||||
[[ -f "$copy_file" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ -f "$copy_file" ]] || fail 'copy report did not reach wl-copy'
|
||||
! grep -Fq 'fixture-secret' "$copy_file" \
|
||||
|| fail 'copied report retained an unknown secret field'
|
||||
|
||||
adversarial_result="$(run ipc call health-test accept "$adversarial_snapshot" 1)"
|
||||
[[ "$adversarial_result" == "true" || "$adversarial_result" == "false" ]] \
|
||||
|| fail "adversarial action fixture did not return a Boolean: $adversarial_result"
|
||||
stored_report="$(run ipc call health-test report)"
|
||||
! grep -Fq 'fixture-secret' <<<"$stored_report" \
|
||||
|| fail "adversarial snapshot leaked an unknown secret field: $stored_report"
|
||||
|
||||
[[ "$(run ipc call health-test accept "$warning_snapshot" -1)" == "false" ]] \
|
||||
|| fail 'older generation replaced the current snapshot'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "older generation altered accepted state: $state"
|
||||
|
||||
[[ "$(run ipc call health-test accept '{not json' 1)" == "false" ]] \
|
||||
|| fail 'malformed snapshot was accepted'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.diagnosticUnavailable == true and .checks == ["integration.calendar", "panama.caffeine"]' \
|
||||
>/dev/null <<<"$state" || fail "malformed snapshot discarded the last valid checks: $state"
|
||||
|
||||
before_generation="$(jq -r .generation <<<"$state")"
|
||||
run ipc call health-test queue >/dev/null
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.queuedRefresh == true and .generation == ($before + 1)' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" || fail "two refreshes did not retain exactly one follow-up: $state"
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == false' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state"
|
||||
|
||||
[[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \
|
||||
|| fail 'repairable check was refused'
|
||||
for _ in $(seq 1 120); do
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .generation == ($before + 3)' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" || fail "accepted repair did not trigger one rescan: $state"
|
||||
|
||||
[[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \
|
||||
|| fail 'unknown check started a repair'
|
||||
[[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \
|
||||
|| fail 'non-repairable check started a repair'
|
||||
state="$(run ipc call health-test status)"
|
||||
jq -e '.repairingId == "" and .generation == ($before + 3)' --argjson before "$before_generation" \
|
||||
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'health service contract: PASS\n'
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The doctor is deliberately exercised through its command boundary. The
|
||||
# fixture commands include sensitive-looking output so this test proves the
|
||||
# report only retains explicitly parsed, non-sensitive observations.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
||||
fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
|
||||
|
||||
fail() {
|
||||
printf 'panama doctor contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
home="$fixture/home"
|
||||
config_home="$home/.config"
|
||||
state_home="$home/.local/state"
|
||||
runtime_dir="$fixture/runtime"
|
||||
bin_dir="$fixture/bin"
|
||||
data_home="$home/.local/share"
|
||||
|
||||
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae"
|
||||
cp "$fixture_root/bin/"* "$bin_dir/"
|
||||
chmod +x "$bin_dir"/*
|
||||
|
||||
# These are intentionally tiny stand-ins for authored executable probes. The
|
||||
# named fixture scripts above cover probes whose output needs branch coverage.
|
||||
for tool in hyprctl wl-paste grim tesseract kitty nextcloud rustdesk kdeconnect-cli; do
|
||||
cat >"$bin_dir/$tool" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
case "${0##*/}" in
|
||||
hyprctl) printf 'Hyprland 0.50.0\n' ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$bin_dir/$tool"
|
||||
done
|
||||
|
||||
cat >"$bin_dir/flatpak" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" \
|
||||
&& "${PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES:-installed}" == "installed" ]]; then
|
||||
printf 'BlueBubbles fixture-secret-token\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
EOF
|
||||
chmod +x "$bin_dir/flatpak"
|
||||
|
||||
cat >"$bin_dir/calendar-agenda" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
if [[ "${1:-}" != "probe" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
case "${PANAMA_DOCTOR_FIXTURE_CALENDAR:-ready}" in
|
||||
ready) printf '{"eds":true,"sourceRegistry":true,"enabledSources":2,"event":"fixture clipboard body"}\n' ;;
|
||||
malformed) printf 'calendar AA:BB:CC:DD:EE:FF\n' ;;
|
||||
timeout) /usr/bin/sleep 2; printf '{"enabledSources":2}\n' ;;
|
||||
*) printf '{"eds":true,"sourceRegistry":true,"enabledSources":0}\n' ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$bin_dir/calendar-agenda"
|
||||
|
||||
cat >"$bin_dir/panama-brightness" <<'EOF'
|
||||
#!/usr/bin/bash
|
||||
case "${PANAMA_DOCTOR_FIXTURE_BRIGHTNESS:-ready}" in
|
||||
ready) printf '{"displays":[{"connector":"AA:BB:CC:DD:EE:FF"}],"error":""}\n' ;;
|
||||
denied) printf '{"displays":[],"error":"fixture-secret-token"}\n' ;;
|
||||
malformed) printf 'fixture clipboard body\n' ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$bin_dir/panama-brightness"
|
||||
|
||||
mkdir -p "$config_home/autostart"
|
||||
touch "$config_home/autostart/nextcloud.desktop"
|
||||
for name in hypr quickshell uwsm vicinae; do
|
||||
ln -s "$repo_dir/config/dot/$name" "$config_home/$name"
|
||||
done
|
||||
ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts"
|
||||
|
||||
run_doctor() {
|
||||
HOME="$home" \
|
||||
PATH="$bin_dir" \
|
||||
XDG_CURRENT_DESKTOP=Hyprland \
|
||||
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
|
||||
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
|
||||
PANAMA_DOCTOR_ROOT="$repo_dir" \
|
||||
PANAMA_DOCTOR_HOME="$home" \
|
||||
PANAMA_DOCTOR_CONFIG_HOME="$config_home" \
|
||||
PANAMA_DOCTOR_STATE_HOME="$state_home" \
|
||||
PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \
|
||||
PANAMA_DOCTOR_PATH="$bin_dir" \
|
||||
PANAMA_DOCTOR_TIMEOUT="${PANAMA_DOCTOR_TIMEOUT:-0.2}" \
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
|
||||
assert_schema_and_redaction() {
|
||||
local snapshot="$1"
|
||||
jq -e '.schemaVersion == 1
|
||||
and (.generatedAt | type == "string")
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 25)
|
||||
and ([.checks[].id] | unique | length == 25)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
||||
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
||||
|| fail "checks are not in the authored order"
|
||||
! grep -Fq 'fixture-secret-token' <<<"$snapshot" \
|
||||
|| fail 'report exposed a fixture secret'
|
||||
! grep -Fq 'fixture clipboard body' <<<"$snapshot" \
|
||||
|| fail 'report exposed clipboard or calendar content'
|
||||
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot" \
|
||||
|| fail 'report exposed a device address'
|
||||
}
|
||||
|
||||
check_status() {
|
||||
local snapshot="$1" id="$2" expected="$3"
|
||||
[[ "$(jq -r --arg id "$id" '.checks[] | select(.id == $id) | .status' <<<"$snapshot")" == "$expected" ]] \
|
||||
|| fail "$id did not report $expected: $snapshot"
|
||||
}
|
||||
|
||||
snapshot="$(run_doctor --json)"
|
||||
assert_schema_and_redaction "$snapshot"
|
||||
|
||||
# A healthy systemd-backed service stays healthy.
|
||||
check_status "$snapshot" desktop.hyprpaper ok
|
||||
|
||||
# Arbitrary parent environment values are not propagated into probes.
|
||||
sealed_environment="$(PANAMA_DOCTOR_FIXTURE_PROBE_SECRET=fixture-secret-token run_doctor --json)"
|
||||
assert_schema_and_redaction "$sealed_environment"
|
||||
check_status "$sealed_environment" desktop.hyprpaper ok
|
||||
|
||||
# An OS-level launch failure is contained as a check result, never a failed
|
||||
# doctor invocation or a partial snapshot.
|
||||
chmod 0644 "$bin_dir/systemctl"
|
||||
if ! launch_failure="$(run_doctor --json)"; then
|
||||
chmod +x "$bin_dir/systemctl"
|
||||
fail 'launch failure prevented the doctor from emitting JSON'
|
||||
fi
|
||||
chmod +x "$bin_dir/systemctl"
|
||||
assert_schema_and_redaction "$launch_failure"
|
||||
check_status "$launch_failure" desktop.hyprpaper error
|
||||
|
||||
# A missing required executable is an error rather than a crash.
|
||||
mv "$bin_dir/qs" "$bin_dir/qs.off"
|
||||
missing_qs="$(run_doctor --json)"
|
||||
check_status "$missing_qs" desktop.quickshell error
|
||||
mv "$bin_dir/qs.off" "$bin_dir/qs"
|
||||
|
||||
# Optional integrations stay neutral until the user configures them.
|
||||
rm "$config_home/autostart/nextcloud.desktop"
|
||||
unconfigured_nextcloud="$(run_doctor --json)"
|
||||
check_status "$unconfigured_nextcloud" integration.nextcloud unconfigured
|
||||
touch "$config_home/autostart/nextcloud.desktop"
|
||||
|
||||
# A configured integration that stopped is actionable with an authored label,
|
||||
# never an application name or command derived from probe output.
|
||||
stopped_nextcloud="$(PANAMA_DOCTOR_FIXTURE_STOPPED=nextcloud.service run_doctor --json)"
|
||||
check_status "$stopped_nextcloud" integration.nextcloud warning
|
||||
jq -e '.checks[] | select(.id == "integration.nextcloud")
|
||||
| .action == {kind:"open", label:"Open Nextcloud", confirm:false}' \
|
||||
>/dev/null <<<"$stopped_nextcloud" || fail 'Nextcloud action was not authored'
|
||||
|
||||
# DDC errors are classified without retaining connectors or bus addresses.
|
||||
denied_brightness="$(PANAMA_DOCTOR_FIXTURE_BRIGHTNESS=denied run_doctor --json)"
|
||||
check_status "$denied_brightness" input.brightness warning
|
||||
jq -e '.checks[] | select(.id == "input.brightness")
|
||||
| .action == {kind:"instructions", label:"View setup instructions", confirm:false, target:"ddc-permissions"}' \
|
||||
>/dev/null <<<"$denied_brightness" || fail 'DDC instructions were not authored'
|
||||
|
||||
# A bounded probe timeout becomes a result, never a helper failure.
|
||||
timed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=timeout PANAMA_DOCTOR_TIMEOUT=0.05 run_doctor --json)"
|
||||
check_status "$timed_calendar" integration.calendar warning
|
||||
jq -e '.checks[] | select(.id == "integration.calendar")
|
||||
| .action == {kind:"open", label:"Open Date & Time", confirm:false, target:"datetime"}' \
|
||||
>/dev/null <<<"$timed_calendar" || fail 'calendar action was not authored'
|
||||
|
||||
# Exact Panama/Caffeine inhibitor rows detect duplicates without exposing PIDs.
|
||||
duplicated_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=duplicate run_doctor --json)"
|
||||
check_status "$duplicated_caffeine" panama.caffeine warning
|
||||
jq -e '.checks[] | select(.id == "panama.caffeine")
|
||||
| .action == {kind:"repair", label:"Release duplicate inhibitors", confirm:false}' \
|
||||
>/dev/null <<<"$duplicated_caffeine" || fail 'Caffeine repair action was not authored'
|
||||
! jq -r '.checks[] | select(.id == "panama.caffeine") | .detail' <<<"$duplicated_caffeine" | grep -Eq '[0-9]{3,}' \
|
||||
|| fail 'Caffeine detail exposed inhibitor PIDs'
|
||||
|
||||
# Process counts use only exact authored names and never expose command lines or PIDs.
|
||||
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=quickshell:duplicate run_doctor --json)"
|
||||
check_status "$duplicated_processes" panama.processes warning
|
||||
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
||||
|| fail 'process detail exposed a PID'
|
||||
|
||||
# Invalid output for a non-Quickshell authored process is not a normal zero
|
||||
# count that can be hidden by the running Quickshell process.
|
||||
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
||||
check_status "$malformed_processes" panama.processes warning
|
||||
|
||||
# Panama/Caffeine-shaped rows that do not satisfy the fixed inhibitor schema
|
||||
# are unavailable rather than reported as a healthy no-inhibitor state.
|
||||
malformed_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=malformed run_doctor --json)"
|
||||
check_status "$malformed_caffeine" panama.caffeine warning
|
||||
|
||||
# A decoding error raised inside a concurrent probe is converted to a complete
|
||||
# snapshot rather than escaping from Future.result().
|
||||
if ! invalid_probe="$(PANAMA_DOCTOR_FIXTURE_BUS=invalid-utf8 run_doctor --json)"; then
|
||||
fail 'unexpected probe exception prevented the doctor from emitting JSON'
|
||||
fi
|
||||
assert_schema_and_redaction "$invalid_probe"
|
||||
check_status "$invalid_probe" desktop.portals warning
|
||||
|
||||
# Configured Home Assistant failures route to the exact authored Settings page.
|
||||
rm "$config_home/quickshell"
|
||||
mkdir -p "$config_home/quickshell/scripts"
|
||||
home_assistant_failure="$(run_doctor --json)"
|
||||
check_status "$home_assistant_failure" integration.home-assistant warning
|
||||
jq -e '.checks[] | select(.id == "integration.home-assistant")
|
||||
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \
|
||||
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone'
|
||||
|
||||
# Invalid probe text is contained in its own check and never copied to JSON.
|
||||
malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)"
|
||||
check_status "$malformed_calendar" integration.calendar warning
|
||||
assert_schema_and_redaction "$malformed_calendar"
|
||||
|
||||
summary="$(run_doctor --summary)"
|
||||
[[ "$summary" =~ ^Panama\ system\ health:\ (healthy|warning|error)\ \([0-9]+\ ok,\ [0-9]+\ warnings,\ [0-9]+\ errors,\ [0-9]+\ unconfigured\)$ ]] \
|
||||
|| fail "summary is not concise: $summary"
|
||||
|
||||
printf 'panama doctor contract: PASS\n'
|
||||
Reference in New Issue
Block a user