Compare commits
47
Commits
2fcaada7e8
...
3b01f1e020
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b01f1e020 | ||
|
|
23141673a2 | ||
|
|
495fb9b41b | ||
|
|
3b657e0a56 | ||
|
|
c148bae4ac | ||
|
|
4cbab01ae2 | ||
|
|
de45f205ad | ||
|
|
52e2a83a78 | ||
|
|
79b3d5cb85 | ||
|
|
1b845c0126 | ||
|
|
f53ca16392 | ||
|
|
6dc606b872 | ||
|
|
32fab59d24 | ||
|
|
f6b970da21 | ||
|
|
7e1c85b094 | ||
|
|
0c364f38e6 | ||
|
|
1aa1324083 | ||
|
|
ac5e6e2130 | ||
|
|
536958430f | ||
|
|
fd99569666 | ||
|
|
a412e3d894 | ||
|
|
4cbe3b882a | ||
|
|
edc504af2e | ||
|
|
a15f019c17 | ||
|
|
116510caa8 | ||
|
|
b10f8e2593 | ||
|
|
8f0fe23377 | ||
|
|
89cf0f8c29 | ||
|
|
e290a262f3 | ||
|
|
6997dd535f | ||
|
|
1f40f8e136 | ||
|
|
e9d567aa72 | ||
|
|
914d58f52b | ||
|
|
a23b42841a | ||
|
|
e0c0e53ae0 | ||
|
|
180308135a | ||
|
|
b743f44c5b | ||
|
|
2528edfddc | ||
|
|
dbd1472e6b | ||
|
|
eeb49c5aff | ||
|
|
8e93f08977 | ||
|
|
99433c0e8e | ||
|
|
a68e4f6dcd | ||
|
|
2b3b762793 | ||
|
|
91306ce810 | ||
|
|
1b94af1163 | ||
|
|
b4ce148caf |
@@ -37,13 +37,13 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
| System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
|
||||
## System health and recovery
|
||||
|
||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||
appears only for actionable warnings or errors and opens the same **System
|
||||
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||
Health** page available from Settings and the Vicinae command **Check
|
||||
System Health**. The terminal summary is available with:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -32,7 +32,12 @@ hl.on("hyprland.start", function()
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
|
||||
-- into its binary and cannot be themed. Only one agent may register per
|
||||
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
|
||||
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
|
||||
-- prompt if Panama's ever fails to come up.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
|
||||
@@ -180,23 +180,30 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
|
||||
|
||||
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||
--
|
||||
-- Forge resized one named edge at a time. Hyprland resizes the active window
|
||||
-- along an axis and lets the layout decide which edge actually moves, so the
|
||||
-- eight Forge keys collapse onto four behaviors. The pairing is kept
|
||||
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
||||
-- and "increase" always grows while "decrease" always shrinks.
|
||||
-- Forge resized one named EDGE at a time: its resize() grows the window for a
|
||||
-- positive amount in every direction, and the edge only decides which side
|
||||
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
|
||||
-- an axis and lets the layout choose the border, so those eight distinct
|
||||
-- behaviours collapse onto four and the direction is simply not expressible.
|
||||
--
|
||||
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
|
||||
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
|
||||
-- correct on paper and wrong under the fingers: with the edge gone, the keys
|
||||
-- that used to pull a window open from one side now push it from the other.
|
||||
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
|
||||
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
|
||||
local step = 60
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
|
||||
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||
-- uses rather than landing on an arbitrary free key.
|
||||
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
|
||||
@@ -77,6 +77,18 @@ hl.window_rule({
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Quick Look. The GNOME previewer is what the file manager opens on space,
|
||||
-- and it is an overlay rather than a window someone manages: tiled, it shoves
|
||||
-- the file manager aside and has to be dismissed before the list is usable
|
||||
-- again. Sized generously because a preview that needs zooming is not a
|
||||
-- preview; it still gets a margin so the file underneath stays visible.
|
||||
hl.window_rule({
|
||||
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
|
||||
float = true,
|
||||
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Portal dialogs (file chooser, screen share picker) should always float.
|
||||
hl.window_rule({
|
||||
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
|
||||
|
||||
@@ -91,6 +91,31 @@ Singleton {
|
||||
label: "Automatically hide the Dock",
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
},
|
||||
{
|
||||
key: "dockPosition", type: "enum", def: "bottom", group: "dock",
|
||||
label: "Position",
|
||||
detail: "Which edge the Dock lives on",
|
||||
options: [
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" }
|
||||
]
|
||||
},
|
||||
{
|
||||
// A "json" value: the screen names the Dock appears on. Empty means
|
||||
// every screen, which is both the sensible default and the right
|
||||
// answer for the common single-monitor case -- storing a list of
|
||||
// names there would go stale the moment a display is unplugged.
|
||||
key: "dockScreens", type: "json", def: [], group: "dock",
|
||||
label: "Screens",
|
||||
detail: "Which displays show the Dock"
|
||||
},
|
||||
{
|
||||
key: "dockIconSize", type: "int", def: 48, min: 32, max: 80, step: 4,
|
||||
unit: "px", group: "dock",
|
||||
label: "Icon size",
|
||||
detail: "How large the Dock's application icons are drawn"
|
||||
},
|
||||
{
|
||||
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||
unit: "ms",
|
||||
@@ -107,6 +132,41 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Focus ───────────────────────────────────────────────────────────
|
||||
// ── Focus modes ─────────────────────────────────────────────────────
|
||||
// A "json" value: named modes, each with what turns it on and what it
|
||||
// does. Triggers rather than a scheduler -- a mode is on because a
|
||||
// condition is true right now, which is re-evaluated rather than fired
|
||||
// once. A schedule is one of those conditions ("is now inside this
|
||||
// window?"), which is why suspend, a reboot mid-window, and a lid
|
||||
// opened after the start time all behave correctly without special
|
||||
// cases: there is no alarm to have missed.
|
||||
//
|
||||
// Gaming ships enabled because the behaviour already existed as
|
||||
// gamingSilenceNotifications; Sleep ships disabled, because a desktop
|
||||
// that starts silencing someone on first boot has overstepped.
|
||||
{
|
||||
key: "focusModes", type: "json", group: "focus",
|
||||
label: "Focus modes",
|
||||
detail: "What quiets this machine, and what turns it on",
|
||||
def: [
|
||||
{
|
||||
id: "deep-work", name: "Deep work", enabled: true,
|
||||
triggers: [{ kind: "manual" }],
|
||||
durationMinutes: 45, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "gaming", name: "Gaming", enabled: true,
|
||||
triggers: [{ kind: "game" }],
|
||||
durationMinutes: 0, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "sleep", name: "Sleep", enabled: false,
|
||||
triggers: [{ kind: "schedule", start: "23:30", end: "07:00",
|
||||
days: [0, 1, 2, 3, 4, 5, 6] }],
|
||||
durationMinutes: 0, silence: true, keepAwake: false, allow: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
unit: "min",
|
||||
@@ -702,6 +762,14 @@ Singleton {
|
||||
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "highContrast", type: "bool", def: false, group: "accessibility",
|
||||
label: "High contrast",
|
||||
detail: "Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here.",
|
||||
// No hypr mapping: this is a GNOME interface setting the portal
|
||||
// republishes as org.freedesktop.appearance contrast, which is what
|
||||
// libadwaita actually reads. DesktopStyle applies it.
|
||||
},
|
||||
{
|
||||
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||
label: "Dim inactive windows",
|
||||
@@ -716,6 +784,22 @@ Singleton {
|
||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||
},
|
||||
|
||||
// ── Gaming ──────────────────────────────────────────────────────────
|
||||
// What Panama does while a game runs. gamemode tells us when that
|
||||
// starts and stops through its own hook scripts, so these are real
|
||||
// behaviours rather than hints -- and each one is undone afterwards to
|
||||
// whatever it was before, not to a default.
|
||||
{
|
||||
key: "gamingPerformanceProfile", type: "bool", def: true, group: "gaming",
|
||||
label: "Use the performance power profile",
|
||||
detail: "Switches while a game runs and switches back when it exits"
|
||||
},
|
||||
{
|
||||
key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming",
|
||||
label: "Say when Game Mode engages",
|
||||
detail: "A notification when a game requests it, which is otherwise invisible"
|
||||
},
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||
|
||||
@@ -63,6 +63,11 @@ Singleton {
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favorites.
|
||||
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
|
||||
readonly property var dockScreens: {
|
||||
const stored = DesktopPreferences.get("dockScreens");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
|
||||
@@ -59,15 +59,22 @@ Singleton {
|
||||
//
|
||||
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||
// the default.
|
||||
//
|
||||
// `gnome` is the nearest member of GNOME's own accent-color enum, which is
|
||||
// a fixed list of nine we do not get to extend. It is what libadwaita
|
||||
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
|
||||
// accent here recolors them too instead of leaving them in GNOME blue.
|
||||
// Nearest by hue, not by name: "rose" maps to red rather than pink because
|
||||
// it is the red role in this palette.
|
||||
readonly property var accents: ({
|
||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
|
||||
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" },
|
||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
|
||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
|
||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
|
||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
|
||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
|
||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" }
|
||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" },
|
||||
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" },
|
||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" },
|
||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" },
|
||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" },
|
||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" },
|
||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" },
|
||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" }
|
||||
})
|
||||
|
||||
// Falls back to blue for an unknown name, so a settings file written by a
|
||||
@@ -103,6 +110,13 @@ Singleton {
|
||||
// light one, and text on it stops being legible.
|
||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
||||
|
||||
// Toasts sit a little lighter than a popover you opened on purpose. A
|
||||
// notification arrives unbidden over whatever you were doing, and at full
|
||||
// popover weight it reads as a dialog demanding an answer -- but the 6%
|
||||
// foreground tint it used to have left the text competing with the desktop
|
||||
// behind it. This is the point between the two.
|
||||
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
|
||||
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
|
||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||
@@ -112,7 +126,7 @@ Singleton {
|
||||
readonly property int barGap: 6 // breathing room below the bar for popovers
|
||||
readonly property int barSideMargin: 10 // inset for floating popovers
|
||||
|
||||
readonly property int dockIconSize: 48
|
||||
readonly property int dockIconSize: DesktopPreferences.get("dockIconSize")
|
||||
readonly property int dockPadding: 8
|
||||
readonly property int dockGap: 8
|
||||
readonly property int dockRadius: 20
|
||||
@@ -121,7 +135,6 @@ Singleton {
|
||||
readonly property int popoverPadding: 14
|
||||
readonly property int popoverWidth: 380
|
||||
readonly property int controlCenterWidth: 430
|
||||
readonly property int controlCenterTopGap: 2
|
||||
|
||||
readonly property int cardRadius: 12
|
||||
readonly property int pillRadius: 999
|
||||
|
||||
@@ -15,7 +15,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
implicitWidth: 350
|
||||
|
||||
@@ -20,8 +20,11 @@ PanelWindow {
|
||||
top: true
|
||||
right: true
|
||||
}
|
||||
// The gap ALONE, not the bar height plus the gap -- see the note in
|
||||
// QuickSettings.qml: exclusiveZone 0 already places this below the bar's
|
||||
// reserved zone, so adding the bar height counted it twice.
|
||||
margins {
|
||||
top: Theme.barHeight + Theme.barGap * 2
|
||||
top: Theme.barGap * 2
|
||||
right: Theme.barSideMargin
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 760
|
||||
|
||||
@@ -22,10 +22,34 @@ PanelWindow {
|
||||
property var modelData: null
|
||||
screen: root.modelData
|
||||
|
||||
// Which edge this dock lives on, and everything that follows from it. The
|
||||
// bottom case is unchanged in every particular: same anchors, same
|
||||
// geometry, same slide -- so a machine that never touches the setting sees
|
||||
// exactly the dock it had.
|
||||
readonly property string position: Settings.dockPosition
|
||||
readonly property bool vertical: root.position === "left" || root.position === "right"
|
||||
|
||||
// Only on the screens asked for. An empty list means all of them, which is
|
||||
// what a single-monitor machine wants and what an unplugged display should
|
||||
// not be able to change.
|
||||
readonly property bool onThisScreen: {
|
||||
const wanted = Settings.dockScreens;
|
||||
if (!wanted || wanted.length === 0)
|
||||
return true;
|
||||
return wanted.indexOf(String(root.screen?.name ?? "")) >= 0;
|
||||
}
|
||||
|
||||
visible: root.onThisScreen
|
||||
|
||||
// A dock spans the edge it lives on, which means anchoring BOTH ends of
|
||||
// that edge: bottom+left+right across the screen, or top+bottom plus one
|
||||
// side down it. Anchoring only one end leaves the surface free to collapse
|
||||
// to its implicit size on that axis -- a side dock came out one pixel tall.
|
||||
anchors {
|
||||
top: root.vertical
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
left: root.position !== "right"
|
||||
right: root.position !== "left"
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
@@ -42,10 +66,13 @@ PanelWindow {
|
||||
// ── Geometry ────────────────────────────────────────────────────────────
|
||||
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
||||
readonly property int revealStripHeight: 3
|
||||
readonly property int bottomMargin: Theme.barGap
|
||||
readonly property int edgeMargin: Theme.barGap
|
||||
readonly property int tooltipSpace: 34
|
||||
|
||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||
// Only the axis the dock is thin on gets an implicit size; the other is
|
||||
// spanned by the anchors above. Setting both would fight them.
|
||||
implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin
|
||||
implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||
@@ -140,22 +167,71 @@ PanelWindow {
|
||||
id: pointer
|
||||
}
|
||||
|
||||
// Revealed: the dock plus everything between it and the edge, so
|
||||
// crossing the gap does not count as leaving. Hidden: a sliver along
|
||||
// the edge the dock lives on, which is the only thing that can bring
|
||||
// it back -- every other click passes through to the window beneath.
|
||||
Item {
|
||||
id: maskItem
|
||||
x: root.revealed ? body.x : 0
|
||||
y: root.revealed ? body.y : surface.height - root.revealStripHeight
|
||||
width: root.revealed ? body.width : surface.width
|
||||
height: root.revealed ? surface.height - body.y : root.revealStripHeight
|
||||
|
||||
x: {
|
||||
if (!root.revealed)
|
||||
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
||||
return root.position === "right" ? body.x : 0;
|
||||
}
|
||||
y: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
||||
return root.vertical ? body.y : body.y;
|
||||
}
|
||||
width: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? root.revealStripHeight : surface.width;
|
||||
return root.vertical
|
||||
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
|
||||
: body.width;
|
||||
}
|
||||
height: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? surface.height : root.revealStripHeight;
|
||||
return root.vertical ? body.height : surface.height - body.y;
|
||||
}
|
||||
}
|
||||
|
||||
DockBody {
|
||||
id: body
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Slides off the bottom edge when hidden.
|
||||
y: root.revealed ? root.tooltipSpace : surface.height
|
||||
vertical: root.vertical
|
||||
leftSide: root.position === "left"
|
||||
|
||||
// Both axes are computed rather than anchored. Anchoring the centre
|
||||
// on one axis and binding a position on the other looks tidier and
|
||||
// is a conflict: an anchored centre owns that coordinate, so the
|
||||
// binding beside it is fighting for the same value.
|
||||
//
|
||||
// Centred on the long axis; on the short one it sits a tooltip's
|
||||
// width in from the edge when revealed, and off-screen when not.
|
||||
x: {
|
||||
if (!root.vertical)
|
||||
return (surface.width - width) / 2;
|
||||
if (root.position === "left")
|
||||
return root.revealed ? root.tooltipSpace : -width;
|
||||
return root.revealed ? surface.width - width - root.tooltipSpace : surface.width;
|
||||
}
|
||||
y: {
|
||||
if (root.vertical)
|
||||
return (surface.height - height) / 2;
|
||||
return root.revealed ? root.tooltipSpace : surface.height;
|
||||
}
|
||||
opacity: root.revealed ? 1 : 0
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
|
||||
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Asymmetric on purpose. Revealing is a response to something the
|
||||
// user just did, so it has to feel immediate — any delay there
|
||||
// reads as lag. Hiding is not a response to anything, so it can
|
||||
|
||||
@@ -111,30 +111,51 @@ Rectangle {
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
|
||||
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
|
||||
|
||||
// The item the tooltip is currently describing, or null.
|
||||
property Item hoveredItem: null
|
||||
|
||||
Row {
|
||||
id: row
|
||||
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||
// of across it.
|
||||
property bool vertical: false
|
||||
|
||||
// Which way a tooltip points on a side dock: away from the screen edge, so
|
||||
// it never opens off-screen.
|
||||
property bool leftSide: true
|
||||
|
||||
// Explicit rather than left to Grid's wrapping. This is always one line, so
|
||||
// saying how many cells it holds is both simpler to read and immune to
|
||||
// Grid's default column count quietly wrapping a long dock.
|
||||
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
|
||||
|
||||
// A Grid rather than a Row so one declaration serves both orientations.
|
||||
// Row and Column would each need their own children, and the cross-axis
|
||||
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
|
||||
// a Column -- Grid centres through its own alignment properties instead,
|
||||
// which is the same result without the anchors positioners disallow.
|
||||
Grid {
|
||||
id: strip
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.dockGap
|
||||
rows: root.vertical ? root.cellCount : 1
|
||||
columns: root.vertical ? 1 : root.cellCount
|
||||
horizontalItemAlignment: Grid.AlignHCenter
|
||||
verticalItemAlignment: Grid.AlignVCenter
|
||||
|
||||
ShowAppsButton {
|
||||
id: showApps
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = showApps
|
||||
onExited: if (root.hoveredItem === showApps)
|
||||
root.hoveredItem = null
|
||||
}
|
||||
|
||||
// Separator between the launcher and the apps, as in GNOME's dash.
|
||||
// Separator between the launcher and the apps, as in GNOME's dash. It
|
||||
// turns with the dock: a hairline across a column, down a row.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 1
|
||||
height: Theme.dockIconSize * 0.7
|
||||
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
|
||||
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.14)
|
||||
}
|
||||
@@ -146,7 +167,6 @@ Rectangle {
|
||||
id: dockItem
|
||||
required property var modelData
|
||||
app: modelData
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = dockItem
|
||||
onExited: if (root.hoveredItem === dockItem)
|
||||
root.hoveredItem = null
|
||||
@@ -172,16 +192,26 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Centered on the hovered item, clamped inside the dock. Reads the item's
|
||||
// x directly (rather than mapToItem) so the binding re-evaluates when
|
||||
// the row reflows.
|
||||
// Centred on the hovered item along the dock's own axis, and placed just
|
||||
// outside the edge it lives on. Reads the item's position directly
|
||||
// (rather than mapToItem) so the binding re-evaluates when the strip
|
||||
// reflows.
|
||||
x: {
|
||||
if (!root.hoveredItem)
|
||||
return 0;
|
||||
const center = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, center - width / 2));
|
||||
if (root.vertical)
|
||||
return root.leftSide ? root.width + 8 : -width - 8;
|
||||
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
|
||||
}
|
||||
y: {
|
||||
if (!root.hoveredItem)
|
||||
return -height - 8;
|
||||
if (!root.vertical)
|
||||
return -height - 8;
|
||||
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
|
||||
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
|
||||
}
|
||||
y: -height - 8
|
||||
|
||||
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
||||
height: tipLabel.implicitHeight + 8
|
||||
|
||||
@@ -35,7 +35,17 @@ Rectangle {
|
||||
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0
|
||||
color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06)
|
||||
// A real popover surface, the same one the date menu and clipboard panel
|
||||
// use, rather than a 6% foreground tint.
|
||||
//
|
||||
// A tint that faint has nothing behind it: the toast window is transparent,
|
||||
// so the card was sitting directly on whatever happened to be on screen and
|
||||
// the text competed with it. Notifications are the one surface someone reads
|
||||
// without having chosen to look at it, so it has to be legible over a
|
||||
// bright photo and a white document alike.
|
||||
color: hover.containsMouse
|
||||
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
|
||||
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
|
||||
@@ -17,7 +17,11 @@ PanelWindow {
|
||||
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// The authentication prompt.
|
||||
//
|
||||
// Deliberately a Panama surface rather than the compositor's stock agent: this
|
||||
// is the window that asks for the password to everything, and it was the one
|
||||
// window on the desktop that looked like it belonged to something else.
|
||||
//
|
||||
// Two things here are security, not styling. It takes EXCLUSIVE keyboard focus,
|
||||
// so keystrokes cannot reach the window underneath while a password is being
|
||||
// typed. And the field is cleared on every exit path, including the ones nobody
|
||||
// plans for.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.settings
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: Polkit.active
|
||||
color: "transparent"
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-polkit"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// Exclusive, not OnDemand: a password prompt that lets keystrokes through
|
||||
// to whatever is behind it is a keylogger with extra steps.
|
||||
WlrLayershell.keyboardFocus: Polkit.active
|
||||
? WlrKeyboardFocus.Exclusive
|
||||
: WlrKeyboardFocus.None
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible) {
|
||||
field.text = "";
|
||||
field.forceActiveFocus();
|
||||
} else {
|
||||
field.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Dims what is behind, and swallows clicks so nothing outside the dialog
|
||||
// can be operated while it is waiting.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
// Clicking outside does nothing on purpose. Dismissing an
|
||||
// authentication request by misclick, and having the thing that
|
||||
// asked report a mysterious failure, is worse than an explicit
|
||||
// Cancel.
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: dialog
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: 420
|
||||
implicitHeight: layout.implicitHeight + 44
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 44
|
||||
spacing: 14
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Authentication required"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Polkit.message
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: Polkit.users.length > 1
|
||||
text: "Authenticating as " + Polkit.chosenUser
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
PasswordField {
|
||||
id: field
|
||||
width: parent.width
|
||||
placeholder: "Password"
|
||||
enabled: !Polkit.authenticating
|
||||
onAccepted: {
|
||||
if (field.text !== "")
|
||||
Polkit.submit(field.text);
|
||||
field.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: Polkit.failureText !== ""
|
||||
text: Polkit.failureText
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Cancel"
|
||||
onClicked: Polkit.cancel()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: Polkit.authenticating ? "Checking…" : "Authenticate"
|
||||
tone: "accent"
|
||||
enabled: !Polkit.authenticating
|
||||
onClicked: {
|
||||
if (field.text !== "")
|
||||
Polkit.submit(field.text);
|
||||
field.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escape cancels, which is what every other dialog on this desktop does.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: Polkit.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
module qs.modules.polkit
|
||||
PolkitPrompt 1.0 PolkitPrompt.qml
|
||||
@@ -36,9 +36,30 @@ Item {
|
||||
|
||||
Rectangle {
|
||||
id: restingCard
|
||||
|
||||
// Shown when the section is closed; the expanded grid below replaces
|
||||
// it when open.
|
||||
readonly property bool shown: root.hasSelection && !root.expanded
|
||||
|
||||
width: parent.width
|
||||
height: visible ? restingGrid.implicitHeight + 20 : 0
|
||||
visible: root.hasSelection && !root.expanded
|
||||
|
||||
// Animated, and on the same curve and duration as the Section that
|
||||
// replaces it. Previously this hid the instant `expanded` flipped:
|
||||
// a Column skips invisible children, so everything above snapped up
|
||||
// while the expanded grid was still sliding open underneath. That
|
||||
// read as a glitch rather than as an animation, and only here,
|
||||
// because this is the only control with a resting and an expanded
|
||||
// form that swap.
|
||||
height: restingCard.shown ? restingGrid.implicitHeight + 20 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.warn, 0.028)
|
||||
border.width: 1
|
||||
|
||||
@@ -20,9 +20,21 @@ PanelWindow {
|
||||
|
||||
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
||||
// preserve the Prism edge without making the surface feel detached.
|
||||
//
|
||||
// The margin is the gap ALONE, not the bar height plus the gap. An
|
||||
// exclusiveZone of 0 means "reserve nothing, but respect what others
|
||||
// reserved", so this surface already begins below the bar's 36px zone;
|
||||
// adding the bar height here counted it twice and left the panel floating
|
||||
// 38px under the bar instead of 2px. It also means the panel follows the
|
||||
// bar on its own -- if the bar ever stops reserving space, this closes up
|
||||
// against the top edge rather than hanging under nothing.
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||
// The same gap every other popover uses. This had its own constant set to
|
||||
// 2, which left the widest surface in the shell hanging ten pixels higher
|
||||
// than the date menu beside it -- an early value nothing else converged on
|
||||
// rather than a decision; it carried no reason, while barGap does.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
|
||||
@@ -107,6 +107,27 @@ Item {
|
||||
onExpanded: root.expand("wifi")
|
||||
}
|
||||
|
||||
// Only present when there is a wired device at all -- most machines
|
||||
// this runs on have one, but a laptop without a dock does not, and
|
||||
// an Ethernet tile there would be a control for absent hardware.
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
icon: Connectivity.wiredOn
|
||||
? "network-wired-symbolic"
|
||||
: "network-wired-disconnected-symbolic"
|
||||
label: "Ethernet"
|
||||
active: Connectivity.wiredOn
|
||||
enabled: Connectivity.wiredAvailable
|
||||
sublabel: {
|
||||
if (Connectivity.wiredOn)
|
||||
return Connectivity.wiredDevice.linkSpeed > 0
|
||||
? Connectivity.wiredDevice.linkSpeed + " Mb/s" : "Connected";
|
||||
return "Off";
|
||||
}
|
||||
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
||||
@@ -304,15 +325,45 @@ Item {
|
||||
width: content.width
|
||||
height: 36
|
||||
|
||||
ThemedIcon {
|
||||
// The account's own picture, the same file the lock screen and the
|
||||
// login screen read. A generic glyph sat here while a real avatar
|
||||
// was already set, which made the desktop look like it did not know
|
||||
// whose it was.
|
||||
Item {
|
||||
id: avatar
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 22
|
||||
height: 22
|
||||
|
||||
ClippingRectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
visible: UserAccounts.avatarUrl !== ""
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: UserAccounts.avatarUrl
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
// Replaced in place when the picture changes, so the
|
||||
// cache has to be told to let go of the old one.
|
||||
cache: false
|
||||
asynchronous: true
|
||||
sourceSize.width: 44
|
||||
sourceSize.height: 44
|
||||
}
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: UserAccounts.avatarUrl === ""
|
||||
size: 20
|
||||
icon: "avatar-default-symbolic"
|
||||
iconFallback: "user-info-symbolic"
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: avatar.right
|
||||
@@ -320,7 +371,9 @@ Item {
|
||||
anchors.right: actions.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Quickshell.env("USER") || "user"
|
||||
text: UserAccounts.me
|
||||
? UserAccounts.displayName(UserAccounts.me)
|
||||
: (Quickshell.env("USER") || "user")
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -27,7 +27,12 @@ SettingsPage {
|
||||
title: "Text"
|
||||
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
||||
|
||||
SliderRow { setting: "textScale"; divider: false }
|
||||
SliderRow { setting: "textScale" }
|
||||
// Reaches GTK4 applications through the desktop portal, which
|
||||
// republishes it as org.freedesktop.appearance contrast. No
|
||||
// high-contrast theme is involved, and none is installed here -- older
|
||||
// GTK3 applications will not change.
|
||||
ToggleRow { setting: "highContrast"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -19,6 +19,19 @@ SettingsPage {
|
||||
|
||||
property string expandedPicker: ""
|
||||
|
||||
// Which group of cards is on screen. Theme leads deliberately: light and
|
||||
// dark is the control reached most often, and it used to be the third
|
||||
// section down, below a wallpaper grid and the whole lock screen.
|
||||
property string tab: "theme"
|
||||
|
||||
// Arriving from another page that named a section opens on it. Taken once
|
||||
// rather than bound, so the tabs still work normally afterwards.
|
||||
Component.onCompleted: {
|
||||
const section = ShellState.takeSettingsSection();
|
||||
if (section !== "")
|
||||
root.tab = section;
|
||||
}
|
||||
|
||||
title: "Appearance"
|
||||
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||
|
||||
@@ -44,7 +57,20 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsTabs {
|
||||
tabs: [
|
||||
{ value: "theme", label: "Theme" },
|
||||
{ value: "background", label: "Background" },
|
||||
{ value: "type", label: "Typography" },
|
||||
{ value: "windows", label: "Windows" },
|
||||
{ value: "shell", label: "Shell" },
|
||||
]
|
||||
current: root.tab
|
||||
onSelected: value => root.tab = value
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "background"
|
||||
title: "Background"
|
||||
subtitle: Wallpaper.lastError !== ""
|
||||
? Wallpaper.lastError
|
||||
@@ -83,6 +109,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "background"
|
||||
title: "Lock screen"
|
||||
subtitle: LockScreen.lastError !== ""
|
||||
? LockScreen.lastError
|
||||
@@ -101,6 +128,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "theme"
|
||||
title: "Color scheme"
|
||||
subtitle: ColorScheme.lastError !== ""
|
||||
? ColorScheme.lastError
|
||||
@@ -116,6 +144,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "type"
|
||||
title: "Shell typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
@@ -157,6 +186,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "type"
|
||||
title: "Application typography"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
@@ -229,6 +259,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "type"
|
||||
title: "Icons & pointer"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
@@ -279,6 +310,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "windows"
|
||||
title: "Titlebars"
|
||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||
|
||||
@@ -288,6 +320,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "windows"
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
|
||||
@@ -302,6 +335,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "theme"
|
||||
title: "Effects"
|
||||
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
||||
|
||||
@@ -318,6 +352,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "shell"
|
||||
title: "Clock"
|
||||
|
||||
ToggleRow { setting: "use24Hour" }
|
||||
@@ -326,6 +361,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "shell"
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
|
||||
|
||||
@@ -13,15 +13,26 @@ SettingsPage {
|
||||
|
||||
property string expandedRole: ""
|
||||
property bool addingAutostart: false
|
||||
|
||||
// Which entry has been asked to be removed. Removal deletes a file, so
|
||||
// it never happens on a first press.
|
||||
property string confirmingAutostartRemoval: ""
|
||||
readonly property var applications: DesktopEntries.applications.values
|
||||
// Each role governs a whole family of types, not one representative: setting
|
||||
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
|
||||
// can never open one image in a viewer and its neighbour in an editor.
|
||||
// The detail line names the family the way someone would describe it.
|
||||
readonly property var roles: [
|
||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
|
||||
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
|
||||
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
|
||||
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
|
||||
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
||||
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
|
||||
{ key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
||||
{ key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
|
||||
{ key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] },
|
||||
{ key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] },
|
||||
{ key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] },
|
||||
{ key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] }
|
||||
]
|
||||
|
||||
function desktopId(entry: var): string {
|
||||
@@ -43,17 +54,27 @@ SettingsPage {
|
||||
}
|
||||
|
||||
function matchesRole(entry: var, role: var): bool {
|
||||
const rawCategories = Array.isArray(entry.categories)
|
||||
? entry.categories
|
||||
: [String(entry.categories ?? "")];
|
||||
// DesktopEntries hands back a QML list, not a JavaScript array, so
|
||||
// Array.isArray is false for it. The old code took that as "this is a
|
||||
// string", stringified the list into "Network,WebBrowser" and then split
|
||||
// on ";" only -- producing the single token "network,webbrowser", which
|
||||
// matches no category at all.
|
||||
//
|
||||
// Nothing failed loudly. Browsers still appeared because their generic
|
||||
// name contains "web browser", so the terms fallback carried the role
|
||||
// by itself. Archives matched NOTHING, which meant that row could only
|
||||
// ever offer the application it already had.
|
||||
//
|
||||
// Joining first and splitting on both separators handles the list form
|
||||
// and a plain string equally.
|
||||
const raw = entry.categories;
|
||||
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
|
||||
const categories = [];
|
||||
for (const rawCategory of rawCategories) {
|
||||
for (const value of String(rawCategory).split(";")) {
|
||||
for (const value of joined.split(/[;,]/)) {
|
||||
const category = value.trim().toLowerCase();
|
||||
if (category !== "")
|
||||
categories.push(category);
|
||||
}
|
||||
}
|
||||
const metadata = [entry.name, entry.genericName]
|
||||
.map(value => String(value ?? "").toLowerCase())
|
||||
.join(" ");
|
||||
@@ -95,14 +116,46 @@ SettingsPage {
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
id: roleRow
|
||||
|
||||
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
||||
|
||||
label: roleBlock.modelData.label
|
||||
detail: roleBlock.modelData.detail
|
||||
value: DefaultApps.busy ? "Loading…" : (
|
||||
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
||||
controlWidth: 210
|
||||
|
||||
// Drawn rather than left to SettingRow's plain value text, so
|
||||
// the row carries the same chevron a PickerRow does. These
|
||||
// open a chooser but looked completely inert without it.
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: DefaultApps.busy ? "Loading…" : (
|
||||
roleBlock.selectedEntry
|
||||
? root.displayName(roleBlock.selectedEntry)
|
||||
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
||||
)
|
||||
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: roleBlock.choices.length > 0
|
||||
text: roleRow.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
|
||||
onActivated: {
|
||||
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
||||
@@ -219,12 +272,50 @@ SettingsPage {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool confirming:
|
||||
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
|
||||
|
||||
label: autostartRow.modelData.name
|
||||
detail: autostartRow.modelData.id
|
||||
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
|
||||
activatable: !DefaultApps.busy
|
||||
detail: autostartRow.confirming
|
||||
? "Removing deletes this entry. Turning it off instead is reversible."
|
||||
: autostartRow.modelData.id
|
||||
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
||||
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
|
||||
controlWidth: 210
|
||||
|
||||
// A switch, not the words "Enabled"/"Disabled". The row always
|
||||
// toggled on click, but read as static text, so a control that
|
||||
// worked looked like a status nobody could change.
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: autostartRow.confirming
|
||||
text: "Remove it"
|
||||
tone: "danger"
|
||||
enabled: !DefaultApps.busy
|
||||
onClicked: {
|
||||
root.confirmingAutostartRemoval = "";
|
||||
DefaultApps.removeAutostart(String(autostartRow.modelData.id));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: autostartRow.confirming ? "Keep" : "Remove…"
|
||||
enabled: !DefaultApps.busy
|
||||
onClicked: root.confirmingAutostartRemoval =
|
||||
autostartRow.confirming ? "" : String(autostartRow.modelData.id)
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: autostartRow.modelData.enabled
|
||||
onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Choosing which part of a picture becomes the profile picture.
|
||||
//
|
||||
// Without this, whatever was picked went to accountsservice whole, and a
|
||||
// landscape photograph became a squashed thumbnail with the subject off the
|
||||
// edge. The circle is drawn where the avatar is actually round elsewhere in the
|
||||
// shell, so what is framed here is what appears there.
|
||||
//
|
||||
// The crop is reported in the picture's OWN pixels, not screen ones, so the
|
||||
// result does not depend on the size this happened to be displayed at.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Absolute path of the picture being framed.
|
||||
property string source: ""
|
||||
|
||||
// Side of the square viewport, in screen pixels.
|
||||
readonly property int viewport: 260
|
||||
|
||||
// How far in the picture is zoomed, as a multiple of the smallest scale
|
||||
// that still covers the viewport. 1 means "just covers".
|
||||
property real zoom: 1
|
||||
readonly property real maxZoom: 4
|
||||
|
||||
// Top-left of the drawn picture, relative to the viewport's top-left.
|
||||
property real offsetX: 0
|
||||
property real offsetY: 0
|
||||
|
||||
readonly property bool ready: picture.status === Image.Ready
|
||||
&& picture.sourceSize.width > 0 && picture.sourceSize.height > 0
|
||||
|
||||
// The scale at which the shorter side exactly fills the viewport. Any
|
||||
// smaller and the square would include area the picture does not cover.
|
||||
readonly property real baseScale: root.ready
|
||||
? root.viewport / Math.min(picture.sourceSize.width, picture.sourceSize.height)
|
||||
: 1
|
||||
readonly property real scale: root.baseScale * root.zoom
|
||||
|
||||
readonly property real drawnWidth: root.ready ? picture.sourceSize.width * root.scale : 0
|
||||
readonly property real drawnHeight: root.ready ? picture.sourceSize.height * root.scale : 0
|
||||
|
||||
// Emitted with a square in the picture's own pixel coordinates.
|
||||
signal cropped(int x, int y, int size)
|
||||
signal cancelled
|
||||
|
||||
implicitWidth: parent ? parent.width : 620
|
||||
implicitHeight: column.implicitHeight
|
||||
|
||||
// Keeps the viewport covered: the picture may never be dragged far enough
|
||||
// to expose an edge, so the crop is always entirely inside the image.
|
||||
function clamp(): void {
|
||||
root.offsetX = Math.min(0, Math.max(root.viewport - root.drawnWidth, root.offsetX));
|
||||
root.offsetY = Math.min(0, Math.max(root.viewport - root.drawnHeight, root.offsetY));
|
||||
}
|
||||
|
||||
// Start centred, filling the viewport.
|
||||
function reset(): void {
|
||||
root.zoom = 1;
|
||||
root.offsetX = (root.viewport - root.drawnWidth) / 2;
|
||||
root.offsetY = (root.viewport - root.drawnHeight) / 2;
|
||||
}
|
||||
|
||||
onReadyChanged: if (root.ready) root.reset()
|
||||
onZoomChanged: root.clamp()
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
spacing: 14
|
||||
|
||||
Row {
|
||||
spacing: 20
|
||||
|
||||
Rectangle {
|
||||
id: frame
|
||||
width: root.viewport
|
||||
height: root.viewport
|
||||
radius: 12
|
||||
clip: true
|
||||
color: Theme.bgDark
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Image {
|
||||
id: picture
|
||||
source: root.source === "" ? "" : "file://" + root.source
|
||||
x: root.offsetX
|
||||
y: root.offsetY
|
||||
width: root.drawnWidth
|
||||
height: root.drawnHeight
|
||||
fillMode: Image.Stretch
|
||||
asynchronous: true
|
||||
// The picture is drawn at whatever size the frame needs, so
|
||||
// decoding it at full resolution wastes memory on a photo.
|
||||
sourceSize.width: 1600
|
||||
sourceSize.height: 1600
|
||||
smooth: true
|
||||
}
|
||||
|
||||
// The round mask: a dimming fill with the circle punched out of
|
||||
// it, then the ring drawn on top. Painted once per change, not
|
||||
// continuously.
|
||||
Canvas {
|
||||
id: mask
|
||||
anchors.fill: parent
|
||||
onPaint: {
|
||||
const context = mask.getContext("2d");
|
||||
context.clearRect(0, 0, mask.width, mask.height);
|
||||
context.fillStyle = Qt.rgba(0.12, 0.13, 0.19, 0.62);
|
||||
context.fillRect(0, 0, mask.width, mask.height);
|
||||
context.globalCompositeOperation = "destination-out";
|
||||
context.beginPath();
|
||||
context.arc(mask.width / 2, mask.height / 2,
|
||||
mask.width / 2 - 8, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalCompositeOperation = "source-over";
|
||||
context.strokeStyle = Qt.rgba(0.78, 0.83, 0.96, 0.85);
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(mask.width / 2, mask.height / 2,
|
||||
mask.width / 2 - 8, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// The offset is tracked from where the press started rather than
|
||||
// accumulated per frame, which would drift.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.ready
|
||||
cursorShape: Qt.OpenHandCursor
|
||||
property real pressX: 0
|
||||
property real pressY: 0
|
||||
property real originX: 0
|
||||
property real originY: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
pressX = mouse.x; pressY = mouse.y;
|
||||
originX = root.offsetX; originY = root.offsetY;
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (!pressed)
|
||||
return;
|
||||
root.offsetX = originX + (mouse.x - pressX);
|
||||
root.offsetY = originY + (mouse.y - pressY);
|
||||
root.clamp();
|
||||
}
|
||||
onWheel: wheel => {
|
||||
const before = root.scale;
|
||||
root.zoom = Math.max(1, Math.min(root.maxZoom,
|
||||
root.zoom * (wheel.angleDelta.y > 0 ? 1.1 : 1 / 1.1)));
|
||||
// Keep the centre of the frame pointing at the same part
|
||||
// of the picture, so zooming does not walk the subject
|
||||
// out of the circle.
|
||||
const ratio = root.scale / before;
|
||||
root.offsetX = root.viewport / 2 - (root.viewport / 2 - root.offsetX) * ratio;
|
||||
root.offsetY = root.viewport / 2 - (root.viewport / 2 - root.offsetY) * ratio;
|
||||
root.clamp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: column.width - frame.width - 20
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.ready
|
||||
? "Drag to reposition, scroll to zoom."
|
||||
: (root.source === "" ? "" : "Opening the picture…")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.ready
|
||||
text: "The circle is what other people see. Written out at 512 × 512."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: picture.status === Image.Error
|
||||
text: "That file could not be opened as a picture."
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Set picture"
|
||||
tone: "accent"
|
||||
enabled: root.ready
|
||||
onClicked: {
|
||||
// Screen coordinates back into the picture's own.
|
||||
const size = root.viewport / root.scale;
|
||||
root.cropped(Math.round(-root.offsetX / root.scale),
|
||||
Math.round(-root.offsetY / root.scale),
|
||||
Math.round(size));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Reset"
|
||||
enabled: root.ready
|
||||
onClicked: root.reset()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Cancel"
|
||||
onClicked: root.cancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Choosing a profile picture.
|
||||
//
|
||||
// A thin wrapper on the same FileDialog the phone controls use, so picking a
|
||||
// picture is the ordinary file chooser rather than something this desktop
|
||||
// invented. Nothing privileged happens here -- reading a file the user selected
|
||||
// needs no authorization; only setting it on the account does.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Dialogs
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal picked(path: string)
|
||||
|
||||
function open(): void {
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: dialog
|
||||
title: "Choose a profile picture"
|
||||
fileMode: FileDialog.OpenFile
|
||||
nameFilters: ["Pictures (*.png *.jpg *.jpeg *.webp *.gif *.bmp)", "All files (*)"]
|
||||
onAccepted: {
|
||||
const value = String(dialog.selectedFile);
|
||||
if (!value.startsWith("file://"))
|
||||
return;
|
||||
root.picked(decodeURIComponent(value.slice(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,27 @@ SettingsPage {
|
||||
title: "Wired"
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
|
||||
TextRow {
|
||||
SwitchRow {
|
||||
label: "Ethernet"
|
||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
||||
// Three states worth telling apart: on, off but plugged in, and
|
||||
// nothing in the socket. "Not connected" covered all three and
|
||||
// explained none of them.
|
||||
detail: {
|
||||
const device = Connectivity.wiredDevice;
|
||||
if (!device)
|
||||
return "";
|
||||
if (device.connected)
|
||||
return device.name + (device.linkSpeed > 0
|
||||
? " · " + device.linkSpeed + " Mb/s" : "");
|
||||
// Not "no cable": that cannot be told apart from "switched off"
|
||||
// by anything reliable here, and guessing produced a switch
|
||||
// that blamed the hardware for what it had just done itself.
|
||||
return device.name + " · off";
|
||||
}
|
||||
checked: Connectivity.wiredOn
|
||||
enabled: Connectivity.wiredAvailable
|
||||
divider: false
|
||||
onToggled: value => Connectivity.setWired(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,24 +185,15 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
|
||||
subtitle: "VPNs and per-connection routing are still configured by GNOME's panel, which is installed and searchable. Printers and online accounts have their own pages here."
|
||||
|
||||
ActionRow {
|
||||
label: "Network connections"
|
||||
detail: "VPN, proxies, and per-connection settings"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Printers"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("printers")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Online accounts"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
// Rootless podman-compose stacks, led by what needs attention.
|
||||
//
|
||||
// The grouping is the compose project, because that is the unit a person thinks
|
||||
// in: not "thirteen containers" but "the Command Center database" and "the
|
||||
// capacity planner's Supabase". State decides prominence within that grouping --
|
||||
// what is running gets rows, what is stopped collapses to a line -- so neither
|
||||
// axis has to be chosen over the other.
|
||||
//
|
||||
// The findings at the top are the same crossing the Firewall page reports, seen
|
||||
// from the side that can close it: the firewall knows only that something is
|
||||
// listening, while this page knows which container, which compose file, and
|
||||
// which token is missing from it.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
objectName: "containers"
|
||||
|
||||
// "images", "volumes", or empty. Removal never happens on a first press.
|
||||
property string confirmingPrune: ""
|
||||
|
||||
// Projects whose stopped containers have been expanded, by project name.
|
||||
property var expanded: []
|
||||
|
||||
function isExpanded(name: string): bool { return root.expanded.indexOf(name) >= 0; }
|
||||
|
||||
function toggleExpanded(name: string): void {
|
||||
root.expanded = root.isExpanded(name)
|
||||
? root.expanded.filter(entry => entry !== name)
|
||||
: root.expanded.concat([name]);
|
||||
}
|
||||
|
||||
function uptimeOf(container: var): string {
|
||||
const status = String(container.status ?? "");
|
||||
// podman already phrases this well ("Up 32 hours (healthy)"); the health
|
||||
// is shown separately, so only the duration is wanted here.
|
||||
const match = /^Up ([^(]+)/.exec(status);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
function portSummary(container: var): string {
|
||||
const ports = container.ports ?? [];
|
||||
if (ports.length === 0)
|
||||
return "no published ports";
|
||||
return ports.map(port => {
|
||||
const host = String(port.hostIp ?? "");
|
||||
const where = host === "" ? "every interface" : host;
|
||||
return where + ":" + port.hostPort + " → " + port.containerPort;
|
||||
}).join(", ");
|
||||
}
|
||||
|
||||
Component.onCompleted: Containers.refresh()
|
||||
|
||||
// ── the list ────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsPage {
|
||||
anchors.fill: parent
|
||||
visible: Containers.logTarget === ""
|
||||
|
||||
title: "Containers"
|
||||
lede: "Local services you run for development — what they expose, and what they cost."
|
||||
|
||||
TextRow {
|
||||
visible: Containers.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: Containers.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.scanned && !Containers.available
|
||||
label: "podman is not available"
|
||||
detail: "Nothing here can be shown until podman is installed."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── finding: published to the network ───────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Containers.reachable.length > 0
|
||||
title: Containers.reachable.length === 1
|
||||
? "A container is published to your whole network"
|
||||
: "Containers are published to your whole network"
|
||||
subtitle: "These bind every interface, so any machine on your network can connect. "
|
||||
+ "For a development database, loopback is almost always what you want. "
|
||||
+ "Binding adds an address to the compose file and changes nothing else."
|
||||
|
||||
Repeater {
|
||||
model: Containers.reachable
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.container ?? "")
|
||||
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
|
||||
+ " · " + String(modelData.image ?? "")
|
||||
+ (modelData.configFile
|
||||
? "\n" + Containers.shorten(String(modelData.configFile))
|
||||
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
|
||||
controlWidth: 250
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Open compose file"
|
||||
visible: String(modelData.configFile ?? "") !== ""
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.openFile(String(modelData.configFile))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Bind to localhost"
|
||||
tone: "accent"
|
||||
// Needs both halves of the label to find the entry in
|
||||
// the compose file; the Supabase CLI records neither.
|
||||
visible: String(modelData.configFile ?? "") !== ""
|
||||
&& String(modelData.service ?? "") !== ""
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.bindLocal(
|
||||
String(modelData.project), String(modelData.service))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.reachable.length > 0
|
||||
label: "After binding"
|
||||
detail: "The change takes effect the next time the stack comes up, because a "
|
||||
+ "published port is fixed when the container is created."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── finding: disk nothing references ────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
|
||||
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
|
||||
subtitle: "Images and volumes no container references. Removing them frees the space; "
|
||||
+ "anything still needed downloads again on next use."
|
||||
|
||||
Repeater {
|
||||
model: Containers.unusedImages.slice(0, 5)
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: ""
|
||||
value: Containers.formatBytes(Number(modelData.size ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.unusedImages.length > 5
|
||||
label: "and " + (Containers.unusedImages.length - 5) + " more"
|
||||
detail: ""
|
||||
value: ""
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: root.confirmingPrune === "images"
|
||||
? "Remove " + Containers.unusedImages.length + " images?"
|
||||
: "Unused images"
|
||||
detail: root.confirmingPrune === "images"
|
||||
? "Each is removed by name. Nothing that a container references is touched."
|
||||
: Containers.unusedImages.length + " images, "
|
||||
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
|
||||
visible: Containers.unusedImages.length > 0
|
||||
controlWidth: 230
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
|
||||
enabled: !Containers.busy
|
||||
onClicked: root.confirmingPrune =
|
||||
root.confirmingPrune === "images" ? "" : "images"
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingPrune === "images"
|
||||
text: "Remove them"
|
||||
tone: "danger"
|
||||
enabled: !Containers.busy
|
||||
onClicked: {
|
||||
root.confirmingPrune = "";
|
||||
Containers.pruneImages();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Containers.unusedVolumes.length > 0
|
||||
label: root.confirmingPrune === "volumes"
|
||||
? "Remove " + Containers.unusedVolumes.length + " volumes?"
|
||||
: "Unused volumes"
|
||||
detail: root.confirmingPrune === "volumes"
|
||||
? "A volume holds data. These are the ones podman reports as referenced by "
|
||||
+ "nothing, but removing them cannot be undone."
|
||||
: Containers.unusedVolumes.length + " volumes, "
|
||||
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
|
||||
controlWidth: 230
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
|
||||
enabled: !Containers.busy
|
||||
onClicked: root.confirmingPrune =
|
||||
root.confirmingPrune === "volumes" ? "" : "volumes"
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingPrune === "volumes"
|
||||
text: "Remove them"
|
||||
tone: "danger"
|
||||
enabled: !Containers.busy
|
||||
onClicked: {
|
||||
root.confirmingPrune = "";
|
||||
Containers.pruneVolumes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the stacks ──────────────────────────────────────────────────────
|
||||
|
||||
Repeater {
|
||||
model: Containers.projects
|
||||
|
||||
delegate: SettingsCard {
|
||||
id: projectCard
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var runningSet:
|
||||
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
|
||||
readonly property var stoppedSet:
|
||||
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
|
||||
readonly property string projectName: String(projectCard.modelData.name ?? "")
|
||||
|
||||
title: String(projectCard.modelData.title ?? "")
|
||||
subtitle: {
|
||||
const total = Number(projectCard.modelData.total ?? 0);
|
||||
const up = Number(projectCard.modelData.running ?? 0);
|
||||
const where = String(projectCard.modelData.configFile ?? "");
|
||||
const counts = up === 0
|
||||
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
|
||||
: up + " of " + total + " running";
|
||||
return where === "" ? counts : counts + " · " + Containers.shorten(where);
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "The whole stack"
|
||||
detail: projectCard.runningSet.length === 0
|
||||
? "Starts every container this project defines."
|
||||
: "Stopping leaves the containers in place; nothing is removed."
|
||||
controlWidth: 250
|
||||
divider: projectCard.runningSet.length > 0
|
||||
|| projectCard.stoppedSet.length > 0
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Start all"
|
||||
visible: projectCard.stoppedSet.length > 0
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.startProject(projectCard.projectName)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Restart all"
|
||||
visible: projectCard.runningSet.length > 0
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.restartProject(projectCard.projectName)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Stop all"
|
||||
visible: projectCard.runningSet.length > 0
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.stopProject(projectCard.projectName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: projectCard.runningSet
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
|
||||
value: {
|
||||
const health = String(modelData.health ?? "");
|
||||
const up = root.uptimeOf(modelData);
|
||||
if (health !== "" && up !== "")
|
||||
return health + " · " + up;
|
||||
return health !== "" ? health : up;
|
||||
}
|
||||
controlWidth: 250
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Logs"
|
||||
onClicked: Containers.openLogs(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Restart"
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.restart(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Stop"
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.stop(String(modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: projectCard.stoppedSet.length > 0
|
||||
activatable: true
|
||||
divider: root.isExpanded(projectCard.projectName)
|
||||
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
|
||||
+ projectCard.stoppedSet.length
|
||||
+ (projectCard.stoppedSet.length === 1
|
||||
? " stopped container" : " stopped containers")
|
||||
detail: {
|
||||
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
|
||||
return bad.length === 0
|
||||
? ""
|
||||
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
|
||||
}
|
||||
onActivated: root.toggleExpanded(projectCard.projectName)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
||||
controlWidth: 180
|
||||
divider: index < projectCard.stoppedSet.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Logs"
|
||||
onClicked: Containers.openLogs(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Start"
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.start(String(modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── containers no compose project claims ────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Containers.loose.length > 0
|
||||
title: "Not part of a project"
|
||||
subtitle: "Started directly rather than by a compose file."
|
||||
|
||||
Repeater {
|
||||
model: Containers.loose
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
||||
controlWidth: 180
|
||||
divider: index < Containers.loose.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Logs"
|
||||
onClicked: Containers.openLogs(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: modelData.state === "running" ? "Stop" : "Start"
|
||||
enabled: !Containers.busy
|
||||
onClicked: modelData.state === "running"
|
||||
? Containers.stop(String(modelData.name))
|
||||
: Containers.start(String(modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.scanned && Containers.available && Containers.total === 0
|
||||
label: "No containers"
|
||||
detail: "Nothing has been created on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── logs ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A drill-in rather than a panel inside the scrolling page: logs need their
|
||||
// own scrollback, and nesting one scrolling view inside another makes the
|
||||
// wheel ambiguous over the region where you most want to use it.
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: Containers.logTarget !== ""
|
||||
|
||||
Column {
|
||||
id: logHeader
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 34
|
||||
anchors.rightMargin: 34
|
||||
anchors.topMargin: 30
|
||||
spacing: 6
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
SettingsButton {
|
||||
text: "‹ Back"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: Containers.closeLogs()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Containers.logTarget
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 22
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Containers.logError !== ""
|
||||
? Containers.logError
|
||||
: (Containers.logFollowing
|
||||
? "Following. The last " + Containers.logLines.count + " lines are shown."
|
||||
: "The stream has ended.")
|
||||
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: logHeader.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 34
|
||||
anchors.rightMargin: 34
|
||||
anchors.topMargin: 14
|
||||
anchors.bottomMargin: 30
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.bgDark, 0.75)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
ListView {
|
||||
id: logList
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
clip: true
|
||||
model: Containers.logLines
|
||||
spacing: 1
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
cacheBuffer: 400
|
||||
|
||||
// Stay pinned to the newest line while the reader is already at
|
||||
// the bottom, and leave the view alone the moment they scroll up
|
||||
// to read something.
|
||||
property bool pinned: true
|
||||
onContentYChanged: logList.pinned =
|
||||
logList.contentY >= logList.contentHeight - logList.height - 24
|
||||
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
|
||||
|
||||
delegate: Text {
|
||||
required property string line
|
||||
width: logList.width - 24
|
||||
text: line
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WrapAnywhere
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,16 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Timezone"
|
||||
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
|
||||
+ ". Type to narrow the list."
|
||||
|
||||
PickerRow {
|
||||
id: zonePicker
|
||||
|
||||
label: "Time zone"
|
||||
detail: DateTime.timezone === ""
|
||||
? "Reading the system clock"
|
||||
: DateTime.cityOf(DateTime.timezone) + " — " + DateTime.regionOf(DateTime.timezone)
|
||||
value: DateTime.timezone === "" ? "Unknown" : DateTime.cityOf(DateTime.timezone)
|
||||
divider: false
|
||||
|
||||
SearchField {
|
||||
id: zoneSearch
|
||||
@@ -72,7 +80,10 @@ SettingsPage {
|
||||
controlWidth: 90
|
||||
divider: index < root.matchingZones.length - 1
|
||||
activatable: true
|
||||
onActivated: DateTime.setTimezone(modelData)
|
||||
onActivated: {
|
||||
DateTime.setTimezone(modelData);
|
||||
zonePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +94,7 @@ SettingsPage {
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: DateTime.lastError !== ""
|
||||
|
||||
@@ -8,21 +8,100 @@
|
||||
// them.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Turning the last screen off would leave no dock anywhere and no obvious
|
||||
// way back, so the final one cannot be removed -- it collapses to "every
|
||||
// screen" instead, which is the same thing on one display and recoverable
|
||||
// on several.
|
||||
function toggleDockScreen(name: string): void {
|
||||
const all = Quickshell.screens.map(screen => String(screen.name));
|
||||
const current = Settings.dockScreens.length === 0
|
||||
? all.slice()
|
||||
: Settings.dockScreens.map(String);
|
||||
const at = current.indexOf(name);
|
||||
let next = current.slice();
|
||||
if (at >= 0)
|
||||
next.splice(at, 1);
|
||||
else
|
||||
next.push(name);
|
||||
if (next.length === 0 || next.length === all.length)
|
||||
next = [];
|
||||
DesktopPreferences.set("dockScreens", next);
|
||||
}
|
||||
|
||||
title: "Desktop & Dock"
|
||||
lede: "Keep the shell instant, spatial, and out of your way."
|
||||
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
|
||||
ChoiceRow { setting: "dockPosition" }
|
||||
|
||||
// One row per connected screen. Nothing selected means every screen,
|
||||
// which is stated rather than left as an empty list somebody has to
|
||||
// interpret -- and it is what a single-monitor machine should do
|
||||
// without being configured at all.
|
||||
SettingRow {
|
||||
label: "Screens"
|
||||
detail: Settings.dockScreens.length === 0
|
||||
? "On every display"
|
||||
: "On " + Settings.dockScreens.length + " of "
|
||||
+ Quickshell.screens.length + " displays"
|
||||
visible: Quickshell.screens.length > 1
|
||||
controlWidth: 260
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Repeater {
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Rectangle {
|
||||
id: screenPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string screenName: String(screenPill.modelData.name ?? "")
|
||||
// An empty list means all, so every pill reads as on.
|
||||
readonly property bool on: Settings.dockScreens.length === 0
|
||||
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
|
||||
|
||||
width: pillLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: 8
|
||||
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: screenPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
anchors.centerIn: parent
|
||||
text: screenPill.screenName
|
||||
color: screenPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow { setting: "dockAutohide" }
|
||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockIconSize"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -62,7 +141,7 @@ SettingsPage {
|
||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||
action: "Open Appearance"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettings("appearance")
|
||||
onTriggered: ShellState.openSettingsSection("appearance", "windows")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +201,71 @@ SettingsPage {
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
// Distinct from the snapshots below, which put THIS machine back as it
|
||||
// was. This carries settings to a different one, and deliberately leaves
|
||||
// behind anything that describes hardware.
|
||||
SettingsCard {
|
||||
title: "Carry settings to another machine"
|
||||
subtitle: SettingsSync.lastError !== ""
|
||||
? SettingsSync.lastError
|
||||
: "Everything except what describes this machine: the display arrangement stays here."
|
||||
|
||||
ActionRow {
|
||||
label: "Export"
|
||||
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
|
||||
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
|
||||
: "Writes " + SettingsSync.defaultPath
|
||||
action: "Export"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "See what an import would change"
|
||||
detail: SettingsSync.previewed
|
||||
? SettingsSync.changes.length + " would change, "
|
||||
+ SettingsSync.skipped.length + " skipped"
|
||||
: "Reads " + SettingsSync.defaultPath + " without applying anything"
|
||||
action: "Preview"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
// Only offered once a preview has said what it would do. Importing
|
||||
// settings sight unseen is how somebody ends up wondering why their
|
||||
// desktop changed.
|
||||
ActionRow {
|
||||
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
|
||||
label: "Apply those " + SettingsSync.changes.length + " changes"
|
||||
detail: "Settings the file does not mention are left alone"
|
||||
action: "Import"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: SettingsSync.previewed ? SettingsSync.skipped : []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.key ?? "")
|
||||
detail: "Skipped: " + String(modelData.reason ?? "")
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
|
||||
label: SettingsSync.applied === 0
|
||||
? "Nothing needed changing"
|
||||
: SettingsSync.applied + " settings applied"
|
||||
detail: "From " + (SettingsSync.exportedFrom || "the export")
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Snapshots"
|
||||
subtitle: SettingsBackup.lastError !== ""
|
||||
|
||||
@@ -15,6 +15,9 @@ Column {
|
||||
property var monitor: null
|
||||
property bool enabled: true
|
||||
|
||||
// Emitted once a mode has been asked for, so a container can put the list
|
||||
// away. Applying is still this component's job; closing is not.
|
||||
signal picked
|
||||
spacing: 0
|
||||
|
||||
readonly property var grouped: {
|
||||
@@ -106,11 +109,14 @@ Column {
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !rate.selected
|
||||
onTapped: Displays.apply(
|
||||
onTapped: {
|
||||
Displays.apply(
|
||||
root.monitor.name,
|
||||
rate.modelData.mode,
|
||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||
root.monitor.transform)
|
||||
root.monitor.transform);
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,15 @@ SettingsPage {
|
||||
? root.monitor.mode
|
||||
: ""
|
||||
|
||||
// Every mode at the resolution in use, which is what a refresh-rate choice
|
||||
// actually is: the same width and height at a different rate.
|
||||
readonly property var ratesForCurrentResolution: {
|
||||
if (!root.monitor || !root.monitor.modes)
|
||||
return [];
|
||||
return root.monitor.modes.filter(mode => mode.width === root.monitor.width
|
||||
&& mode.height === root.monitor.height);
|
||||
}
|
||||
|
||||
function syncSelectedOutput(): void {
|
||||
if (!Displays.monitorNamed(root.selectedOutput))
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
@@ -175,10 +184,61 @@ SettingsPage {
|
||||
title: "Resolution"
|
||||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||
|
||||
PickerRow {
|
||||
id: modePicker
|
||||
|
||||
label: "Resolution"
|
||||
detail: root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height + " native"
|
||||
: "No display selected"
|
||||
value: root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height
|
||||
: ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
|
||||
DisplayModePicker {
|
||||
width: parent.width
|
||||
monitor: root.monitor
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: modePicker.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh rate on its own, because the rates for a resolution used to be
|
||||
// reachable only by opening the resolution list -- which is now
|
||||
// collapsed, so changing only the rate meant going through the mode you
|
||||
// already had.
|
||||
PickerRow {
|
||||
id: ratePicker
|
||||
|
||||
label: "Refresh rate"
|
||||
detail: "Rates this display offers at " + (root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height
|
||||
: "the current resolution")
|
||||
value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : ""
|
||||
visible: root.ratesForCurrentResolution.length > 1
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
|
||||
Repeater {
|
||||
model: root.ratesForCurrentResolution
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.refreshLabel ?? "")
|
||||
value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : ""
|
||||
controlWidth: 90
|
||||
divider: index < root.ratesForCurrentResolution.length - 1
|
||||
activatable: !Displays.modeIsCurrent(root.monitor, modelData)
|
||||
onActivated: {
|
||||
root.applyWith({ mode: modelData.mode });
|
||||
ratePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,50 @@ Column {
|
||||
DesktopPreferences.set("dockPinned", next);
|
||||
}
|
||||
|
||||
// ── Dragging ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// By a grip rather than the whole row. The objection this file used to
|
||||
// record -- that dragging inside a Flickable inside a scrolling page is
|
||||
// hard to get right and fails in a way that reads as breakage -- is real,
|
||||
// and the answer is preventStealing on the grip: the Flickable cannot take
|
||||
// a gesture that started there, so a vertical drag reorders instead of
|
||||
// scrolling the page out from under it. The arrow buttons stay, because
|
||||
// they are the keyboard-reachable path and a grip is not.
|
||||
//
|
||||
// The order is held here while the drag runs and written once on release.
|
||||
// Committing on every slot crossed would rewrite settings.json a dozen
|
||||
// times for one gesture.
|
||||
property int draggingIndex: -1
|
||||
property var workingOrder: []
|
||||
|
||||
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
|
||||
|
||||
function beginDrag(index: int): void {
|
||||
root.workingOrder = root.pinned.slice();
|
||||
root.draggingIndex = index;
|
||||
}
|
||||
|
||||
function dragTo(target: int): void {
|
||||
if (root.draggingIndex < 0 || target === root.draggingIndex)
|
||||
return;
|
||||
if (target < 0 || target >= root.workingOrder.length)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
const moved = next.splice(root.draggingIndex, 1)[0];
|
||||
next.splice(target, 0, moved);
|
||||
root.workingOrder = next;
|
||||
root.draggingIndex = target;
|
||||
}
|
||||
|
||||
function endDrag(): void {
|
||||
if (root.draggingIndex < 0)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
root.draggingIndex = -1;
|
||||
root.workingOrder = [];
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function move(from: int, to: int): void {
|
||||
if (to < 0 || to >= root.pinned.length)
|
||||
return;
|
||||
@@ -67,7 +111,7 @@ Column {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.pinned
|
||||
model: root.displayed
|
||||
|
||||
SettingRow {
|
||||
id: pin
|
||||
@@ -76,15 +120,71 @@ Column {
|
||||
required property int index
|
||||
|
||||
label: root.nameFor(pin.modelData)
|
||||
detail: pin.modelData
|
||||
// The desktop id is shown only when the name alone would not say
|
||||
// which entry this is. It is developer text, and repeating it under
|
||||
// fifteen recognisable application names is noise that makes the
|
||||
// list harder to scan, not easier.
|
||||
detail: root.pinned.filter(other =>
|
||||
root.nameFor(other) === root.nameFor(pin.modelData)).length > 1
|
||||
? pin.modelData
|
||||
: ""
|
||||
divider: pin.index < root.pinned.length - 1
|
||||
controlWidth: 132
|
||||
|
||||
// Lifted while dragging so the row being moved is the one that
|
||||
// looks moved.
|
||||
z: root.draggingIndex === pin.index ? 2 : 0
|
||||
opacity: root.draggingIndex === pin.index ? 0.85 : 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
// The grip. preventStealing is the whole reason this works
|
||||
// inside a scrolling page: without it the Flickable claims the
|
||||
// vertical gesture and the row never moves.
|
||||
Item {
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u2261"
|
||||
color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: grip
|
||||
anchors.fill: parent
|
||||
preventStealing: true
|
||||
cursorShape: Qt.SizeVerCursor
|
||||
|
||||
property real pressY: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
grip.pressY = mouse.y;
|
||||
root.beginDrag(pin.index);
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (root.draggingIndex < 0 || pin.height <= 0)
|
||||
return;
|
||||
// How many whole rows the pointer has travelled from
|
||||
// where it started. Rounded, so the swap happens as
|
||||
// the grip passes the midpoint of the next row.
|
||||
const travelled = (mouse.y - grip.pressY);
|
||||
const slots = Math.round(travelled / pin.height);
|
||||
if (slots !== 0)
|
||||
root.dragTo(root.draggingIndex + slots);
|
||||
}
|
||||
onReleased: root.endDrag()
|
||||
onCanceled: root.endDrag()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "↑"
|
||||
enabled: pin.index > 0
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// The firewall, led by what another machine can actually reach.
|
||||
//
|
||||
// A rules list alone is not an answer: a port is reachable only when something
|
||||
// is listening on a network address AND the firewall permits it. On this
|
||||
// machine that crossing is the whole story -- the rules look unremarkable while
|
||||
// a database and a cache sit open, because Fedora Workstation's zone opens
|
||||
// every port above 1024 and rootless containers publish on all interfaces.
|
||||
//
|
||||
// Rich rules are shown but never edited. They are a syntax rather than a
|
||||
// setting, and a page that half-supports a syntax is a trap -- but hiding them
|
||||
// would mean the page misrepresents the configuration.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "firewall"
|
||||
title: "Firewall"
|
||||
lede: "What another machine on your network can reach, and what allows it."
|
||||
|
||||
property string confirmingRemoval: ""
|
||||
property bool confirmingRange: false
|
||||
|
||||
Component.onCompleted: Firewall.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Firewall.lastError !== ""
|
||||
label: "The firewall needs attention"
|
||||
detail: Firewall.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── The finding, when there is one ───────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Firewall.exposedDataStores.length > 0
|
||||
title: Firewall.exposedDataStores.length === 1
|
||||
? "A database is reachable from your network"
|
||||
: "Databases are reachable from your network"
|
||||
subtitle: {
|
||||
const names = Firewall.exposedDataStores.map(entry => String(entry.name));
|
||||
return names.join(" and ") + " "
|
||||
+ (names.length === 1 ? "is" : "are")
|
||||
+ " listening on every interface, and this zone permits it. Anyone on your network can connect.";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Firewall.exposedDataStores
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
|
||||
+ (String(modelData.process ?? "") !== ""
|
||||
? " · " + String(modelData.process) : "")
|
||||
+ " · allowed by " + String(modelData.allowedBy ?? "")
|
||||
value: ""
|
||||
divider: index < Firewall.exposedDataStores.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Everything reachable ─────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Reachable right now"
|
||||
subtitle: !Firewall.scanned
|
||||
? "Checking what is listening and what the firewall permits…"
|
||||
: (Firewall.available
|
||||
? "Listening on a network address, and permitted by the firewall. Both have to be true."
|
||||
: "The firewall is not running, so nothing here is being filtered.")
|
||||
|
||||
Repeater {
|
||||
model: Firewall.exposed
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
|
||||
+ (String(modelData.process ?? "") !== "" && String(modelData.process) !== String(modelData.name)
|
||||
? " · " + String(modelData.process) : "")
|
||||
+ " · allowed by " + String(modelData.allowedBy ?? "")
|
||||
value: String(modelData.kind ?? "") === "data" ? "Database" : ""
|
||||
divider: index < Firewall.exposed.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Firewall.exposed.length === 0 && Firewall.scanned && Firewall.available
|
||||
label: "Nothing is reachable"
|
||||
detail: "No service is both listening on a network address and permitted"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── The rules that allow it ──────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Firewall.zone !== null
|
||||
title: "What this zone allows"
|
||||
subtitle: Firewall.zone
|
||||
? String(Firewall.zone.name) + ", applied to "
|
||||
+ (Firewall.zone.interfaces ?? []).join(" and ")
|
||||
: ""
|
||||
|
||||
// The single rule that explains almost every row above.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: Firewall.wideOpen
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "Ports " + Firewall.openRanges.join(", ")
|
||||
detail: root.confirmingRange
|
||||
? "Closing this cuts off " + Firewall.rangeDependents().length
|
||||
+ " reachable service" + (Firewall.rangeDependents().length === 1 ? "" : "s")
|
||||
+ ", including " + Firewall.rangeDependents().slice(0, 3)
|
||||
.map(entry => String(entry.name)).join(", ")
|
||||
+ ". Anything that needs a port will have to be allowed by name."
|
||||
: "Fedora Workstation opens these so applications can listen without asking. It is why most of the list above is reachable."
|
||||
controlWidth: 230
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingRange ? "Keep it open" : "Close the range…"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: root.confirmingRange = !root.confirmingRange
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingRange
|
||||
text: "Close it"
|
||||
tone: "danger"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: {
|
||||
root.confirmingRange = false;
|
||||
for (const spec of Firewall.openRanges)
|
||||
Firewall.removePort(String(spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Firewall.zone?.services ?? []
|
||||
|
||||
delegate: SettingRow {
|
||||
id: serviceRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string serviceName: String(serviceRow.modelData)
|
||||
readonly property bool confirming: root.confirmingRemoval === serviceRow.serviceName
|
||||
// Removing ssh while someone is connected over it ends their
|
||||
// session. Worth saying before, not after.
|
||||
readonly property bool risky: serviceRow.serviceName === "ssh"
|
||||
&& Firewall.sshSessions > 0
|
||||
|
||||
width: parent.width
|
||||
label: serviceRow.serviceName
|
||||
detail: serviceRow.confirming
|
||||
? (serviceRow.risky
|
||||
? "Someone is connected over SSH right now. Removing this ends that session."
|
||||
: "Anything relying on this service stops being reachable.")
|
||||
: "Allowed by name, so it works whatever the port range says"
|
||||
controlWidth: 210
|
||||
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: serviceRow.confirming ? "Keep" : "Remove…"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: root.confirmingRemoval =
|
||||
serviceRow.confirming ? "" : serviceRow.serviceName
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: serviceRow.confirming
|
||||
text: "Remove"
|
||||
tone: "danger"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: {
|
||||
root.confirmingRemoval = "";
|
||||
Firewall.removeService(serviceRow.serviceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shown, never edited.
|
||||
TextRow {
|
||||
visible: (Firewall.zone?.richRules ?? []).length > 0
|
||||
label: "Rich rules"
|
||||
detail: "Custom rules in firewalld's own syntax. Shown here so this page does not misrepresent your configuration; edit them with firewall-cmd."
|
||||
value: (Firewall.zone?.richRules ?? []).length + " defined"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zones ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Zones"
|
||||
subtitle: "A zone is a set of rules. Each network connection uses one."
|
||||
|
||||
Repeater {
|
||||
model: Object.keys(Firewall.activeZones ?? ({}))
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData)
|
||||
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
|
||||
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Default for new connections"
|
||||
detail: "Used when a network does not ask for a particular zone"
|
||||
value: Firewall.defaultZone
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── The service underneath ───────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Firewall service"
|
||||
|
||||
TextRow {
|
||||
label: "firewalld"
|
||||
detail: !Firewall.scanned
|
||||
? "Reading the firewall's state…"
|
||||
: (Firewall.running
|
||||
? (Firewall.enabledAtBoot
|
||||
? "Running, and starts with the system"
|
||||
: "Running, but not started at boot")
|
||||
: "Not running, so nothing is being filtered")
|
||||
value: !Firewall.scanned ? "Checking…" : (Firewall.running ? "Running" : "Stopped")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Gaming: what the machine is doing now, and what it should do while you play.
|
||||
//
|
||||
// Live first, because unlike every other page here this one has a genuinely
|
||||
// live dimension -- "is Game Mode actually on, and how hot is the card" is the
|
||||
// question that brings someone here mid-session.
|
||||
//
|
||||
// The part that makes this Panama's page rather than a gamemode config editor
|
||||
// is "While a game is running": gamemode runs a script when a game starts and
|
||||
// another when it exits, so the power profile and Do Not Disturb can follow the
|
||||
// game and be put back afterwards -- back to what they were, not to a default.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "gaming"
|
||||
title: "Gaming"
|
||||
lede: "What this machine is doing, and how it should behave while you play."
|
||||
|
||||
readonly property var gpu: Gaming.primaryGpu
|
||||
|
||||
// Polling only while this page is on screen.
|
||||
Component.onCompleted: {
|
||||
Gaming.refresh();
|
||||
Gaming.watching = true;
|
||||
}
|
||||
Component.onDestruction: Gaming.watching = false
|
||||
|
||||
TextRow {
|
||||
visible: Gaming.lastError !== ""
|
||||
label: "Gaming needs attention"
|
||||
detail: Gaming.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Right now ────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: Gaming.active ? "Playing" : "Idle"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Gaming.active
|
||||
? "Game Mode is engaged"
|
||||
: "No game has requested Game Mode"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Gaming.gpus
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: modelData.discrete ? "Graphics card" : "Integrated graphics"
|
||||
detail: String(modelData.driver ?? "")
|
||||
+ (modelData.discrete ? " · the one games use" : " · idle unless something asks for it")
|
||||
value: Gaming.gpuSummary(modelData)
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "CPU governor"
|
||||
detail: "What the cores are scaling to right now"
|
||||
value: String(Gaming.gameMode?.governorNow ?? "unknown")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── What Panama does about it ────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "While a game is running"
|
||||
subtitle: Gaming.gameMode?.hooksInstalled === true
|
||||
? "Panama reacts when Game Mode engages, and puts everything back when the game exits."
|
||||
: "Panama can react when Game Mode engages. This needs a hook in gamemode's configuration."
|
||||
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled !== true
|
||||
label: "Let Panama react to games"
|
||||
detail: "Adds a start and end hook to your gamemode configuration. Nothing else in that file is touched."
|
||||
action: "Enable"
|
||||
enabled: !Gaming.busy && Gaming.gameMode?.available === true
|
||||
onTriggered: Gaming.installHooks()
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
setting: "gamingPerformanceProfile"
|
||||
}
|
||||
|
||||
// Silencing while a game runs is a focus mode with a trigger, and it
|
||||
// now lives with the others -- so "something silenced my notifications"
|
||||
// has one answer rather than two places to look.
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
label: "Silence notifications"
|
||||
detail: {
|
||||
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
|
||||
if (!mode)
|
||||
return "Handled by a focus mode on Notifications & Focus.";
|
||||
return mode.enabled === true
|
||||
? "Handled by the Gaming focus mode, which is on."
|
||||
: "The Gaming focus mode is off, so notifications are not silenced.";
|
||||
}
|
||||
action: "Open Focus"
|
||||
onTriggered: ShellState.settingsPage = "notifications"
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
setting: "gamingNotifyOnStart"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
label: "Stop reacting to games"
|
||||
detail: "Removes the hooks. The settings above are kept."
|
||||
action: "Disable"
|
||||
enabled: !Gaming.busy
|
||||
divider: false
|
||||
onTriggered: Gaming.removeHooks()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Game Mode itself ─────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Game Mode"
|
||||
subtitle: Gaming.gameMode?.available === true
|
||||
? "Applied by gamemode to a game while it runs, then undone."
|
||||
: "gamemode is not installed."
|
||||
|
||||
TextRow {
|
||||
label: "Daemon"
|
||||
detail: Gaming.gameMode?.daemonRunning === true
|
||||
? "Running, waiting for a game to ask"
|
||||
: "Not running, so no game can request it"
|
||||
value: Gaming.gameMode?.daemonRunning === true ? "Running" : "Stopped"
|
||||
}
|
||||
|
||||
// Said plainly rather than implied: on a machine already running the
|
||||
// governor gamemode would switch to, its headline effect is nothing.
|
||||
TextRow {
|
||||
label: "Governor while gaming"
|
||||
detail: Gaming.governorAlreadyThere
|
||||
? "This machine already runs that governor, so Game Mode changes nothing here"
|
||||
: "Switched for the duration of the game"
|
||||
value: String(Gaming.gameMode?.governorWhileGaming ?? "unknown")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overlay ──────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Performance overlay"
|
||||
subtitle: Gaming.overlay?.installed === true
|
||||
? "MangoHud, drawn on top of the game."
|
||||
: "MangoHud is not installed."
|
||||
|
||||
SwitchRow {
|
||||
label: "Show the overlay in games"
|
||||
detail: "Takes effect for games launched after your next sign-in, because it is read from the session environment"
|
||||
checked: Gaming.overlay?.globallyEnabled === true
|
||||
enabled: !Gaming.busy && Gaming.overlay?.installed === true
|
||||
onToggled: value => Gaming.setOverlay(value)
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
label: "What it shows"
|
||||
detail: "Frame rate alone, or the full readout with GPU and CPU"
|
||||
options: [
|
||||
{ value: "fps", label: "Frame rate" },
|
||||
{ value: "detailed", label: "Detailed" }
|
||||
]
|
||||
value: String(Gaming.overlay?.preset ?? "fps")
|
||||
enabled: !Gaming.busy && Gaming.overlay?.installed === true
|
||||
onSelected: value => Gaming.setOverlayPreset(value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Toggle in game"
|
||||
detail: "Shows and hides the overlay without leaving the game"
|
||||
value: String(Gaming.overlay?.toggleKey ?? "")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library ──────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Library"
|
||||
subtitle: "Where games live, and what can run them."
|
||||
|
||||
TextRow {
|
||||
label: "Installed games"
|
||||
detail: String(Gaming.library?.path ?? "")
|
||||
value: String(Gaming.library?.games ?? 0)
|
||||
}
|
||||
|
||||
// Listed, not chosen. Steam picks the runtime per game in its own
|
||||
// properties, and a control here would be claiming an authority this
|
||||
// page does not have.
|
||||
Repeater {
|
||||
model: Gaming.library?.protonBuilds ?? []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: modelData.community === true
|
||||
? "Community build · chosen per game in Steam"
|
||||
: "Valve · chosen per game in Steam"
|
||||
value: "Installed"
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "gamescope"
|
||||
detail: "Micro-compositor for scaling and frame limiting, used per game from Steam"
|
||||
value: Gaming.library?.gamescope === true ? "Available" : "Not installed"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,22 +422,6 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:users"
|
||||
label: "Users"
|
||||
detail: "Accounts, passwords, and automatic login"
|
||||
action: "Open users"
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:sharing"
|
||||
label: "Sharing"
|
||||
detail: "Remote desktop, media sharing, and remote login"
|
||||
action: "Open sharing"
|
||||
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
label: "Color profiles"
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
// Home — the page you open to do something, not to read a report.
|
||||
//
|
||||
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000,
|
||||
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest
|
||||
// thing on the screen, and nobody has ever needed it here. Below it sat a
|
||||
// permanently open search field for a weather location that is set about once a
|
||||
// year, and then roughly half a page of nothing.
|
||||
//
|
||||
// What Panama already knows is the useful material: whether anything needs
|
||||
// attention, what is running, and the two or three things worth doing next. A
|
||||
// finding is shown when there is one and the page is quiet when there is not,
|
||||
// which is the same shape the Firewall and Containers pages use.
|
||||
//
|
||||
// Weather stays. It is the one genuinely ambient thing here, and it belongs
|
||||
// next to a greeting rather than in a card of its own with a search box open.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
@@ -6,119 +22,101 @@ SettingsPage {
|
||||
id: root
|
||||
|
||||
readonly property int openedHour: new Date().getHours()
|
||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
readonly property string greeting: openedHour < 12
|
||||
? "Good morning"
|
||||
: (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
|
||||
// Findings, in the order they deserve attention. Each names the page that
|
||||
// can actually resolve it, because a home page that reports a problem it
|
||||
// cannot help with is just an alarm.
|
||||
readonly property var findings: {
|
||||
const found = [];
|
||||
const exposed = Firewall.exposedDataStores ?? [];
|
||||
if (exposed.length > 0) {
|
||||
found.push({
|
||||
label: exposed.length === 1
|
||||
? "A database is reachable from your network"
|
||||
: "Databases are reachable from your network",
|
||||
detail: exposed.map(entry => String(entry.name)).join(" and ")
|
||||
+ ", published on every interface",
|
||||
page: "firewall",
|
||||
action: "Review"
|
||||
});
|
||||
}
|
||||
if (Updates.securityCount > 0) {
|
||||
found.push({
|
||||
label: Updates.securityCount + " update"
|
||||
+ (Updates.securityCount === 1 ? "" : "s") + " carry a security advisory",
|
||||
detail: "Worth installing before the rest",
|
||||
page: "updates",
|
||||
action: "Open"
|
||||
});
|
||||
}
|
||||
if (Updates.rebootNeeded) {
|
||||
found.push({
|
||||
label: "A newer kernel is installed than the one running",
|
||||
detail: "Restart to use it",
|
||||
page: "updates",
|
||||
action: "Open"
|
||||
});
|
||||
}
|
||||
if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) {
|
||||
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
|
||||
found.push({
|
||||
label: count + " health check" + (count === 1 ? "" : "s") + " need attention",
|
||||
detail: "Something the desktop owns is not in its expected state",
|
||||
page: "services",
|
||||
action: "Open"
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// One line of everything that is fine, so the quiet case still says
|
||||
// something rather than showing an empty page.
|
||||
readonly property string reassurance: {
|
||||
const parts = [];
|
||||
if (Health.checks.length > 0)
|
||||
parts.push(Health.checks.length + " health checks pass");
|
||||
if (Disks.rootFilesystem)
|
||||
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free");
|
||||
const newest = Snapshots.configs.length > 0
|
||||
? (Snapshots.configs[0].snapshots ?? [])[0]
|
||||
: null;
|
||||
if (newest)
|
||||
parts.push("snapshots ran at " + root.clockOf(String(newest.date ?? "")));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// snapper prints a full date; only the time is wanted in a summary line.
|
||||
function clockOf(stamp: string): string {
|
||||
const match = /(\d{1,2}:\d{2})(?::\d{2})?\s*(AM|PM)?/i.exec(stamp);
|
||||
if (!match)
|
||||
return stamp;
|
||||
return match[2] ? match[1] + " " + match[2].toUpperCase() : match[1];
|
||||
}
|
||||
|
||||
title: `${root.greeting}, Gabriel`
|
||||
lede: "Your desktop is configured and ready."
|
||||
lede: Weather.available
|
||||
? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
|
||||
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation
|
||||
: "Your desktop is configured and ready."
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorDescription || "Active display"
|
||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||
Component.onCompleted: {
|
||||
if (!Firewall.scanned)
|
||||
Firewall.refresh();
|
||||
if (!Containers.scanned)
|
||||
Containers.refresh();
|
||||
if (!Snapshots.scanned)
|
||||
Snapshots.refresh();
|
||||
if (!Disks.scanned)
|
||||
Disks.refresh();
|
||||
}
|
||||
|
||||
// ── What you came here to do ────────────────────────────────────────────
|
||||
|
||||
Grid {
|
||||
id: monitorLayout
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 620 ? 2 : 1
|
||||
columnSpacing: 28
|
||||
rowSpacing: 16
|
||||
|
||||
Item {
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: 164
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(parent.width - 24, 260)
|
||||
height: width * 0.64
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 5
|
||||
radius: 12
|
||||
color: Theme.alpha(Theme.bgDark, 0.94)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.34)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
radius: 7
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
|
||||
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: SystemSettings.monitorName || "DISPLAY"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: monitorLayout.columns === 2 ? 164 : implicitHeight
|
||||
spacing: 13
|
||||
|
||||
Text {
|
||||
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
|
||||
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Local conditions in the date menu"
|
||||
TextRow {
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name below is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
}
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
id: doing
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 720 ? 2 : 1
|
||||
@@ -126,20 +124,46 @@ SettingsPage {
|
||||
rowSpacing: 16
|
||||
|
||||
SettingsCard {
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Quiet focus"
|
||||
subtitle: "Notifications and focused work"
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Focus"
|
||||
subtitle: FocusSession.active
|
||||
? "A session is running · " + FocusSession.remainingText + " left"
|
||||
: (FocusModes.active
|
||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason
|
||||
: (Notifs.doNotDisturb
|
||||
? "Do Not Disturb is on"
|
||||
: "Nothing is quieting this machine"))
|
||||
|
||||
SettingRow {
|
||||
label: FocusSession.active ? "End the session" : "Start a focus session"
|
||||
detail: FocusSession.active
|
||||
? "Puts Do Not Disturb and Caffeine back as they were"
|
||||
: Settings.focusDurationMinutes + " minutes · Super+Shift+F"
|
||||
controlWidth: 120
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.active ? "End" : "Start"
|
||||
tone: FocusSession.active ? "normal" : "accent"
|
||||
onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
|
||||
detail: FocusModes.active
|
||||
? "Held on by the " + FocusModes.activeName + " mode"
|
||||
: "Banners are held; the notification centre still fills"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !FocusModes.active && !FocusSession.active
|
||||
checked: Notifs.doNotDisturb
|
||||
onToggled: value => Notifs.doNotDisturb = value
|
||||
}
|
||||
@@ -147,18 +171,119 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Desktop services"
|
||||
subtitle: "The essentials are running"
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Right now"
|
||||
subtitle: Containers.running > 0
|
||||
? Containers.running + " of " + Containers.total + " containers running"
|
||||
: "No containers are running"
|
||||
|
||||
TextRow {
|
||||
label: "Sync & remote access"
|
||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||
label: Containers.running > 0
|
||||
? Containers.projects.filter(project => project.running > 0)
|
||||
.map(project => String(project.title)).join(", ")
|
||||
: "Nothing to report"
|
||||
detail: Containers.running > 0
|
||||
? "Started for development, still up"
|
||||
: "Start a stack from the Containers page"
|
||||
value: ""
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Containers"
|
||||
detail: "Projects, what they expose, and what they cost"
|
||||
action: "Open"
|
||||
divider: false
|
||||
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── This machine ────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "This machine"
|
||||
subtitle: root.reassurance
|
||||
|
||||
TextRow {
|
||||
visible: root.findings.length === 0
|
||||
label: "Nothing needs your attention"
|
||||
detail: "Anything worth acting on would appear here."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.findings
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.label ?? "")
|
||||
detail: String(modelData.detail ?? "")
|
||||
controlWidth: 110
|
||||
divider: index < root.findings.length - 1
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(modelData.action ?? "Open")
|
||||
onClicked: ShellState.settingsPage = String(modelData.page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worth doing, when there is something ────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Updates.total > 0 || Containers.reclaimable > 0
|
||||
title: "Do next"
|
||||
subtitle: "Small things this machine is waiting on."
|
||||
|
||||
ActionRow {
|
||||
visible: Updates.total > 0
|
||||
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s")
|
||||
detail: "Packages, applications and firmware, from wherever each comes from"
|
||||
action: "Open"
|
||||
divider: Containers.reclaimable > 0
|
||||
onTriggered: ShellState.settingsPage = "updates"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Containers.reclaimable > 0
|
||||
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable)
|
||||
detail: "Container images and volumes nothing references"
|
||||
action: "Review"
|
||||
divider: false
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Shown above and in the date menu."
|
||||
|
||||
// The location is set roughly once a year, so the search that changes it
|
||||
// is behind a press rather than permanently open.
|
||||
PickerRow {
|
||||
id: locationPicker
|
||||
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
onPicked: locationPicker.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,14 @@ SettingsPage {
|
||||
return "Home Assistant is unavailable";
|
||||
}
|
||||
|
||||
// The entity list is a one-time migration seed for the Control Center
|
||||
// selection, not the light catalog -- the helper discovers that live from
|
||||
// Home Assistant. It is passed back unchanged so that saving a URL or a
|
||||
// token cannot disturb the seed.
|
||||
function saveHomeAssistantConfig(): void {
|
||||
HomeAssistantConfig.save(
|
||||
homeUrlInput.text,
|
||||
homeEntitiesInput.text,
|
||||
HomeAssistantConfig.entities.join(", "),
|
||||
homeTokenInput.text
|
||||
);
|
||||
}
|
||||
@@ -57,7 +61,6 @@ SettingsPage {
|
||||
function onConfigurationSaved(): void {
|
||||
homeTokenInput.clear();
|
||||
homeUrlInput.text = HomeAssistantConfig.url;
|
||||
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,65 +133,6 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
topPadding: 10
|
||||
bottomPadding: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Light entities"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Comma-separated entity IDs. These define the discoverable light catalog."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 72
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.fg, 0.055)
|
||||
border.width: homeEntitiesInput.activeFocus ? 2 : 1
|
||||
border.color: homeEntitiesInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
TextEdit {
|
||||
id: homeEntitiesInput
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.entities.join(", ")
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: TextEdit.Wrap
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeEntitiesInput.text === ""
|
||||
text: "light.living_room, light.kitchen"
|
||||
color: Theme.fgMuted
|
||||
font: homeEntitiesInput.font
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: HomeAssistantConfig.lastError !== ""
|
||||
|
||||
@@ -12,6 +12,10 @@ import qs.modules.clipboard
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// Emitted once a place has been chosen, so a container can put the search
|
||||
// away. Choosing is still this component's job; closing is not.
|
||||
signal picked
|
||||
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
@@ -37,8 +41,10 @@ Column {
|
||||
divider: place.index < Geocoding.results.length - 1
|
||||
activatable: true
|
||||
onActivated: {
|
||||
if (Geocoding.choose(place.modelData))
|
||||
if (Geocoding.choose(place.modelData)) {
|
||||
query.text = "";
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,52 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Only one application is expanded at a time; the point is a card you
|
||||
// can read, not twenty open at once.
|
||||
property string expandedApp: ""
|
||||
|
||||
// Which focus mode is open for editing. One at a time, like the app rules.
|
||||
property string expandedMode: ""
|
||||
|
||||
// Which mode's exception list is open. Separate from expandedMode so the
|
||||
// list does not unfold every time a mode is opened to change a switch.
|
||||
property string expandedAllow: ""
|
||||
|
||||
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
|
||||
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
|
||||
const at = current.indexOf(appId);
|
||||
if (allowed && at < 0)
|
||||
current.push(appId);
|
||||
else if (!allowed && at >= 0)
|
||||
current.splice(at, 1);
|
||||
FocusModes.update(String(mode.id), { allow: current });
|
||||
}
|
||||
|
||||
// Schedule edits go through the mode's trigger list rather than replacing
|
||||
// it, so a mode that also has a game or fullscreen trigger keeps it.
|
||||
function reschedule(mode: var, changes: var): void {
|
||||
FocusModes.update(String(mode.id), {
|
||||
triggers: (mode.triggers ?? []).map(trigger =>
|
||||
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDay(mode: var, day: int): void {
|
||||
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
||||
if (!schedule)
|
||||
return;
|
||||
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
|
||||
const at = days.indexOf(day);
|
||||
if (at >= 0)
|
||||
days.splice(at, 1);
|
||||
else
|
||||
days.push(day);
|
||||
days.sort((a, b) => a - b);
|
||||
root.reschedule(mode, { days: days });
|
||||
}
|
||||
|
||||
title: "Notifications & Focus"
|
||||
lede: "Control interruptions without losing useful history."
|
||||
|
||||
@@ -25,7 +71,7 @@ SettingsPage {
|
||||
TextRow {
|
||||
label: "Notification history"
|
||||
detail: "Live notifications retained by the shell"
|
||||
value: `${Notifs.history.length} items`
|
||||
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
@@ -65,26 +111,65 @@ SettingsPage {
|
||||
model: Notifs.applications
|
||||
|
||||
Column {
|
||||
id: appEntry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var app: modelData
|
||||
readonly property var app: appEntry.modelData
|
||||
readonly property var rule: Notifs.appRule(appEntry.app.id)
|
||||
readonly property bool open: root.expandedApp === String(appEntry.app.id)
|
||||
|
||||
// What the two lock-screen switches add up to, so the common
|
||||
// case -- reading rather than changing -- needs no interaction.
|
||||
// Every app repeated both switch labels verbatim before this,
|
||||
// which made twenty apps sixty rows of identical sentences.
|
||||
readonly property string summary: {
|
||||
if (!appEntry.rule.enabled)
|
||||
return "Notifications off";
|
||||
if (!appEntry.rule.showOnLockScreen)
|
||||
return "On · hidden on the lock screen";
|
||||
return appEntry.rule.showContentOnLockScreen
|
||||
? "On · lock screen shows content"
|
||||
: "On · lock screen shows the sender only";
|
||||
}
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
label: app.name
|
||||
detail: app.id
|
||||
controlWidth: 48
|
||||
width: parent.width
|
||||
label: appEntry.app.name
|
||||
// The identifier is only worth the space while the app is
|
||||
// open, which is the only time it disambiguates anything.
|
||||
detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary
|
||||
activatable: true
|
||||
divider: !appEntry.open
|
||||
controlWidth: 92
|
||||
onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id)
|
||||
|
||||
SettingsToggle {
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).enabled
|
||||
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
|
||||
spacing: 11
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: appEntry.rule.enabled
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: appEntry.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: appEntry.open
|
||||
label: "Show on lock screen"
|
||||
detail: "Allow this app's notifications on the lock screen"
|
||||
controlWidth: 48
|
||||
@@ -92,22 +177,264 @@ SettingsPage {
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
|
||||
checked: appEntry.rule.showOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: appEntry.open
|
||||
label: "Show content on lock screen"
|
||||
detail: "Show message details when this app is visible there"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
// Meaningless unless the app reaches the lock screen at all.
|
||||
opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: appEntry.rule.showOnLockScreen
|
||||
checked: appEntry.rule.showContentOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus modes"
|
||||
subtitle: FocusModes.active
|
||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
|
||||
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
|
||||
|
||||
Repeater {
|
||||
model: FocusModes.modes
|
||||
|
||||
delegate: Column {
|
||||
id: modeEntry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var mode: modeEntry.modelData
|
||||
readonly property string modeId: String(modeEntry.mode.id ?? "")
|
||||
readonly property bool open: root.expandedMode === modeEntry.modeId
|
||||
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
|
||||
readonly property var schedule: (modeEntry.mode.triggers ?? [])
|
||||
.find(trigger => trigger.kind === "schedule") ?? null
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: String(modeEntry.mode.name ?? "")
|
||||
detail: modeEntry.running
|
||||
? "On now — " + FocusModes.activeReason
|
||||
: FocusModes.summary(modeEntry.mode)
|
||||
activatable: true
|
||||
divider: !modeEntry.open
|
||||
controlWidth: 92
|
||||
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 11
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.enabled === true
|
||||
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modeEntry.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open
|
||||
label: "Silence notifications"
|
||||
detail: "Banners are held until the mode ends"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showContentOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
|
||||
checked: modeEntry.mode.silence === true
|
||||
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open
|
||||
label: "Keep the screen awake"
|
||||
detail: "Caffeine, for as long as the mode is on"
|
||||
controlWidth: 48
|
||||
divider: modeEntry.schedule !== null
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.keepAwake === true
|
||||
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
|
||||
}
|
||||
}
|
||||
|
||||
// Only modes that actually have a schedule get the schedule
|
||||
// controls; a game trigger has no times to set.
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "From"
|
||||
detail: "24-hour, such as 23:30"
|
||||
text: String(modeEntry.schedule?.start ?? "")
|
||||
placeholder: "23:30"
|
||||
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "Until"
|
||||
detail: "A time earlier than the start means the window crosses midnight"
|
||||
text: String(modeEntry.schedule?.end ?? "")
|
||||
placeholder: "07:00"
|
||||
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.mode.silence === true
|
||||
label: "May interrupt"
|
||||
detail: (modeEntry.mode.allow ?? []).length === 0
|
||||
? "Nothing gets through while this mode is on"
|
||||
: "Everything else is held until the mode ends"
|
||||
activatable: true
|
||||
controlWidth: 150
|
||||
onActivated: root.expandedAllow =
|
||||
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: (modeEntry.mode.allow ?? []).length === 0
|
||||
? "Nothing"
|
||||
: (modeEntry.mode.allow ?? []).length + " apps"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn from the applications that have actually sent a
|
||||
// notification, which is the same list the rules above use --
|
||||
// an exception for something that never notifies is not a
|
||||
// choice worth offering.
|
||||
Repeater {
|
||||
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
|
||||
? Notifs.applications
|
||||
: []
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.id ?? "")
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
|
||||
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
|
||||
&& Notifs.applications.length === 0
|
||||
label: "No applications yet"
|
||||
detail: "They appear here once they have sent a notification."
|
||||
value: ""
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "On these days"
|
||||
detail: "A window that crosses midnight belongs to the day it starts on"
|
||||
controlWidth: 250
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ value: 1, label: "M" }, { value: 2, label: "T" },
|
||||
{ value: 3, label: "W" }, { value: 4, label: "T" },
|
||||
{ value: 5, label: "F" }, { value: 6, label: "S" },
|
||||
{ value: 0, label: "S" }
|
||||
]
|
||||
|
||||
delegate: Rectangle {
|
||||
id: dayPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool on:
|
||||
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
|
||||
|
||||
width: 30
|
||||
height: 28
|
||||
radius: 8
|
||||
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: dayPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: String(dayPill.modelData.label)
|
||||
color: dayPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler {
|
||||
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// quietly stops syncing for weeks.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -57,6 +58,22 @@ SettingsPage {
|
||||
id: accountCard
|
||||
required property var modelData
|
||||
|
||||
// GOA hands back a preference-ordered chain; this walks it and takes
|
||||
// the first name the active icon theme actually has. Taking the
|
||||
// first name blindly, or the last as a fallback, both looked right
|
||||
// and were not: on Adwaita the tail of these chains ("mail",
|
||||
// "goa-symbolic") does not exist, so a miss would have rendered
|
||||
// nothing at all.
|
||||
icon: {
|
||||
const chain = accountCard.modelData.providerIcons ?? [];
|
||||
for (const name of chain) {
|
||||
if (Quickshell.iconPath(String(name), true) !== "")
|
||||
return String(name);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
iconFallback: "avatar-default-symbolic"
|
||||
|
||||
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||
subtitle: accountCard.modelData.needsAttention
|
||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// A row whose trailing control is a masked password field.
|
||||
//
|
||||
// Reports every keystroke rather than committing on Enter, because the page it
|
||||
// serves has to compare two fields as they are typed and say whether they match
|
||||
// before offering to set anything.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string placeholder: "Password"
|
||||
|
||||
signal changed(value: string)
|
||||
|
||||
controlWidth: 220
|
||||
|
||||
PasswordField {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.controlWidth
|
||||
placeholder: root.placeholder
|
||||
onTextChanged: root.changed(this.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// A setting whose value is chosen from far more options than belong on screen.
|
||||
//
|
||||
// PickerRow {
|
||||
// label: "Language"
|
||||
// value: SystemLocale.currentLabel
|
||||
// SearchPicker { items: SystemLocale.locales; onPicked: ... }
|
||||
// }
|
||||
//
|
||||
// Three pages rendered an entire dataset as rows -- every installed locale, the
|
||||
// whole tz database, every mode a monitor advertises -- so the one line telling
|
||||
// you what is currently set was buried under hundreds that were not. The chooser
|
||||
// itself was never the problem and is unchanged: this only collapses it behind
|
||||
// the current value, which is what a person came to the page to read.
|
||||
//
|
||||
// Collapsed by default, and closes again once something is picked, so the page
|
||||
// returns to being readable rather than staying open on a list nobody needs any
|
||||
// more.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string detail: ""
|
||||
// What is set right now. Shown on the collapsed row, so the common case --
|
||||
// looking rather than changing -- needs no interaction at all.
|
||||
property string value: ""
|
||||
property bool enabled: true
|
||||
property bool divider: true
|
||||
property bool expanded: false
|
||||
|
||||
default property alias content: body.data
|
||||
|
||||
// Pages call this from their picker's onPicked, so choosing something puts
|
||||
// the list away instead of leaving it open over the rest of the page.
|
||||
function collapse(): void { root.expanded = false; }
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: root.label
|
||||
detail: root.detail
|
||||
activatable: root.enabled
|
||||
divider: root.divider && !root.expanded
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
controlWidth: 210
|
||||
onActivated: root.expanded = !root.expanded
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.value
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
width: parent.width
|
||||
visible: root.expanded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Printers, and one queue across all of them.
|
||||
//
|
||||
// The list answers "which printers exist"; the queue answers "where is my
|
||||
// document", which is the question that actually brings someone here. They are
|
||||
// separate cards because they are separate questions -- a job that has not come
|
||||
// out is not necessarily a problem with the printer it was sent to.
|
||||
//
|
||||
// Driverless only. A printer old enough to need a PPD is named as such rather
|
||||
// than offered and then failing at the moment it is added.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "printers"
|
||||
title: "Printers"
|
||||
lede: "Printers this machine can use, and what they are waiting on."
|
||||
|
||||
property string expandedPrinter: ""
|
||||
property string confirmingRemoval: ""
|
||||
property bool addingByAddress: false
|
||||
property string manualUri: ""
|
||||
property string manualName: ""
|
||||
|
||||
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
|
||||
&& root.manualName.trim() !== ""
|
||||
|
||||
Component.onCompleted: Printers.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Printers.lastError !== ""
|
||||
label: "Printing needs attention"
|
||||
detail: Printers.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Which printers exist ─────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.anyPrinters
|
||||
title: "Printers"
|
||||
subtitle: "Open one to change what it does by default."
|
||||
|
||||
Repeater {
|
||||
model: Printers.printers
|
||||
|
||||
delegate: Column {
|
||||
id: printerBlock
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string name: String(printerBlock.modelData.name ?? "")
|
||||
readonly property bool open: root.expandedPrinter === printerBlock.name
|
||||
readonly property bool paused: String(printerBlock.modelData.state ?? "") === "stopped"
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: printerBlock.width
|
||||
icon: printerBlock.paused ? "\u{F0026}" : "\u{F042A}"
|
||||
label: String(printerBlock.modelData.description ?? printerBlock.name)
|
||||
+ (printerBlock.modelData.isDefault ? " · Default" : "")
|
||||
detail: Printers.stateSummary(printerBlock.modelData)
|
||||
+ " · " + String(printerBlock.modelData.uri ?? "")
|
||||
value: Printers.jobsFor(printerBlock.name) + " job"
|
||||
+ (Printers.jobsFor(printerBlock.name) === 1 ? "" : "s")
|
||||
activatable: !Printers.busy
|
||||
divider: !printerBlock.open
|
||||
&& printerBlock.index < Printers.printers.length - 1
|
||||
onActivated: {
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedPrinter = printerBlock.open ? "" : printerBlock.name;
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: printerBlock.width
|
||||
visible: printerBlock.open
|
||||
|
||||
TextRow {
|
||||
width: printerBlock.width
|
||||
label: "Model"
|
||||
detail: "Reported by the printer itself"
|
||||
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
visible: !printerBlock.modelData.isDefault
|
||||
label: "Use by default"
|
||||
detail: "Applications print here unless they are told otherwise"
|
||||
action: "Make default"
|
||||
enabled: !Printers.busy
|
||||
onTriggered: Printers.setDefault(printerBlock.name)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
label: printerBlock.paused ? "Resume printing" : "Pause printing"
|
||||
detail: printerBlock.paused
|
||||
? "Queued jobs start again"
|
||||
: "Jobs keep queuing, but nothing is sent until it resumes"
|
||||
action: printerBlock.paused ? "Resume" : "Pause"
|
||||
enabled: !Printers.busy
|
||||
onTriggered: printerBlock.paused
|
||||
? Printers.resume(printerBlock.name)
|
||||
: Printers.pause(printerBlock.name)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
label: "Print a test page"
|
||||
detail: "Confirms the printer answers and puts ink on paper"
|
||||
action: "Print"
|
||||
enabled: !Printers.busy && !printerBlock.paused
|
||||
onTriggered: Printers.testPage(printerBlock.name)
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: printerBlock.width
|
||||
label: "Remove this printer"
|
||||
detail: root.confirmingRemoval === printerBlock.name
|
||||
? "Anything still queued for it is cancelled."
|
||||
: "It can be added again later"
|
||||
controlWidth: 200
|
||||
divider: printerBlock.index < Printers.printers.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingRemoval === printerBlock.name ? "Keep" : "Remove…"
|
||||
enabled: !Printers.busy
|
||||
onClicked: root.confirmingRemoval =
|
||||
root.confirmingRemoval === printerBlock.name ? "" : printerBlock.name
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingRemoval === printerBlock.name
|
||||
text: "Remove"
|
||||
tone: "danger"
|
||||
enabled: !Printers.busy
|
||||
onClicked: {
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedPrinter = "";
|
||||
Printers.remove(printerBlock.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Where my document is ─────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.anyPrinters
|
||||
title: "Print queue"
|
||||
subtitle: Printers.jobs.length === 0
|
||||
? "Nothing is waiting."
|
||||
: "Everything waiting, across every printer."
|
||||
|
||||
Repeater {
|
||||
model: Printers.jobs
|
||||
|
||||
delegate: ActionRow {
|
||||
id: jobRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(jobRow.modelData.name ?? "Untitled")
|
||||
detail: String(jobRow.modelData.printer ?? "") + " · "
|
||||
+ String(jobRow.modelData.state ?? "")
|
||||
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
||||
? " · " + jobRow.modelData.pages + " pages" : "")
|
||||
action: "Cancel"
|
||||
enabled: !Printers.busy
|
||||
divider: jobRow.index < Printers.jobs.length - 1
|
||||
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Printers.jobs.length === 0
|
||||
label: "Queue is empty"
|
||||
detail: "Jobs appear here while they wait, and can be cancelled from here"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nothing set up yet ───────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.scanned && !Printers.anyPrinters
|
||||
title: "No printers yet"
|
||||
subtitle: "Printers that announce themselves on your network appear here on their own."
|
||||
|
||||
ActionRow {
|
||||
label: "Search the network"
|
||||
detail: Printers.searching
|
||||
? "Listening for printers that announce themselves…"
|
||||
: (Printers.searched
|
||||
? Printers.addable.length + " found"
|
||||
: "Looks for printers over mDNS, the same way phones and laptops find them")
|
||||
action: Printers.searching ? "Searching…" : "Search"
|
||||
enabled: !Printers.searching
|
||||
divider: false
|
||||
onTriggered: Printers.search()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adding ───────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Add a printer"
|
||||
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
|
||||
|
||||
ActionRow {
|
||||
visible: Printers.anyPrinters
|
||||
label: "Search the network"
|
||||
detail: Printers.searching
|
||||
? "Listening for printers that announce themselves…"
|
||||
: (Printers.searched
|
||||
? Printers.addable.length + " printer"
|
||||
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
|
||||
: "Looks for printers over mDNS")
|
||||
action: Printers.searching ? "Searching…" : "Search"
|
||||
enabled: !Printers.searching
|
||||
onTriggered: Printers.search()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Printers.addable
|
||||
|
||||
delegate: ActionRow {
|
||||
id: foundRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: String(foundRow.modelData.name ?? "")
|
||||
detail: foundRow.modelData.driverless === true
|
||||
? String(foundRow.modelData.uri ?? "") + " · driverless"
|
||||
: String(foundRow.modelData.uri ?? "") + " · needs a manufacturer driver"
|
||||
action: foundRow.modelData.driverless === true ? "Add" : "Not supported"
|
||||
enabled: !Printers.busy && foundRow.modelData.driverless === true
|
||||
onTriggered: Printers.add(String(foundRow.modelData.uri ?? ""),
|
||||
Printers.suggestedName(String(foundRow.modelData.name ?? "")))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Printers.searched && Printers.addable.length === 0 && !Printers.searching
|
||||
label: "Nothing found"
|
||||
detail: "No printer announced itself. It may be asleep, on another network, or may not support network discovery."
|
||||
value: ""
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Add by address"
|
||||
detail: "For a printer that does not announce itself"
|
||||
action: root.addingByAddress ? "Cancel" : "Enter…"
|
||||
enabled: !Printers.busy
|
||||
divider: root.addingByAddress
|
||||
onTriggered: {
|
||||
root.addingByAddress = !root.addingByAddress;
|
||||
root.manualUri = "";
|
||||
root.manualName = "";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.addingByAddress
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Address"
|
||||
detail: "ipp://, ipps://, or socket:// followed by the printer's host"
|
||||
placeholder: "ipp://printer.local/ipp/print"
|
||||
text: root.manualUri
|
||||
onAccepted: value => root.manualUri = value
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Name"
|
||||
detail: "What this printer is called on this machine"
|
||||
placeholder: "office-printer"
|
||||
text: root.manualName
|
||||
onAccepted: value => root.manualName = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Add this printer"
|
||||
detail: "The printer is asked what it can do; if it cannot answer, it is not added"
|
||||
action: "Add"
|
||||
enabled: root.manualReady && !Printers.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
Printers.add(root.manualUri, Printers.suggestedName(root.manualName));
|
||||
root.addingByAddress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The service underneath ───────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Printing service"
|
||||
subtitle: "What has to be running for any of this to work."
|
||||
|
||||
TextRow {
|
||||
label: "CUPS"
|
||||
detail: Printers.service?.running === true
|
||||
? "Running, so a printer added here works immediately"
|
||||
: "Not running, so nothing can print"
|
||||
value: Printers.service?.running === true ? "Running" : "Stopped"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Starts at boot"
|
||||
detail: Printers.service?.startsAtBoot === true
|
||||
? "Started with the system"
|
||||
: "Started on demand, when something prints. This is a normal configuration."
|
||||
value: Printers.service?.startsAtBoot === true ? "Yes" : "On demand"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Network discovery"
|
||||
detail: Printers.service?.discoveryAvailable === true
|
||||
? "Avahi is running, which is what finds printers that announce themselves"
|
||||
: "Avahi is not running, so network printers cannot be discovered"
|
||||
value: Printers.service?.discoveryAvailable === true ? "Available" : "Unavailable"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,25 @@ SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Privacy & Security"
|
||||
|
||||
// The stored-secret list is collapsed until asked for, and one item at a
|
||||
// time can be waiting on a confirmed Forget.
|
||||
property bool showingSecrets: false
|
||||
property string confirmingPath: ""
|
||||
|
||||
// What a stored secret is FOR, from its attributes. Never its value.
|
||||
function describe(item: var): string {
|
||||
const attributes = item?.attributes ?? {};
|
||||
const parts = [];
|
||||
for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) {
|
||||
if (attributes[key])
|
||||
parts.push(String(attributes[key]));
|
||||
}
|
||||
if (parts.length > 0)
|
||||
return parts.join(" · ");
|
||||
const schema = String(item?.schema ?? "");
|
||||
return schema !== "" ? schema : "No further detail stored";
|
||||
}
|
||||
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||
@@ -28,6 +47,8 @@ SettingsPage {
|
||||
DeviceSecurity.refresh();
|
||||
if (!Keyring.scanned)
|
||||
Keyring.refresh();
|
||||
if (!Permissions.scanned)
|
||||
Permissions.refresh();
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -96,6 +117,140 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── What is actually stored ──────────────────────────────────────────────
|
||||
// Collapsed until asked. Opening the Privacy page should not enumerate
|
||||
// someone's saved passwords as a side effect, and the list is long enough
|
||||
// that it would bury every other setting on the page.
|
||||
SettingsCard {
|
||||
visible: Keyring.scanned && Keyring.available && !Keyring.locked
|
||||
title: "Stored secrets"
|
||||
subtitle: Keyring.listed
|
||||
? "Passwords and tokens applications have saved. The values are never shown here."
|
||||
: "Passwords and tokens applications have saved, listed only when you ask."
|
||||
|
||||
ActionRow {
|
||||
label: "Saved items"
|
||||
detail: Keyring.listed
|
||||
? Keyring.storedCount + " stored across "
|
||||
+ Keyring.collections.length + " keyring"
|
||||
+ (Keyring.collections.length === 1 ? "" : "s")
|
||||
: "Read the keyring and list what is in it"
|
||||
action: root.showingSecrets
|
||||
? "Hide"
|
||||
: (Keyring.listing ? "Reading…" : "Show")
|
||||
enabled: !Keyring.listing
|
||||
divider: root.showingSecrets
|
||||
onTriggered: {
|
||||
if (root.showingSecrets) {
|
||||
root.showingSecrets = false;
|
||||
root.confirmingPath = "";
|
||||
return;
|
||||
}
|
||||
root.showingSecrets = true;
|
||||
if (!Keyring.listed)
|
||||
Keyring.list();
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.showingSecrets && Keyring.copiedPath !== ""
|
||||
label: "Copied to the clipboard"
|
||||
detail: "It clears itself in about a minute, unless you copy something else first."
|
||||
value: ""
|
||||
divider: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.showingSecrets && Keyring.listed ? Keyring.collections : []
|
||||
|
||||
delegate: Column {
|
||||
id: collectionBlock
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
|
||||
TextRow {
|
||||
width: collectionBlock.width
|
||||
label: String(collectionBlock.modelData.label ?? "")
|
||||
detail: collectionBlock.modelData.locked
|
||||
? "Locked, so its contents cannot be listed"
|
||||
: (collectionBlock.modelData.items ?? []).length + " stored"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: collectionBlock.modelData.items ?? []
|
||||
|
||||
delegate: SettingRow {
|
||||
id: secretRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string itemPath: String(secretRow.modelData.path ?? "")
|
||||
readonly property bool confirming: root.confirmingPath === secretRow.itemPath
|
||||
|
||||
width: collectionBlock.width
|
||||
label: String(secretRow.modelData.label ?? "")
|
||||
// Attributes, never the value: what the secret is FOR is
|
||||
// the part that identifies it.
|
||||
detail: root.describe(secretRow.modelData)
|
||||
controlWidth: 200
|
||||
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: secretRow.confirming ? "Cancel" : "Copy"
|
||||
enabled: !Keyring.working
|
||||
onClicked: {
|
||||
if (secretRow.confirming) {
|
||||
root.confirmingPath = "";
|
||||
return;
|
||||
}
|
||||
Keyring.copy(secretRow.itemPath);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
// Two presses, always. Forgetting a stored
|
||||
// password cannot be undone, and the button
|
||||
// sits next to Copy where a misclick is cheap.
|
||||
text: secretRow.confirming ? "Forget it" : "Forget"
|
||||
tone: secretRow.confirming ? "danger" : "normal"
|
||||
enabled: !Keyring.working
|
||||
onClicked: {
|
||||
if (!secretRow.confirming) {
|
||||
root.confirmingPath = secretRow.itemPath;
|
||||
return;
|
||||
}
|
||||
root.confirmingPath = "";
|
||||
Keyring.forget(secretRow.itemPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0
|
||||
label: "Nothing stored yet"
|
||||
detail: "Applications that save a password or token will appear here."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Camera & microphone"
|
||||
subtitle: PrivacyState.anyActive
|
||||
@@ -128,6 +283,70 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application permissions"
|
||||
// The limit is stated here rather than left to be discovered. Saying
|
||||
// "your camera is protected" when a native binary can open it
|
||||
// directly would be a claim this page cannot back up.
|
||||
subtitle: Permissions.available
|
||||
? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly."
|
||||
: (Permissions.lastError || "The desktop portal's permission store is not running.")
|
||||
|
||||
Repeater {
|
||||
model: Permissions.devices
|
||||
|
||||
delegate: Column {
|
||||
id: deviceBlock
|
||||
|
||||
required property var modelData
|
||||
readonly property var applications: deviceBlock.modelData.applications ?? []
|
||||
|
||||
width: parent.width
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: deviceBlock.applications.length === 0
|
||||
label: String(deviceBlock.modelData.label ?? "")
|
||||
detail: "No application has asked for this."
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: deviceBlock.applications
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.app ?? "")
|
||||
detail: String(deviceBlock.modelData.label ?? "")
|
||||
controlWidth: 150
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Ask again"
|
||||
enabled: !Permissions.busy
|
||||
onClicked: Permissions.forget(
|
||||
String(deviceBlock.modelData.id), String(modelData.app))
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modelData.allowed === true
|
||||
onToggled: value => Permissions.setAllowed(
|
||||
String(deviceBlock.modelData.id), String(modelData.app), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Device security"
|
||||
subtitle: DeviceSecurity.attentionCount === 0
|
||||
|
||||
@@ -8,7 +8,7 @@ such rather than half-reimplemented.
|
||||
|
||||
The stable internal `services` route renders **System Health**. It is reachable
|
||||
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
||||
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
|
||||
indicator, and Vicinae's **Check System Health** command. Healthy scans
|
||||
reserve no bar space and produce no notification.
|
||||
|
||||
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||
|
||||
@@ -29,13 +29,15 @@ SettingsPage {
|
||||
title: "Language"
|
||||
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||
|
||||
TextRow {
|
||||
PickerRow {
|
||||
id: languagePicker
|
||||
|
||||
label: "Current language"
|
||||
detail: SystemLocale.pendingRestart
|
||||
? "Chosen, but not in use until you sign out and back in"
|
||||
: "Used by programs that ask the system what language to speak"
|
||||
value: SystemLocale.currentLabel || "Reading…"
|
||||
}
|
||||
divider: false
|
||||
|
||||
SearchPicker {
|
||||
width: parent.width
|
||||
@@ -43,7 +45,11 @@ SettingsPage {
|
||||
current: SystemLocale.current
|
||||
placeholder: "Search languages and regions"
|
||||
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
||||
onPicked: value => SystemLocale.set(value)
|
||||
onPicked: value => {
|
||||
SystemLocale.set(value);
|
||||
languagePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// A segmented choice that is NOT schema-bound.
|
||||
//
|
||||
// ChoiceRow reads its options from the preference schema. This one is handed
|
||||
// them, for choices that describe system state rather than a Panama setting.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
// [{ value, label }]
|
||||
property var options: []
|
||||
property var value: null
|
||||
property bool enabled: true
|
||||
|
||||
signal selected(value: var)
|
||||
|
||||
controlWidth: Math.max(150, root.options.length * 92)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
delegate: Rectangle {
|
||||
id: segment
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: root.value === segment.modelData.value
|
||||
|
||||
width: Math.max(84, segmentLabel.implicitWidth + 22)
|
||||
height: 30
|
||||
radius: 9
|
||||
color: segment.current
|
||||
? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, segmentMouse.containsMouse ? 0.12 : 0.06)
|
||||
border.width: 1
|
||||
border.color: segment.current
|
||||
? Theme.alpha(Theme.accent, 0.5)
|
||||
: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: segmentLabel
|
||||
anchors.centerIn: parent
|
||||
text: String(segment.modelData.label ?? "")
|
||||
color: segment.current ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: segment.current ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: segmentMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: root.enabled && !segment.current
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.selected(segment.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
// "normal", "accent", or "danger". Danger is for an action that cannot be
|
||||
// undone -- it never initiates one on a single press, it marks the press
|
||||
// that confirms it, so the confirming button cannot be mistaken for the
|
||||
// Cancel sitting next to it.
|
||||
property string tone: "normal"
|
||||
property bool enabled: true
|
||||
signal clicked
|
||||
@@ -16,16 +20,20 @@ Rectangle {
|
||||
color: {
|
||||
if (tone === "accent")
|
||||
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
|
||||
if (tone === "danger")
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.danger, 0.22) : Theme.alpha(Theme.danger, 0.12);
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075);
|
||||
}
|
||||
border.width: tone === "accent" ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
border.color: tone === "danger" ? Theme.alpha(Theme.danger, 0.45) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.tone === "accent" ? Theme.bgDark : Theme.fg
|
||||
color: root.tone === "accent"
|
||||
? Theme.bgDark
|
||||
: (root.tone === "danger" ? Theme.danger : Theme.fg)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
|
||||
@@ -9,6 +9,13 @@ Rectangle {
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
|
||||
// An optional themed icon beside the title, for cards that represent a
|
||||
// specific thing rather than a topic -- an online account is one of four
|
||||
// that otherwise differ only by a line of small grey text. Empty by
|
||||
// default, and when empty the header lays out exactly as it did before.
|
||||
property string icon: ""
|
||||
property string iconFallback: "dialog-information-symbolic"
|
||||
|
||||
width: parent ? parent.width : 680
|
||||
implicitHeight: body.implicitHeight + 30
|
||||
radius: Theme.cardRadius + 2
|
||||
@@ -32,15 +39,38 @@ Rectangle {
|
||||
anchors.margins: 15
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Row {
|
||||
width: parent.width
|
||||
visible: root.title !== ""
|
||||
spacing: root.icon === "" ? 0 : 10
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.icon !== ""
|
||||
width: root.icon === "" ? 0 : 22
|
||||
icon: root.icon
|
||||
iconFallback: root.iconFallback
|
||||
size: 22
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (root.icon === "" ? 0 : 32)
|
||||
text: root.title
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
bottomPadding: root.subtitle === "" ? 10 : 3
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// The title's bottom padding lived on the title Text; it moves here so
|
||||
// the Row above can centre an icon against it without the padding
|
||||
// pushing the icon off-centre.
|
||||
Item {
|
||||
width: 1
|
||||
height: root.title === "" ? 0 : (root.subtitle === "" ? 10 : 3)
|
||||
}
|
||||
|
||||
Text {
|
||||
|
||||
@@ -109,6 +109,7 @@ Rectangle {
|
||||
case "home-phone": return homePhonePage;
|
||||
case "desktop": return desktopPage;
|
||||
case "sound": return soundPage;
|
||||
case "gaming": return gamingPage;
|
||||
case "notifications": return notificationsPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
case "shortcuts": return shortcutsPage;
|
||||
@@ -120,6 +121,15 @@ Rectangle {
|
||||
case "power": return powerPage;
|
||||
case "datetime": return dateTimePage;
|
||||
case "applications": return applicationsPage;
|
||||
case "storage": return storagePage;
|
||||
case "snapshots": return snapshotsPage;
|
||||
case "updates": return updatesPage;
|
||||
case "users": return usersPage;
|
||||
case "sharing": return sharingPage;
|
||||
case "firewall": return firewallPage;
|
||||
case "printers": return printersPage;
|
||||
case "containers": return containersPage;
|
||||
case "ssh-keys": return sshKeysPage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -158,6 +168,15 @@ Rectangle {
|
||||
|
||||
Component { id: homePage; HomePage {} }
|
||||
Component { id: applicationsPage; ApplicationsPage {} }
|
||||
Component { id: storagePage; StoragePage {} }
|
||||
Component { id: snapshotsPage; SnapshotsPage {} }
|
||||
Component { id: updatesPage; UpdatesPage {} }
|
||||
Component { id: usersPage; UsersPage {} }
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: firewallPage; FirewallPage {} }
|
||||
Component { id: containersPage; ContainersPage {} }
|
||||
Component { id: sshKeysPage; SshKeysPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||
Component { id: powerPage; PowerPage {} }
|
||||
Component { id: dateTimePage; DateTimePage {} }
|
||||
@@ -167,6 +186,7 @@ Rectangle {
|
||||
Component { id: homePhonePage; HomePhonePage {} }
|
||||
Component { id: desktopPage; DesktopPage {} }
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: gamingPage; GamingPage {} }
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
|
||||
@@ -26,9 +26,15 @@ Rectangle {
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
||||
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
|
||||
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
{ page: "gaming", label: "Gaming", icon: "\u{F0297}" },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||
@@ -40,6 +46,10 @@ Rectangle {
|
||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
|
||||
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
||||
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
|
||||
{ page: "users", label: "Users", icon: "\u{F0004}" },
|
||||
{ page: "services", label: "System Health", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Page-level tabs, for a page that is genuinely several subjects.
|
||||
//
|
||||
// SettingsTabs {
|
||||
// tabs: [{ value: "theme", label: "Theme" }, …]
|
||||
// current: root.tab
|
||||
// onSelected: value => root.tab = value
|
||||
// }
|
||||
//
|
||||
// Only for pages long enough that scrolling hides the control someone came for.
|
||||
// Appearance was six cards deep, so light and dark -- the thing reached most --
|
||||
// sat below a wallpaper grid and an entire lock screen. Tabs are not a way to
|
||||
// make a short page look organised; they are for when the page is long enough
|
||||
// that the order stops being a suggestion and starts being a burial.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// [{ value, label }]
|
||||
property var tabs: []
|
||||
property string current: ""
|
||||
|
||||
signal selected(string value)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 40
|
||||
|
||||
Row {
|
||||
id: strip
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: root.tabs
|
||||
|
||||
delegate: Item {
|
||||
id: tab
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool active: String(tab.modelData.value) === root.current
|
||||
|
||||
implicitWidth: caption.implicitWidth + 30
|
||||
implicitHeight: 38
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.centerIn: parent
|
||||
text: String(tab.modelData.label ?? "")
|
||||
color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: tab.active ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 2
|
||||
radius: 1
|
||||
visible: tab.active
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.selected(String(tab.modelData.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
z: -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// What this machine offers to other machines on the network.
|
||||
//
|
||||
// Every row states what is true right now, including "the software for this is
|
||||
// not installed" -- which is the honest answer for file sharing here, and is
|
||||
// what the panel this replaces hides behind a switch that silently does
|
||||
// nothing.
|
||||
//
|
||||
// Turning remote login on changes the whole system and prompts. Remote desktop
|
||||
// is a user service and does not.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "sharing"
|
||||
title: "Sharing"
|
||||
lede: "What this machine offers to other machines on the network."
|
||||
|
||||
Component.onCompleted: Sharing.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.lastError !== ""
|
||||
label: "Sharing needs attention"
|
||||
detail: Sharing.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "This machine"
|
||||
subtitle: "The name other machines see."
|
||||
|
||||
TextFieldRow {
|
||||
label: "Network name"
|
||||
detail: "Used for ssh and for anything else that finds this machine by name"
|
||||
text: Sharing.hostname
|
||||
placeholder: "desktop"
|
||||
enabled: !Sharing.busy
|
||||
divider: false
|
||||
onAccepted: value => Sharing.setHostname(value)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Remote login"
|
||||
subtitle: "Sign in to a terminal on this machine over SSH."
|
||||
|
||||
SwitchRow {
|
||||
label: "Allow remote login"
|
||||
detail: Sharing.remoteLogin?.installed === true
|
||||
? (Sharing.remoteLoginOn
|
||||
? "Running, and starts automatically at boot"
|
||||
: "Not running")
|
||||
: "OpenSSH server is not installed"
|
||||
checked: Sharing.remoteLoginOn
|
||||
enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true
|
||||
onToggled: value => Sharing.setRemoteLogin(value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn
|
||||
label: "Connect with"
|
||||
detail: "From another machine on your network"
|
||||
value: "ssh " + Sharing.networkName
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0
|
||||
label: "Nobody is signed in"
|
||||
detail: "Remote login is on, and no one is connected from another machine."
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Sharing.remoteSessions
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "")
|
||||
detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "")
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn
|
||||
label: "Port"
|
||||
detail: "Where the SSH server is listening"
|
||||
value: String(Sharing.remoteLogin?.port ?? "22")
|
||||
}
|
||||
|
||||
// Reported from the configuration rather than assumed. Saying "keys
|
||||
// only" on a machine that actually accepts passwords would be a
|
||||
// security claim this page cannot back up.
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn
|
||||
label: "Password sign-in"
|
||||
detail: Sharing.passwordLoginSummary()
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Remote desktop"
|
||||
subtitle: "See and control this desktop from another machine."
|
||||
|
||||
SwitchRow {
|
||||
label: "Allow remote desktop"
|
||||
detail: Sharing.remoteDesktop?.available === true
|
||||
? (Sharing.remoteDesktopOn
|
||||
? "Running for your session"
|
||||
: (Sharing.remoteDesktop?.hasCredentials === true
|
||||
? "Not running"
|
||||
: "Set a username and password before turning this on"))
|
||||
: "Remote desktop support is not installed"
|
||||
checked: Sharing.remoteDesktopOn
|
||||
enabled: !Sharing.busy
|
||||
&& Sharing.remoteDesktop?.available === true
|
||||
&& Sharing.remoteDesktop?.hasCredentials === true
|
||||
onToggled: value => Sharing.setRemoteDesktop(value)
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
visible: Sharing.remoteDesktop?.available === true
|
||||
label: "Port"
|
||||
detail: "The RDP port other machines connect to"
|
||||
text: String(Sharing.remoteDesktop?.port ?? "")
|
||||
placeholder: "3389"
|
||||
enabled: !Sharing.busy
|
||||
onAccepted: value => Sharing.setRdpPort(value)
|
||||
}
|
||||
|
||||
SwitchRow {
|
||||
visible: Sharing.remoteDesktop?.available === true
|
||||
label: "View only"
|
||||
detail: "Let someone watch this desktop without controlling the pointer or keyboard"
|
||||
checked: Sharing.remoteDesktop?.viewOnly === true
|
||||
enabled: !Sharing.busy
|
||||
onToggled: value => Sharing.setRdpViewOnly(value)
|
||||
}
|
||||
|
||||
// The password is typed into gnome-remote-desktop's own tool in a
|
||||
// terminal, never into this page. grdctl prompts for it on a terminal
|
||||
// and crashes without one, and passing it as an argument would publish
|
||||
// it through /proc to every process on this machine.
|
||||
ActionRow {
|
||||
visible: Sharing.remoteDesktop?.available === true
|
||||
label: "Credentials"
|
||||
detail: Sharing.remoteDesktop?.hasCredentials === true
|
||||
? "Stored in the login keyring · setting new ones opens a terminal to type into"
|
||||
: "None stored yet · remote desktop cannot be turned on without them"
|
||||
action: "Set…"
|
||||
enabled: !Sharing.busy
|
||||
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Sharing.remoteDesktop?.available === true
|
||||
&& Sharing.remoteDesktop?.hasCredentials === true
|
||||
label: "Forget the stored credentials"
|
||||
detail: "Remote desktop cannot be turned on again until new ones are set"
|
||||
action: "Clear"
|
||||
enabled: !Sharing.busy
|
||||
divider: false
|
||||
onTriggered: Sharing.clearRdpCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "File and media sharing"
|
||||
subtitle: "Sharing folders and media needs software this machine does not necessarily have."
|
||||
|
||||
TextRow {
|
||||
label: "Share folders on the network"
|
||||
detail: Sharing.fileSharing?.installed === true
|
||||
? "Samba is installed"
|
||||
: "Needs Samba, which is not installed. Settings does not install software."
|
||||
value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed"
|
||||
}
|
||||
|
||||
SwitchRow {
|
||||
visible: Sharing.mediaSharing?.installed === true
|
||||
label: "Share music and video to devices"
|
||||
// Said before it happens, not after: this advertises on the network
|
||||
// to anything that speaks DLNA, with no password in front of it.
|
||||
detail: Sharing.mediaSharing?.active === true
|
||||
? "Rygel is serving your media to devices on the network"
|
||||
: "Publishes your media folders to every device on the network. No password is asked for."
|
||||
checked: Sharing.mediaSharing?.active === true
|
||||
enabled: !Sharing.busy
|
||||
divider: false
|
||||
onToggled: value => Sharing.setMediaSharing(value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.mediaSharing?.installed !== true
|
||||
label: "Share music and video to devices"
|
||||
detail: "Needs Rygel, which is not installed."
|
||||
value: "Not installed"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ SettingsPage {
|
||||
// when nothing is being captured. Held here rather than per row so that
|
||||
// starting a new capture cancels any other.
|
||||
property string capturingChord: ""
|
||||
|
||||
// The action already holding a chord somebody just pressed, and the chord
|
||||
// itself. Held while the capture stays open so the message can name both.
|
||||
property string conflict: ""
|
||||
property string conflictChord: ""
|
||||
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||
|
||||
function xkbOptions(): var {
|
||||
@@ -170,11 +175,28 @@ SettingsPage {
|
||||
height: 30
|
||||
visible: bindRow.capturing
|
||||
focus: bindRow.capturing
|
||||
message: root.conflict === ""
|
||||
? ""
|
||||
: root.conflictChord + " is already " + root.conflict
|
||||
// A chord already in use is reported rather than
|
||||
// taken. Two actions on one chord means whichever
|
||||
// Hyprland happens to read last wins, which is not
|
||||
// a thing to discover later by pressing it.
|
||||
onCaptured: chord => {
|
||||
const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord);
|
||||
if (taken !== "") {
|
||||
root.conflict = taken;
|
||||
root.conflictChord = chord;
|
||||
return;
|
||||
}
|
||||
root.conflict = "";
|
||||
Keybinds.rebind(bindRow.modelData.luaChord, chord);
|
||||
root.capturingChord = "";
|
||||
}
|
||||
onCanceled: root.capturingChord = ""
|
||||
onCanceled: {
|
||||
root.conflict = "";
|
||||
root.capturingChord = "";
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// Snapshots: what is protected, and how to get something back.
|
||||
//
|
||||
// Per volume, because on this machine the news was that one volume was covered
|
||||
// and the important one was not -- six hundred snapshots of the system, none of
|
||||
// anyone's documents. A timeline that opened on all those snapshots would have
|
||||
// buried that.
|
||||
//
|
||||
// Inside a volume, the timeline is the Time Machine view: points in time,
|
||||
// newest first, each one openable as a folder tree you can take a file out of.
|
||||
//
|
||||
// Rollback is deliberately absent. snapper's rollback changes the btrfs default
|
||||
// subvolume, and this system's fstab pins subvol= explicitly, which overrides
|
||||
// it -- so it would report success and change nothing after a reboot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "snapshots"
|
||||
title: "Snapshots"
|
||||
lede: "Points in time you can go back to, taken automatically for each volume."
|
||||
|
||||
// Points in time shown without asking. The page used to show none: the
|
||||
// timeline and its Delete buttons sat behind a row labelled "Browse…", a
|
||||
// word that promises a file browser, so a page about points in time
|
||||
// appeared to list only how many there were.
|
||||
readonly property int previewCount: 3
|
||||
|
||||
property string openConfig: ""
|
||||
property string confirmingDelete: ""
|
||||
property string confirmingRestore: ""
|
||||
|
||||
readonly property bool browsingOpen: Snapshots.browsingConfig !== ""
|
||||
|
||||
Component.onCompleted: Snapshots.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Snapshots.lastError !== ""
|
||||
label: "Snapshots need attention"
|
||||
detail: Snapshots.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// What just happened to the file that was already there.
|
||||
TextRow {
|
||||
visible: Snapshots.lastRestore !== null
|
||||
label: "Restored"
|
||||
detail: Snapshots.lastRestore
|
||||
? String(Snapshots.lastRestore.restored ?? "")
|
||||
+ (String(Snapshots.lastRestore.keptAs ?? "") !== ""
|
||||
? " — the version that was there was kept as "
|
||||
+ String(Snapshots.lastRestore.keptAs).split("/").pop()
|
||||
: "")
|
||||
: ""
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Anything unprotected is the headline ─────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Snapshots.unprotected.length > 0
|
||||
title: "Not protected"
|
||||
subtitle: "These volumes have no snapshot configuration, so nothing on them can be recovered."
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.unprotected
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.path ?? "")
|
||||
detail: "btrfs subvolume " + String(modelData.subvolume ?? "")
|
||||
+ " · needs a configuration, which takes a password once"
|
||||
value: "Unprotected"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── One card per volume ──────────────────────────────────────────────────
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.configs
|
||||
|
||||
delegate: SettingsCard {
|
||||
id: volumeCard
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string configName: String(volumeCard.modelData.name ?? "")
|
||||
readonly property var snapshots: volumeCard.modelData.snapshots ?? []
|
||||
readonly property bool open: root.openConfig === volumeCard.configName
|
||||
readonly property int shownCount: volumeCard.open
|
||||
? volumeCard.snapshots.length
|
||||
: Math.min(root.previewCount, volumeCard.snapshots.length)
|
||||
|
||||
title: Snapshots.labelFor(volumeCard.modelData)
|
||||
subtitle: String(volumeCard.modelData.subvolume ?? "")
|
||||
|
||||
SwitchRow {
|
||||
label: "Take snapshots automatically"
|
||||
detail: volumeCard.modelData.timelineEnabled
|
||||
? Snapshots.describe(volumeCard.modelData)
|
||||
: "Nothing is being taken for this volume"
|
||||
checked: volumeCard.modelData.timelineEnabled === true
|
||||
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
||||
onToggled: value => Snapshots.setTimeline(volumeCard.configName, value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Keep"
|
||||
detail: "Older points are removed automatically once these counts are exceeded"
|
||||
value: Snapshots.retentionSummary(volumeCard.modelData)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Take one now"
|
||||
detail: "Kept until you remove it, unlike the automatic ones"
|
||||
action: "Take snapshot"
|
||||
enabled: !Snapshots.busy && volumeCard.modelData.readable === true
|
||||
onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings")
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: volumeCard.snapshots.length === 0
|
||||
label: "No points in time yet"
|
||||
detail: "One is taken automatically on the schedule above."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── The timeline ─────────────────────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: !root.browsingOpen
|
||||
|
||||
Repeater {
|
||||
model: volumeCard.open
|
||||
? volumeCard.snapshots
|
||||
: volumeCard.snapshots.slice(0, root.previewCount)
|
||||
|
||||
delegate: SettingRow {
|
||||
id: pointRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number
|
||||
readonly property bool confirming: root.confirmingDelete === pointRow.token
|
||||
|
||||
width: parent.width
|
||||
// A kept snapshot is one the timeline will not remove,
|
||||
// which is the distinction that matters when choosing
|
||||
// what to rely on later.
|
||||
icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}"
|
||||
label: String(pointRow.modelData.date ?? "")
|
||||
detail: String(pointRow.modelData.description ?? "")
|
||||
+ " · #" + pointRow.modelData.number
|
||||
+ (pointRow.modelData.kept ? " · kept" : "")
|
||||
controlWidth: 250
|
||||
divider: pointRow.index < volumeCard.shownCount - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(volumeCard.configName,
|
||||
Number(pointRow.modelData.number), "")
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: pointRow.confirming ? "Keep" : "Delete"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: root.confirmingDelete =
|
||||
pointRow.confirming ? "" : pointRow.token
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: pointRow.confirming
|
||||
text: "Delete it"
|
||||
tone: "danger"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: {
|
||||
root.confirmingDelete = "";
|
||||
Snapshots.remove(volumeCard.configName,
|
||||
Number(pointRow.modelData.number));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: volumeCard.snapshots.length > root.previewCount
|
||||
activatable: true
|
||||
divider: false
|
||||
label: volumeCard.open
|
||||
? "Showing all " + volumeCard.snapshots.length + " points in time"
|
||||
: (volumeCard.snapshots.length - root.previewCount)
|
||||
+ " older point"
|
||||
+ (volumeCard.snapshots.length - root.previewCount === 1 ? "" : "s")
|
||||
+ " in time"
|
||||
detail: volumeCard.open
|
||||
? ""
|
||||
: "Oldest is " + String(volumeCard.snapshots[volumeCard.snapshots.length - 1]?.date ?? "")
|
||||
controlWidth: 110
|
||||
onActivated: {
|
||||
root.confirmingDelete = "";
|
||||
root.openConfig = volumeCard.open ? "" : volumeCard.configName;
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside one point in time ─────────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: volumeCard.open && root.browsingOpen
|
||||
&& Snapshots.browsingConfig === volumeCard.configName
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: Snapshots.browsingPath === ""
|
||||
? "Snapshot #" + Snapshots.browsingSnapshot
|
||||
: "…/" + Snapshots.browsingPath
|
||||
detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now"
|
||||
action: "Back"
|
||||
enabled: !Snapshots.browsing
|
||||
onTriggered: Snapshots.browseUp()
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Snapshots.browsing
|
||||
label: "Reading the snapshot…"
|
||||
detail: "Listing a folder from a point in time"
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Snapshots.browsing ? [] : Snapshots.browseEntries
|
||||
|
||||
delegate: SettingRow {
|
||||
id: entryRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string entryPath: Snapshots.browsingPath === ""
|
||||
? String(entryRow.modelData.name)
|
||||
: Snapshots.browsingPath + "/" + String(entryRow.modelData.name)
|
||||
readonly property bool confirming: root.confirmingRestore === entryRow.entryPath
|
||||
|
||||
width: parent.width
|
||||
icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}"
|
||||
label: String(entryRow.modelData.name ?? "")
|
||||
detail: entryRow.modelData.directory
|
||||
? "Folder"
|
||||
: Snapshots.formatBytes(entryRow.modelData.bytes ?? 0)
|
||||
controlWidth: 230
|
||||
divider: entryRow.index < Snapshots.browseEntries.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
visible: entryRow.modelData.directory === true
|
||||
text: "Open"
|
||||
enabled: !Snapshots.browsing
|
||||
onClicked: Snapshots.browse(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: entryRow.confirming ? "Cancel" : "Restore"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: root.confirmingRestore =
|
||||
entryRow.confirming ? "" : entryRow.entryPath
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: entryRow.confirming
|
||||
text: "Put it back"
|
||||
tone: "danger"
|
||||
enabled: !Snapshots.busy
|
||||
onClicked: {
|
||||
root.confirmingRestore = "";
|
||||
Snapshots.restore(Snapshots.browsingConfig,
|
||||
Snapshots.browsingSnapshot,
|
||||
entryRow.entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseTruncated
|
||||
label: "Only the first entries are shown"
|
||||
detail: "This folder holds more than this list can usefully show"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0
|
||||
label: "Nothing here"
|
||||
detail: "This folder was empty at that point in time"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── What it costs ────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Space"
|
||||
subtitle: "A snapshot shares its data with the live filesystem and grows only as files change afterwards."
|
||||
|
||||
TextRow {
|
||||
label: "Free space"
|
||||
detail: "Snapshots are removed oldest-first when this runs low"
|
||||
value: Snapshots.formatBytes(Snapshots.space?.freeBytes ?? 0)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Automatic snapshots"
|
||||
detail: Snapshots.timelineRunning
|
||||
? "The hourly timer is running"
|
||||
: "The hourly timer is not running, so nothing new is being taken"
|
||||
value: Snapshots.timelineRunning ? "Running" : "Stopped"
|
||||
}
|
||||
|
||||
// Honest about what cannot be measured: per-snapshot size needs btrfs
|
||||
// quota groups, which cost performance on every write. Reporting a
|
||||
// made-up number would be worse than saying so.
|
||||
TextRow {
|
||||
label: "Space used by snapshots"
|
||||
detail: "Measuring this per snapshot needs btrfs quotas, which slow down every write. Free space above is the number that matters."
|
||||
value: "Not measured"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,19 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
// Outputs only. Testing an input would mean recording and playing
|
||||
// it back, which is a different thing than this button implies.
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.output
|
||||
text: "Test"
|
||||
onClicked: SoundTest.play(root.node)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -98,6 +111,7 @@ Rectangle {
|
||||
onClicked: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// SSH keys, and what this machine can reach with them.
|
||||
//
|
||||
// Read-heavy on purpose. The genuinely useful things a person wants from a page
|
||||
// like this are "which key is this", "is the agent holding it", "copy the public
|
||||
// half", and "forget a host whose key changed" -- and all four are safe. What is
|
||||
// not here is generating a key, because a passphrase cannot be collected and
|
||||
// handed to ssh-keygen without putting it somewhere it should not be, and a
|
||||
// page offering to make an unencrypted key instead would be a downgrade
|
||||
// disguised as a feature.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "ssh-keys"
|
||||
title: "SSH Keys"
|
||||
lede: "The keys this machine signs in with, and the hosts it has met."
|
||||
|
||||
property string confirmingForget: ""
|
||||
|
||||
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: SshKeys.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.scanned && !SshKeys.available
|
||||
label: "No SSH directory"
|
||||
detail: "Nothing has created ~/.ssh on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Keys readable by other people ───────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.overexposed.length > 0
|
||||
title: SshKeys.overexposed.length === 1
|
||||
? "A private key is readable by other accounts"
|
||||
: "Private keys are readable by other accounts"
|
||||
subtitle: "ssh refuses to use a key with these permissions, so it will never be offered."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.overexposed
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The agent ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Agent"
|
||||
subtitle: SshKeys.agent?.available === true
|
||||
? (SshKeys.agent?.kind === "gnome-keyring"
|
||||
? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh."
|
||||
: "An SSH agent is holding your keys for this session.")
|
||||
: String(SshKeys.agent?.detail ?? "No SSH agent is running.")
|
||||
|
||||
TextRow {
|
||||
label: "Holding"
|
||||
detail: SshKeys.agent?.available === true
|
||||
? String(SshKeys.agent?.socket ?? "")
|
||||
: "Keys will be asked for on every connection"
|
||||
value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s")
|
||||
divider: SshKeys.agent?.kind === "gnome-keyring"
|
||||
}
|
||||
|
||||
// Said plainly because it is measurable and surprising: ssh-add -d
|
||||
// reports success against this agent and the key is still offered a
|
||||
// moment later, because it is read back off disk.
|
||||
TextRow {
|
||||
visible: SshKeys.agent?.kind === "gnome-keyring"
|
||||
label: "Removing a key from this agent does not stick"
|
||||
detail: "It lists every key in ~/.ssh, so one removed comes straight back. Move the file out of ~/.ssh to stop it being offered."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: SshKeys.keys.length === 1 ? "Your key" : "Your keys"
|
||||
subtitle: SshKeys.keys.length === 0
|
||||
? "No keys in " + SshKeys.directory
|
||||
: "Public halves are safe to share; the private half never leaves this machine."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.keys
|
||||
|
||||
delegate: SettingRow {
|
||||
id: keyRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(keyRow.modelData.name ?? "")
|
||||
detail: String(keyRow.modelData.type ?? "") + " · "
|
||||
+ String(keyRow.modelData.fingerprint ?? "")
|
||||
+ (String(keyRow.modelData.comment ?? "") !== ""
|
||||
? " · " + keyRow.modelData.comment : "")
|
||||
+ (keyRow.modelData.encrypted === true
|
||||
? " · passphrase protected"
|
||||
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
|
||||
divider: keyRow.index < SshKeys.keys.length - 1
|
||||
controlWidth: 220
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded === true
|
||||
text: "In the agent"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded !== true
|
||||
&& SshKeys.agent?.available === true
|
||||
text: "Add to agent"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: SshKeys.addToAgent(String(keyRow.modelData.path))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Copy public key"
|
||||
onClicked: SshKeys.copyPublicKey(String(keyRow.modelData.publicPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Known hosts ─────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.hosts.length > 0
|
||||
title: "Known hosts"
|
||||
subtitle: "Machines this one has connected to before. Forgetting one means being asked to trust it again."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.hosts
|
||||
|
||||
delegate: SettingRow {
|
||||
id: hostRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool confirming:
|
||||
root.confirmingForget === String(hostRow.modelData.host ?? "")
|
||||
|
||||
label: hostRow.modelData.hashed === true
|
||||
? hostRow.modelData.count + " hashed entries"
|
||||
: String(hostRow.modelData.host ?? "")
|
||||
detail: hostRow.modelData.hashed === true
|
||||
? "Hashed on purpose, so the names cannot be read from the file"
|
||||
: (hostRow.confirming
|
||||
? "You will be asked to trust this host the next time you connect."
|
||||
: (hostRow.modelData.types ?? []).join(", "))
|
||||
divider: hostRow.index < SshKeys.hosts.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: hostRow.confirming
|
||||
text: "Forget it"
|
||||
tone: "danger"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: {
|
||||
root.confirmingForget = "";
|
||||
SshKeys.forgetHost(String(hostRow.modelData.host));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: hostRow.modelData.hashed !== true
|
||||
text: hostRow.confirming ? "Keep" : "Forget…"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: root.confirmingForget = hostRow.confirming
|
||||
? "" : String(hostRow.modelData.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Storage: what is using the drive, then what the drive is.
|
||||
//
|
||||
// The page is one scroll with a rule behind it: everything above the divider
|
||||
// answers "how much space do I have and what took it", everything below answers
|
||||
// "what is this device and is it healthy". A card that answers neither does not
|
||||
// belong here.
|
||||
//
|
||||
// Measuring what is using the space is expensive -- a Steam library alone can
|
||||
// be a terabyte -- so it happens on request rather than on open. The page says
|
||||
// so plainly instead of showing an empty list that reads as "nothing here".
|
||||
//
|
||||
// Partitioning and formatting are deliberately absent; GNOME Disks is one row
|
||||
// away for that.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "storage"
|
||||
title: "Storage"
|
||||
lede: "How much space is left, what is using it, and whether the drive is healthy."
|
||||
|
||||
readonly property var rootFs: Disks.rootFilesystem
|
||||
readonly property var drive: Disks.primaryDrive
|
||||
|
||||
// The measured folders, as a share of the largest one, so the bars compare
|
||||
// against each other rather than against a total they do not sum to.
|
||||
readonly property real largestFolder: {
|
||||
let largest = 0;
|
||||
for (const folder of Disks.folders)
|
||||
largest = Math.max(largest, Number(folder.bytes ?? 0));
|
||||
return largest;
|
||||
}
|
||||
|
||||
readonly property var removableDrives: Disks.drives.filter(entry => entry.removable)
|
||||
|
||||
Component.onCompleted: Disks.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Disks.lastError !== ""
|
||||
label: "Storage needs attention"
|
||||
detail: Disks.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Space ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Free space"
|
||||
subtitle: root.rootFs && (root.rootFs.mountpoints ?? []).length > 1
|
||||
? "One filesystem is mounted at " + Disks.mountLabel(root.rootFs)
|
||||
+ ", so they share the same space."
|
||||
: "The filesystem this session runs from."
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: root.rootFs ? Disks.formatBytes(root.rootFs.availBytes) + " free" : "Reading…"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.rootFs !== null
|
||||
text: root.rootFs
|
||||
? "of " + Disks.formatBytes(root.rootFs.sizeBytes)
|
||||
+ " · " + String(root.rootFs.fstype ?? "")
|
||||
: ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Rectangle {
|
||||
width: Math.max(parent.width * Disks.usedFraction(root.rootFs), parent.height)
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
// Color is the only warning this bar gives, so it changes
|
||||
// at the point where free space starts to be a problem
|
||||
// rather than at a round number.
|
||||
color: Disks.usedFraction(root.rootFs) > 0.92
|
||||
? Theme.danger
|
||||
: (Disks.usedFraction(root.rootFs) > 0.8 ? Theme.warn : Theme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.rootFs
|
||||
? Disks.formatBytes(root.rootFs.usedBytes) + " used · "
|
||||
+ Math.round(Disks.usedFraction(root.rootFs) * 100) + "%"
|
||||
: ""
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "What is using it"
|
||||
subtitle: Disks.foldersMeasured
|
||||
? "Measured by walking each folder."
|
||||
: "Measuring means walking every file, so it is not done automatically."
|
||||
|
||||
ActionRow {
|
||||
visible: !Disks.foldersMeasured || Disks.scanning
|
||||
label: Disks.scanning ? "Measuring…" : "Measure folders"
|
||||
detail: Disks.scanning
|
||||
? "Walking the largest folders. This can take a minute."
|
||||
: "Walk the usual suspects and report what is biggest."
|
||||
action: "Measure"
|
||||
enabled: !Disks.scanning
|
||||
divider: Disks.foldersMeasured
|
||||
onTriggered: Disks.scan()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Disks.foldersMeasured ? Disks.folders : []
|
||||
|
||||
delegate: Column {
|
||||
id: folderBlock
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
|
||||
TextRow {
|
||||
width: folderBlock.width
|
||||
label: String(folderBlock.modelData.label ?? "")
|
||||
detail: String(folderBlock.modelData.path ?? "")
|
||||
value: Disks.formatBytes(folderBlock.modelData.bytes ?? 0)
|
||||
divider: false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: folderBlock.width
|
||||
height: 4
|
||||
radius: 2
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// Relative to the LARGEST folder, not to the drive. At this
|
||||
// scale one folder holds almost everything, so bars drawn
|
||||
// against the total would leave every other row invisible.
|
||||
Rectangle {
|
||||
width: root.largestFolder > 0
|
||||
? Math.max(parent.width * (Number(folderBlock.modelData.bytes ?? 0) / root.largestFolder), 2)
|
||||
: 0
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Theme.accent
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Disks.foldersMeasured && Disks.folderScanTruncated
|
||||
label: "Some folders were not measured"
|
||||
detail: "The walk ran out of time before reaching them, so this list is incomplete."
|
||||
value: ""
|
||||
divider: Disks.containers !== null
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Disks.containers !== null
|
||||
&& Number(Disks.containers?.reclaimableBytes ?? 0) > 0
|
||||
label: "Unused container images"
|
||||
detail: Disks.containers
|
||||
? Disks.formatBytes(Disks.containers.reclaimableBytes)
|
||||
+ " of " + Disks.formatBytes(Disks.containers.totalBytes)
|
||||
+ " is not used by any container"
|
||||
: ""
|
||||
action: "Show"
|
||||
divider: false
|
||||
// Reclaiming is not offered here on purpose: pruning images can
|
||||
// destroy work that lives outside this desktop, and a settings pane
|
||||
// should not put that one click deep. This opens a terminal showing
|
||||
// what would be reclaimed, and leaves the decision there.
|
||||
onTriggered: Quickshell.execDetached(
|
||||
["kitty", "--hold", "-e", "podman", "system", "df"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── The device ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Drive"
|
||||
subtitle: root.drive
|
||||
? String(root.drive.model) + (root.drive.encrypted ? " · encrypted" : "")
|
||||
: "Reading…"
|
||||
|
||||
TextRow {
|
||||
visible: root.drive !== null
|
||||
label: "Health"
|
||||
detail: root.drive && root.drive.selfTest !== ""
|
||||
? "Last self-test: " + String(root.drive.selfTest)
|
||||
: "Reported by the drive itself"
|
||||
value: Disks.healthSummary(root.drive)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.drive !== null && root.drive.temperatureC !== null
|
||||
label: "Temperature"
|
||||
detail: "Measured by the drive controller"
|
||||
value: root.drive && root.drive.temperatureC !== null
|
||||
? String(root.drive.temperatureC) + " °C" : ""
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.drive !== null && root.drive.powerOnHours !== null
|
||||
label: "Powered on"
|
||||
detail: "Total hours this drive has been running"
|
||||
value: root.drive && root.drive.powerOnHours !== null
|
||||
? String(root.drive.powerOnHours) + " hours" : ""
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.drive !== null && root.drive.encrypted
|
||||
label: "Encryption"
|
||||
detail: "The filesystem is encrypted and unlocked at boot"
|
||||
value: "LUKS"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Partitioning and formatting"
|
||||
detail: "Deliberately not here. Disks is the tool that owns erasing a drive."
|
||||
action: "Open Disks"
|
||||
divider: false
|
||||
onTriggered: Quickshell.execDetached(["gapplication", "launch", "org.gnome.DiskUtility"])
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Filesystems"
|
||||
subtitle: "Every mounted filesystem, grouped by the device behind it."
|
||||
|
||||
Repeater {
|
||||
model: Disks.filesystems
|
||||
|
||||
delegate: TextRow {
|
||||
id: filesystemRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: Disks.mountLabel(filesystemRow.modelData)
|
||||
detail: String(filesystemRow.modelData.fstype ?? "")
|
||||
+ " on " + String(filesystemRow.modelData.device ?? "")
|
||||
value: Disks.formatBytes(filesystemRow.modelData.availBytes) + " free of "
|
||||
+ Disks.formatBytes(filesystemRow.modelData.sizeBytes)
|
||||
divider: filesystemRow.index < Disks.filesystems.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Named here because this is the page somebody opens when space is short,
|
||||
// and container images are usually the largest thing nobody remembers
|
||||
// having. Only shown when there is actually something to reclaim.
|
||||
SettingsCard {
|
||||
visible: Containers.available && Containers.reclaimable > 0
|
||||
title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable)
|
||||
|
||||
ActionRow {
|
||||
label: "Images and volumes nothing references"
|
||||
detail: Containers.unusedImages.length + " image"
|
||||
+ (Containers.unusedImages.length === 1 ? "" : "s") + " and "
|
||||
+ Containers.unusedVolumes.length + " volume"
|
||||
+ (Containers.unusedVolumes.length === 1 ? "" : "s")
|
||||
action: "Review"
|
||||
divider: false
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Removable drives"
|
||||
subtitle: "USB drives and memory cards."
|
||||
|
||||
Repeater {
|
||||
model: root.removableDrives
|
||||
|
||||
delegate: ActionRow {
|
||||
id: removableRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: String(removableRow.modelData.model ?? removableRow.modelData.name)
|
||||
detail: Disks.formatBytes(removableRow.modelData.sizeBytes)
|
||||
+ " · " + String(removableRow.modelData.path)
|
||||
action: removableRow.modelData.ejectable ? "Eject" : "Unmount"
|
||||
divider: false
|
||||
onTriggered: removableRow.modelData.ejectable
|
||||
? Disks.eject(String(removableRow.modelData.path))
|
||||
: Disks.unmount(String(removableRow.modelData.path))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.removableDrives.length === 0
|
||||
label: "None connected"
|
||||
detail: "Drives you plug in appear here, with a way to unmount them safely."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Disks.swap.length > 0
|
||||
title: "Swap"
|
||||
subtitle: "Compressed swap lives in memory, not on the drive."
|
||||
|
||||
Repeater {
|
||||
model: Disks.swap
|
||||
|
||||
delegate: TextRow {
|
||||
id: swapRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(swapRow.modelData.name ?? "")
|
||||
detail: String(swapRow.modelData.kind) === "zram"
|
||||
? "Compressed swap in RAM" : "Swap"
|
||||
value: Disks.formatBytes(swapRow.modelData.sizeBytes)
|
||||
divider: swapRow.index < Disks.swap.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// A toggle that is NOT schema-bound.
|
||||
//
|
||||
// ToggleRow reads and writes a preference key. Some switches govern system
|
||||
// state instead -- an account flag, a systemd unit -- which lives outside
|
||||
// Panama's settings file and is read back from the system that owns it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property bool checked: false
|
||||
property bool enabled: true
|
||||
|
||||
signal toggled(value: bool)
|
||||
|
||||
controlWidth: 54
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 44
|
||||
height: 26
|
||||
radius: height / 2
|
||||
color: root.checked ? Theme.accent : Theme.alpha(Theme.fg, 0.14)
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
Rectangle {
|
||||
x: root.checked ? parent.width - width - 3 : 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 20
|
||||
height: 20
|
||||
radius: height / 2
|
||||
color: root.checked ? Theme.bgDark : Theme.fg
|
||||
|
||||
// Short and non-repeating: this animates only when someone acts.
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled(!root.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// A row whose trailing control is a free-text field, NOT bound to a schema key.
|
||||
//
|
||||
// TextFieldRow {
|
||||
// label: "Full name"
|
||||
// text: Accounts.me.realName
|
||||
// onAccepted: value => Accounts.setRealName("gib", value)
|
||||
// }
|
||||
//
|
||||
// TextEntryRow is the schema-bound one, and is the right choice for anything
|
||||
// that is a Panama setting. This is for values that live in the system rather
|
||||
// than in settings.json -- an account's real name, the machine's hostname --
|
||||
// where the label, the validation, and the write all belong to whoever owns
|
||||
// that value.
|
||||
//
|
||||
// Committed on Enter or when focus leaves, never per keystroke: these drive
|
||||
// privileged calls that prompt, and prompting once per typed character would be
|
||||
// unusable.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string placeholder: ""
|
||||
property bool enabled: true
|
||||
|
||||
signal accepted(value: string)
|
||||
|
||||
controlWidth: 220
|
||||
|
||||
// An external change replaces what is shown, unless it would yank the field
|
||||
// out from under someone mid-edit.
|
||||
onTextChanged: if (!input.activeFocus) input.text = root.text
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.controlWidth
|
||||
height: 32
|
||||
radius: 9
|
||||
color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
|
||||
border.width: input.activeFocus ? 2 : 1
|
||||
border.color: input.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55)
|
||||
: Theme.alpha(Theme.fg, 0.1)
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 11
|
||||
anchors.rightMargin: 11
|
||||
enabled: root.enabled
|
||||
activeFocusOnTab: true
|
||||
text: root.text
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
clip: true
|
||||
|
||||
onAccepted: if (input.text !== root.text) root.accepted(input.text)
|
||||
|
||||
onActiveFocusChanged: {
|
||||
if (input.activeFocus)
|
||||
return;
|
||||
if (input.text !== root.text)
|
||||
root.accepted(input.text);
|
||||
else
|
||||
input.text = root.text;
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: input.text === ""
|
||||
text: root.placeholder
|
||||
color: Theme.fgMuted
|
||||
font: input.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Software updates.
|
||||
//
|
||||
// Three sources that fail independently -- packages, applications, firmware --
|
||||
// so each is counted and applied on its own. Blending them into one number
|
||||
// would hide the case that matters: a flatpak mirror being down says nothing
|
||||
// about whether a security fix is waiting.
|
||||
//
|
||||
// Applying packages takes a snapshot first, named after what is about to
|
||||
// happen, so the Snapshots page shows "before 32 package updates" rather than a
|
||||
// timestamp. That is the thing neither macOS nor Windows does cleanly, and it
|
||||
// is nearly free here.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "updates"
|
||||
title: "Software Update"
|
||||
lede: "Packages, applications, and firmware, each from the place it actually comes from."
|
||||
|
||||
property string expandedSource: ""
|
||||
|
||||
Component.onCompleted: {
|
||||
Updates.refresh();
|
||||
// A first visit with nothing cached should not show a confident "up to
|
||||
// date" it has no basis for, so it goes and finds out.
|
||||
if (!Updates.everChecked && !Updates.checking)
|
||||
Updates.check();
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.lastError !== ""
|
||||
label: "Updates need attention"
|
||||
detail: Updates.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.lastApplied !== null
|
||||
label: "Updated"
|
||||
detail: Updates.lastApplied
|
||||
? Updates.sourceLabel(String(Updates.lastApplied.source ?? ""))
|
||||
+ (String(Updates.lastApplied.restorePoint ?? "") !== ""
|
||||
? " · a snapshot was taken first, number "
|
||||
+ String(Updates.lastApplied.restorePoint)
|
||||
: "")
|
||||
: ""
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── The headline ─────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: Updates.checking ? "Checking…" : Updates.summary()
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: Updates.securityCount > 0
|
||||
text: Updates.securityCount + " carry a security advisory"
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Updates.lastCheckedText()
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Check for updates"
|
||||
detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds."
|
||||
action: Updates.checking ? "Checking…" : "Check now"
|
||||
enabled: !Updates.busy
|
||||
divider: Updates.rebootNeeded
|
||||
onTriggered: Updates.check()
|
||||
}
|
||||
|
||||
// The honest version of "restart required": the running kernel is not
|
||||
// the newest installed one, so a reboot would change which kernel runs.
|
||||
TextRow {
|
||||
visible: Updates.rebootNeeded
|
||||
label: "Restart to finish"
|
||||
detail: "A newer kernel is installed than the one running. "
|
||||
+ String(Updates.kernel?.running ?? "") + " → "
|
||||
+ String(Updates.kernel?.newestInstalled ?? "")
|
||||
value: "Restart needed"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── One card per source ──────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "System packages"
|
||||
subtitle: Updates.dnf?.available === false
|
||||
? "dnf is not available on this machine."
|
||||
: (Number(Updates.dnf?.count ?? 0) === 0
|
||||
? "Nothing waiting."
|
||||
: Updates.dnf.count + " package"
|
||||
+ (Updates.dnf.count === 1 ? "" : "s") + " ready to install"
|
||||
+ (Updates.securityCount > 0
|
||||
? ", " + Updates.securityCount + " carrying an advisory" : ""))
|
||||
|
||||
ActionRow {
|
||||
visible: Number(Updates.dnf?.count ?? 0) > 0
|
||||
label: "Install package updates"
|
||||
detail: "Asks for your password, and takes a snapshot first so this can be undone"
|
||||
action: Updates.applying ? "Working…" : "Install"
|
||||
enabled: !Updates.busy
|
||||
onTriggered: Updates.apply("dnf")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Number(Updates.dnf?.count ?? 0) > 0
|
||||
label: "What would change"
|
||||
detail: root.expandedSource === "dnf"
|
||||
? "Every package that would be replaced"
|
||||
: Updates.dnf.count + " packages"
|
||||
action: root.expandedSource === "dnf" ? "Hide" : "Show"
|
||||
divider: root.expandedSource === "dnf"
|
||||
onTriggered: root.expandedSource = root.expandedSource === "dnf" ? "" : "dnf"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.repository ?? "")
|
||||
value: String(modelData.version ?? "")
|
||||
divider: index < (Updates.dnf?.packages ?? []).length - 1
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Number(Updates.dnf?.count ?? 0) === 0 && Updates.everChecked
|
||||
label: "Packages are current"
|
||||
detail: "Nothing from the system repositories is waiting"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Applications"
|
||||
subtitle: Updates.flatpak?.available === false
|
||||
? "Flatpak is not installed."
|
||||
: (Number(Updates.flatpak?.count ?? 0) === 0
|
||||
? "Nothing waiting."
|
||||
: Updates.flatpak.count + " application"
|
||||
+ (Updates.flatpak.count === 1 ? "" : "s") + " ready to update")
|
||||
|
||||
Repeater {
|
||||
model: Updates.flatpak?.applications ?? []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.id ?? "")
|
||||
detail: "Flatpak"
|
||||
value: String(modelData.version ?? "")
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Number(Updates.flatpak?.count ?? 0) > 0
|
||||
label: "Update applications"
|
||||
detail: "Needs no password: these are installed for your account"
|
||||
action: Updates.applying ? "Working…" : "Update"
|
||||
enabled: !Updates.busy
|
||||
onTriggered: Updates.apply("flatpak")
|
||||
}
|
||||
|
||||
SwitchRow {
|
||||
label: "Update applications automatically"
|
||||
detail: "Once a day, in the background. Applications are not a security boundary the way packages are, so this is safe to leave on; packages still ask."
|
||||
checked: Updates.automatic?.flatpakEnabled === true
|
||||
enabled: !Updates.busy && Updates.automatic?.flatpakAvailable === true
|
||||
divider: false
|
||||
onToggled: value => Updates.setAutomaticFlatpak(value)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Firmware"
|
||||
subtitle: Updates.firmware?.available === false
|
||||
? "Firmware updating is not available on this machine."
|
||||
: (Number(Updates.firmware?.count ?? 0) === 0
|
||||
? "No firmware updates are offered for this hardware."
|
||||
: Updates.firmware.count + " device"
|
||||
+ (Updates.firmware.count === 1 ? "" : "s") + " have firmware available")
|
||||
|
||||
Repeater {
|
||||
model: Updates.firmware?.devices ?? []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
|
||||
+ (modelData.needsReboot ? " · installs on restart" : "")
|
||||
value: ""
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Number(Updates.firmware?.count ?? 0) > 0
|
||||
label: "Install firmware"
|
||||
detail: "Some devices only finish updating after a restart"
|
||||
action: Updates.applying ? "Working…" : "Install"
|
||||
enabled: !Updates.busy
|
||||
divider: false
|
||||
onTriggered: Updates.apply("firmware")
|
||||
}
|
||||
|
||||
// Offered only when the machinery exists. When it does not, this says
|
||||
// so rather than showing a switch that could not do anything --
|
||||
// installing software is not a settings action.
|
||||
SwitchRow {
|
||||
visible: Updates.automatic?.dnfAutomaticAvailable === true
|
||||
label: "Download package updates automatically"
|
||||
detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose."
|
||||
checked: Updates.automatic?.dnfAutomaticEnabled === true
|
||||
enabled: !Updates.busy
|
||||
divider: false
|
||||
onToggled: value => Updates.setAutomaticDnf(value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.automatic?.dnfAutomaticAvailable === false
|
||||
label: "Automatic package updates"
|
||||
detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
|
||||
value: "Off"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// Worth showing because automatic updates leave no other trace. Something
|
||||
// that installed itself overnight is invisible until you look here.
|
||||
SettingsCard {
|
||||
title: "Recently installed"
|
||||
subtitle: Updates.historyLoaded
|
||||
? "Packages and applications, newest first, from both sources at once."
|
||||
: "Asks dnf and flatpak for their transaction logs."
|
||||
|
||||
ActionRow {
|
||||
visible: !Updates.historyLoaded
|
||||
label: "Show what has been installed"
|
||||
detail: "Not loaded with the page: it reads the whole history from both sources"
|
||||
action: Updates.loadingHistory ? "Reading…" : "Show"
|
||||
enabled: !Updates.loadingHistory
|
||||
divider: false
|
||||
onTriggered: Updates.loadHistory()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Updates.history
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.summary ?? "")
|
||||
detail: Updates.agoText(Number(modelData.at ?? 0))
|
||||
value: Updates.describeHistory(modelData)
|
||||
divider: index < Updates.history.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Updates.historyLoaded && Updates.history.length === 0
|
||||
label: "Nothing recorded"
|
||||
detail: "Neither dnf nor flatpak has a transaction history here."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Your account, and anyone else who signs in to this machine.
|
||||
//
|
||||
// The layout puts your own account first because that is what someone opens
|
||||
// this page for, and the avatar leads because it is the thing that shows up
|
||||
// elsewhere in the desktop -- the Control Center draws it, and the lock screen
|
||||
// and login screen read the same file.
|
||||
//
|
||||
// Everything privileged here prompts through polkit. A prompt that is dismissed
|
||||
// is a normal outcome and says so plainly rather than reporting a failure.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "users"
|
||||
title: "Users"
|
||||
lede: "Your account, and anyone else who signs in to this machine."
|
||||
|
||||
// One panel open at a time: changing a password and adding an account are
|
||||
// both multi-field, and two open at once reads as a form with no shape.
|
||||
property string openPanel: ""
|
||||
property string newPassword: ""
|
||||
property string confirmPassword: ""
|
||||
property string newUserName: ""
|
||||
property string newRealName: ""
|
||||
property bool newUserIsAdministrator: false
|
||||
property string confirmingRemoval: ""
|
||||
|
||||
readonly property var me: UserAccounts.me
|
||||
|
||||
readonly property string passwordProblem: {
|
||||
if (root.newPassword === "")
|
||||
return "";
|
||||
if (root.newPassword.length < 6)
|
||||
return "Use at least six characters.";
|
||||
if (root.confirmPassword !== "" && root.newPassword !== root.confirmPassword)
|
||||
return "The two entries do not match.";
|
||||
return "";
|
||||
}
|
||||
|
||||
readonly property bool passwordReady: root.newPassword.length >= 6
|
||||
&& root.newPassword === root.confirmPassword
|
||||
|
||||
function closePanels(): void {
|
||||
root.openPanel = "";
|
||||
root.newPassword = "";
|
||||
root.confirmPassword = "";
|
||||
root.newUserName = "";
|
||||
root.newRealName = "";
|
||||
root.newUserIsAdministrator = false;
|
||||
}
|
||||
|
||||
Component.onCompleted: UserAccounts.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: UserAccounts.lastError !== ""
|
||||
label: "Accounts need attention"
|
||||
detail: UserAccounts.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── You ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Chosen but not yet framed. While this is set the cropper replaces the
|
||||
// account card, because framing is a decision to finish, not a setting to
|
||||
// leave half-made.
|
||||
property string pendingPicture: ""
|
||||
|
||||
SettingsCard {
|
||||
visible: root.pendingPicture !== ""
|
||||
title: "Frame the picture"
|
||||
|
||||
AvatarCropper {
|
||||
width: parent.width
|
||||
source: root.pendingPicture
|
||||
|
||||
onCropped: (x, y, size) => {
|
||||
UserAccounts.setIconCropped(String(root.me?.userName ?? ""),
|
||||
root.pendingPicture, x, y, size);
|
||||
root.pendingPicture = "";
|
||||
}
|
||||
onCancelled: root.pendingPicture = ""
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.me !== null && root.pendingPicture === ""
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 20
|
||||
|
||||
Item {
|
||||
width: 96
|
||||
height: 96
|
||||
|
||||
ClippingRectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: UserAccounts.avatarUrl
|
||||
visible: UserAccounts.avatarUrl !== ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
// accountsservice replaces the file in place, so the
|
||||
// path never changes. cache:false is not enough on its
|
||||
// own -- an unchanged source is never re-read at all --
|
||||
// which is why avatarUrl carries a revision fragment.
|
||||
cache: false
|
||||
asynchronous: true
|
||||
sourceSize.width: 192
|
||||
sourceSize.height: 192
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: UserAccounts.avatarUrl === ""
|
||||
text: UserAccounts.displayName(root.me).slice(0, 1).toUpperCase()
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 38
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - 116
|
||||
spacing: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
text: UserAccounts.displayName(root.me)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: String(root.me?.userName ?? "") + " · "
|
||||
+ (root.me?.administrator ? "Administrator" : "Standard account")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
|
||||
SettingsButton {
|
||||
text: "Change picture…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: avatarPicker.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Account"
|
||||
visible: root.me !== null
|
||||
|
||||
TextFieldRow {
|
||||
label: "Full name"
|
||||
detail: "Shown on the lock screen and in the Control Center"
|
||||
text: String(root.me?.realName ?? "")
|
||||
placeholder: "Your name"
|
||||
enabled: !UserAccounts.busy
|
||||
onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Username"
|
||||
detail: "Fixed when the account was created, because files and permissions are keyed to it"
|
||||
value: String(root.me?.userName ?? "")
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
label: "Account type"
|
||||
detail: root.me?.administrator && UserAccounts.administratorCount <= 1
|
||||
? "This is the only administrator, so it cannot be changed"
|
||||
: "Administrators can install software and manage other accounts"
|
||||
options: [
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "administrator", label: "Administrator" }
|
||||
]
|
||||
value: root.me?.administrator ? "administrator" : "standard"
|
||||
enabled: !UserAccounts.busy
|
||||
&& !(root.me?.administrator && UserAccounts.administratorCount <= 1)
|
||||
onSelected: value => UserAccounts.setAccountType(String(root.me?.userName ?? ""), value)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Password"
|
||||
detail: root.openPanel === "password"
|
||||
? "Changing it asks for authorization first"
|
||||
: "Change the password used to sign in and to unlock the screen"
|
||||
action: root.openPanel === "password" ? "Cancel" : "Change…"
|
||||
enabled: !UserAccounts.busy
|
||||
divider: root.openPanel === "password"
|
||||
onTriggered: {
|
||||
if (root.openPanel === "password")
|
||||
root.closePanels();
|
||||
else {
|
||||
root.closePanels();
|
||||
root.openPanel = "password";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openPanel === "password"
|
||||
|
||||
PasswordRow {
|
||||
width: parent.width
|
||||
label: "New password"
|
||||
detail: "At least six characters"
|
||||
onChanged: value => root.newPassword = value
|
||||
}
|
||||
|
||||
PasswordRow {
|
||||
width: parent.width
|
||||
label: "Confirm"
|
||||
detail: root.passwordProblem !== ""
|
||||
? root.passwordProblem
|
||||
: "Type it a second time"
|
||||
onChanged: value => root.confirmPassword = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Set this password"
|
||||
detail: "You will be asked to authorize the change"
|
||||
action: "Set password"
|
||||
enabled: root.passwordReady && !UserAccounts.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
UserAccounts.setPassword(String(root.me?.userName ?? ""), root.newPassword);
|
||||
root.closePanels();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SwitchRow {
|
||||
label: "Automatic login"
|
||||
detail: "Sign in without typing a password. The login keyring stays locked when this is on, so stored passwords are unavailable until something asks for them."
|
||||
checked: root.me?.automaticLogin === true
|
||||
enabled: !UserAccounts.busy
|
||||
divider: false
|
||||
onToggled: value => UserAccounts.setAutomaticLogin(String(root.me?.userName ?? ""), value)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Everyone else ────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Other accounts"
|
||||
subtitle: UserAccounts.others.length === 0
|
||||
? "Only your account exists on this machine."
|
||||
: UserAccounts.others.length + " other account"
|
||||
+ (UserAccounts.others.length === 1 ? "" : "s")
|
||||
|
||||
Repeater {
|
||||
model: UserAccounts.others
|
||||
|
||||
delegate: Column {
|
||||
id: otherBlock
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
|
||||
readonly property string userName: String(otherBlock.modelData.userName ?? "")
|
||||
readonly property bool confirming: root.confirmingRemoval === otherBlock.userName
|
||||
|
||||
SettingRow {
|
||||
width: otherBlock.width
|
||||
label: UserAccounts.displayName(otherBlock.modelData)
|
||||
detail: otherBlock.userName + " · "
|
||||
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard account")
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: otherBlock.confirming ? "Keep" : "Remove…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: root.confirmingRemoval = otherBlock.confirming
|
||||
? "" : otherBlock.userName
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: otherBlock.confirming
|
||||
text: "Delete account and files"
|
||||
tone: "danger"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: {
|
||||
root.confirmingRemoval = "";
|
||||
UserAccounts.deleteUser(otherBlock.userName, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: otherBlock.width
|
||||
visible: otherBlock.confirming
|
||||
label: "This cannot be undone"
|
||||
detail: "Their home directory and everything in it is deleted."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Creating an account asks for authorization first"
|
||||
action: root.openPanel === "newUser" ? "Cancel" : "Add…"
|
||||
enabled: !UserAccounts.busy
|
||||
divider: root.openPanel === "newUser"
|
||||
onTriggered: {
|
||||
if (root.openPanel === "newUser")
|
||||
root.closePanels();
|
||||
else {
|
||||
root.closePanels();
|
||||
root.openPanel = "newUser";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openPanel === "newUser"
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Full name"
|
||||
placeholder: "Their name"
|
||||
detail: "Shown on the login screen"
|
||||
text: root.newRealName
|
||||
onAccepted: value => root.newRealName = value
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Username"
|
||||
placeholder: "lowercase, no spaces"
|
||||
detail: "Their home directory is named after this and cannot be changed later"
|
||||
text: root.newUserName
|
||||
onAccepted: value => root.newUserName = value
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
width: parent.width
|
||||
label: "Account type"
|
||||
detail: "Standard accounts cannot install software or manage other accounts"
|
||||
options: [
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "administrator", label: "Administrator" }
|
||||
]
|
||||
value: root.newUserIsAdministrator ? "administrator" : "standard"
|
||||
onSelected: value => root.newUserIsAdministrator = value === "administrator"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Create the account"
|
||||
detail: "They set their own password the first time they sign in"
|
||||
action: "Create"
|
||||
enabled: !UserAccounts.busy && /^[a-z_][a-z0-9_-]*$/.test(root.newUserName)
|
||||
divider: false
|
||||
onTriggered: {
|
||||
UserAccounts.createUser(root.newUserName, root.newRealName,
|
||||
root.newUserIsAdministrator ? "administrator" : "standard");
|
||||
root.closePanels();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picking a picture goes through the desktop portal, which is the same
|
||||
// chooser every other application gets and needs no privilege of its own.
|
||||
AvatarPicker {
|
||||
id: avatarPicker
|
||||
onPicked: path => root.pendingPicture = path
|
||||
}
|
||||
}
|
||||
@@ -96,11 +96,19 @@ Item {
|
||||
source: "file://" + tile.modelData
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: false
|
||||
// Decode to roughly the size actually drawn. Without this a
|
||||
// grid of 12MB photographs decodes at full resolution.
|
||||
sourceSize.width: 400
|
||||
sourceSize.height: 240
|
||||
// Cached, because these are slow: the largest of these files
|
||||
// takes over two seconds to decode even at this size, and
|
||||
// without a cache scrolling back up pays that again for
|
||||
// every tile. What is held is the scaled thumbnail, not the
|
||||
// original, so the cost of keeping it is small. The tradeoff
|
||||
// is that a wallpaper replaced in place under the same name
|
||||
// shows its old thumbnail until the shell restarts -- worth
|
||||
// it for a directory of files that are added, not edited.
|
||||
cache: true
|
||||
|
||||
// Several of these are 8-12MB originals, so a tile can sit
|
||||
// empty for a second or two. Fading in on ready makes that
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
module qs.modules.settings
|
||||
AboutPage 1.0 AboutPage.qml
|
||||
AppearancePage 1.0 AppearancePage.qml
|
||||
AvatarPicker 1.0 AvatarPicker.qml
|
||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||
FirewallPage 1.0 FirewallPage.qml
|
||||
ContainersPage 1.0 ContainersPage.qml
|
||||
SshKeysPage 1.0 SshKeysPage.qml
|
||||
AvatarCropper 1.0 AvatarCropper.qml
|
||||
PickerRow 1.0 PickerRow.qml
|
||||
SettingsTabs 1.0 SettingsTabs.qml
|
||||
GamingPage 1.0 GamingPage.qml
|
||||
HomePhonePage 1.0 HomePhonePage.qml
|
||||
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||
AvailableLightRow 1.0 AvailableLightRow.qml
|
||||
@@ -9,10 +17,13 @@ DesktopPage 1.0 DesktopPage.qml
|
||||
DisplaysPage 1.0 DisplaysPage.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
NotificationsPage 1.0 NotificationsPage.qml
|
||||
PasswordRow 1.0 PasswordRow.qml
|
||||
PrintersPage 1.0 PrintersPage.qml
|
||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||
HealthPage 1.0 HealthPage.qml
|
||||
HealthSummary 1.0 HealthSummary.qml
|
||||
HealthCheckRow 1.0 HealthCheckRow.qml
|
||||
SegmentRow 1.0 SegmentRow.qml
|
||||
SettingRow 1.0 SettingRow.qml
|
||||
SettingsCard 1.0 SettingsCard.qml
|
||||
SettingsButton 1.0 SettingsButton.qml
|
||||
@@ -20,9 +31,13 @@ SettingsShell 1.0 SettingsShell.qml
|
||||
SettingsSidebar 1.0 SettingsSidebar.qml
|
||||
SettingsToggle 1.0 SettingsToggle.qml
|
||||
SettingsWindow 1.0 SettingsWindow.qml
|
||||
SharingPage 1.0 SharingPage.qml
|
||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||
SnapshotsPage 1.0 SnapshotsPage.qml
|
||||
SoundPage 1.0 SoundPage.qml
|
||||
SettingsPage 1.0 SettingsPage.qml
|
||||
StoragePage 1.0 StoragePage.qml
|
||||
SwitchRow 1.0 SwitchRow.qml
|
||||
ToggleRow 1.0 ToggleRow.qml
|
||||
SliderRow 1.0 SliderRow.qml
|
||||
ChoiceRow 1.0 ChoiceRow.qml
|
||||
@@ -33,6 +48,8 @@ LockScreenPreview 1.0 LockScreenPreview.qml
|
||||
PowerPage 1.0 PowerPage.qml
|
||||
DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
UpdatesPage 1.0 UpdatesPage.qml
|
||||
UsersPage 1.0 UsersPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
WallpaperControls 1.0 WallpaperControls.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
@@ -62,3 +79,4 @@ RegionPage 1.0 RegionPage.qml
|
||||
SearchPicker 1.0 SearchPicker.qml
|
||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||
AccentPicker 1.0 AccentPicker.qml
|
||||
TextFieldRow 1.0 TextFieldRow.qml
|
||||
|
||||
@@ -22,7 +22,11 @@ PanelWindow {
|
||||
|
||||
screen: root.modelData
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
implicitWidth: root.showingEvent ? 388 : 438
|
||||
implicitHeight: root.showingEvent ? 76 : 132
|
||||
|
||||
@@ -121,7 +121,19 @@ emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/me
|
||||
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||
emit "Swap" "$swap"
|
||||
|
||||
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
|
||||
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
|
||||
# Decimal units with explicit GB/TB labels, matching the Storage page and the
|
||||
# way drives are actually sold. `df -h` is binary but prints a bare "G", so the
|
||||
# same drive read 488G here and 523 GB there.
|
||||
read -r size avail <<<"$(df -B1 --output=size,avail / 2>/dev/null | tail -1)"
|
||||
if [[ -n "${size:-}" ]]; then
|
||||
emit "Disk" "$(awk -v a="$avail" -v s="$size" '
|
||||
function human(v, units, i) {
|
||||
split("B KB MB GB TB PB", units, " ")
|
||||
i = 1
|
||||
while (v >= 1000 && i < 6) { v /= 1000; i++ }
|
||||
return sprintf("%.*f %s", (v < 10 && i > 2) ? 1 : 0, v, units[i])
|
||||
}
|
||||
BEGIN { printf "%s free of %s", human(a), human(s) }')"
|
||||
fi
|
||||
|
||||
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||
|
||||
@@ -68,6 +68,11 @@ def describe(obj):
|
||||
"path": obj.get_object_path(),
|
||||
"provider": account.props.provider_type,
|
||||
"providerName": account.props.provider_name,
|
||||
# GOA hands back a serialised GThemedIcon: ". GThemedIcon name1 name2 …",
|
||||
# a preference-ordered fallback chain. Passed on as that list rather than
|
||||
# resolved here, because which of those names exists is a property of the
|
||||
# icon theme in use, which this has no business deciding.
|
||||
"providerIcons": themed_icon_names(account.props.provider_icon),
|
||||
# PresentationIdentity is the human one (an email address); Identity is
|
||||
# the internal handle and is not always readable.
|
||||
"identity": account.props.presentation_identity or account.props.identity,
|
||||
@@ -79,6 +84,26 @@ def describe(obj):
|
||||
}
|
||||
|
||||
|
||||
def themed_icon_names(icon) -> list[str]:
|
||||
"""The icon names out of a serialised GThemedIcon, best first.
|
||||
|
||||
The string form is ". GThemedIcon mail-unread-symbolic mail-symbolic mail",
|
||||
where the leading "." and the type name are structure rather than content.
|
||||
Anything that is not that shape yields nothing, so a caller gets an empty
|
||||
list rather than a name that will never resolve.
|
||||
"""
|
||||
if icon is None:
|
||||
return []
|
||||
try:
|
||||
text = icon.to_string()
|
||||
except Exception:
|
||||
text = str(icon)
|
||||
parts = str(text or "").split()
|
||||
if len(parts) < 3 or parts[0] != "." or parts[1] != "GThemedIcon":
|
||||
return []
|
||||
return [name for name in parts[2:] if name]
|
||||
|
||||
|
||||
def find(client, path):
|
||||
for obj in client.get_accounts():
|
||||
if obj.get_object_path() == path:
|
||||
|
||||
@@ -109,6 +109,19 @@ case "$action" in
|
||||
clipboard) qs ipc call clipboard open ;;
|
||||
overview) qs ipc call overview open ;;
|
||||
settings) qs ipc call settings open ;;
|
||||
|
||||
# Jump straight to one settings page. The launcher's per-page commands are
|
||||
# the only caller, and they are generated from the page list itself, so the
|
||||
# pattern below is a boundary check rather than a whitelist: an unknown page
|
||||
# would leave the settings window showing nothing at all.
|
||||
settings-page)
|
||||
page="${2:-}"
|
||||
if [[ ! "$page" =~ ^[a-z][a-z-]*$ ]]; then
|
||||
printf 'Usage: panama-action settings-page PAGE\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
qs ipc call settings page "$page"
|
||||
;;
|
||||
health) qs ipc call health open ;;
|
||||
|
||||
dnd)
|
||||
@@ -149,7 +162,7 @@ case "$action" in
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: panama-action {%s}\n' \
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|settings-page PAGE|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Rootless containers, grouped by the project that defines them.
|
||||
|
||||
Every container on this machine is created by podman-compose and labelled with
|
||||
the project it belongs to, so the grouping is read from the labels rather than
|
||||
invented. Acting on a group is then done with plain `podman` over the labelled
|
||||
set -- never `podman-compose down`, which would REMOVE the containers. Nothing
|
||||
here creates, recreates, or removes a container: the compose file is the source
|
||||
of truth for what exists, and it belongs to the repository, not to this tool.
|
||||
|
||||
Rootless throughout, so nothing here needs privilege.
|
||||
|
||||
The one exception to "does not touch the compose file" is `bind-local`, which
|
||||
exists because a development database published on every interface is worth
|
||||
closing and the fix is a single token. It prepends a loopback bind address and
|
||||
leaves the rest of the line byte-for-byte -- variables, quoting and style
|
||||
intact -- then re-parses to confirm only that value moved. Anything it cannot
|
||||
read unambiguously it refuses rather than guesses.
|
||||
|
||||
panama-containers snapshot
|
||||
panama-containers start NAME | stop NAME | restart NAME
|
||||
panama-containers project-start NAME | project-stop NAME | project-restart NAME
|
||||
panama-containers prune-images | prune-volumes
|
||||
panama-containers bind-local PROJECT SERVICE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# A container or project name as podman and compose accept them. Deliberately
|
||||
# strict: these values reach an argv, and nothing legitimate needs more.
|
||||
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
||||
|
||||
# Project names that say nothing, because podman-compose defaults to the
|
||||
# directory holding the compose file. "docker" is a directory, not a project.
|
||||
ANONYMOUS = {"docker", "compose", "containers", "container", "db", "dev", "local", "src"}
|
||||
|
||||
# Substituted before a port mapping is split, because ${POSTGRES_PORT:-5432}
|
||||
# contains a colon and would otherwise be torn in half.
|
||||
INTERPOLATION = re.compile(r"\$\{[^}]*\}")
|
||||
|
||||
# Host addresses that mean "every interface", and so mean reachable.
|
||||
EVERY_INTERFACE = {"", "0.0.0.0", "::", "[::]", "*"}
|
||||
|
||||
LOOPBACK = "127.0.0.1"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or podman failure."""
|
||||
|
||||
|
||||
def podman_binary() -> str:
|
||||
"""The podman to call.
|
||||
|
||||
Overridable so that stopping a container and removing an image can be
|
||||
tested without stopping a real container or removing a real image. The
|
||||
containers on this machine are a working development database; a test suite
|
||||
has no business touching them.
|
||||
"""
|
||||
return os.environ.get("PANAMA_CONTAINERS_PODMAN", "podman")
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 60.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
except FileNotFoundError as error:
|
||||
raise BoundaryError("podman is not installed.") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError("podman did not respond.") from error
|
||||
|
||||
|
||||
def podman_json(arguments: list[str], timeout: float = 60.0) -> list | dict:
|
||||
result = run([podman_binary(), *arguments], timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "podman could not be read.")
|
||||
try:
|
||||
return json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError as error:
|
||||
raise BoundaryError("podman returned something unreadable.") from error
|
||||
|
||||
|
||||
def podman_do(arguments: list[str], failure: str, timeout: float = 120.0) -> None:
|
||||
result = run([podman_binary(), *arguments], timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else failure)
|
||||
|
||||
|
||||
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
|
||||
if not pattern.match(value or ""):
|
||||
raise BoundaryError(message)
|
||||
return value
|
||||
|
||||
|
||||
# ── reading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def split_mapping(mapping: str) -> list[str]:
|
||||
"""The parts of a compose port mapping, with interpolations kept whole."""
|
||||
masked: list[str] = []
|
||||
placeholder = "\x00{}\x00"
|
||||
def keep(match: re.Match[str]) -> str:
|
||||
masked.append(match.group(0))
|
||||
return placeholder.format(len(masked) - 1)
|
||||
stand_in = INTERPOLATION.sub(keep, mapping)
|
||||
return [
|
||||
re.sub(r"\x00(\d+)\x00", lambda m: masked[int(m.group(1))], part)
|
||||
for part in stand_in.split(":")
|
||||
]
|
||||
|
||||
|
||||
def health_of(status: str) -> str:
|
||||
"""The health podman prints inside the status line, when it prints one."""
|
||||
match = re.search(r"\((healthy|unhealthy|starting)\)", status or "")
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def containers() -> list[dict]:
|
||||
raw = podman_json(["ps", "-a", "--format", "json"])
|
||||
result: list[dict] = []
|
||||
for entry in raw if isinstance(raw, list) else []:
|
||||
labels = entry.get("Labels") or {}
|
||||
names = entry.get("Names") or []
|
||||
status = str(entry.get("Status") or "")
|
||||
ports = [
|
||||
{
|
||||
"hostIp": str(port.get("host_ip") or ""),
|
||||
"hostPort": int(port.get("host_port") or 0),
|
||||
"containerPort": int(port.get("container_port") or 0),
|
||||
"protocol": str(port.get("protocol") or "tcp"),
|
||||
"range": int(port.get("range") or 1),
|
||||
}
|
||||
for port in (entry.get("Ports") or [])
|
||||
]
|
||||
result.append({
|
||||
"id": str(entry.get("Id") or "")[:12],
|
||||
"name": names[0] if names else str(entry.get("Id") or "")[:12],
|
||||
"image": str(entry.get("Image") or ""),
|
||||
"state": str(entry.get("State") or ""),
|
||||
"status": status,
|
||||
"health": health_of(status),
|
||||
"exitCode": int(entry.get("ExitCode") or 0),
|
||||
"startedAt": int(entry.get("StartedAt") or 0),
|
||||
"restarts": int(entry.get("Restarts") or 0),
|
||||
"project": str(labels.get("com.docker.compose.project") or ""),
|
||||
"service": str(labels.get("com.docker.compose.service") or ""),
|
||||
"configFile": str(labels.get("com.docker.compose.project.config_files") or ""),
|
||||
"workingDir": str(labels.get("com.docker.compose.project.working_dir") or ""),
|
||||
# Published ports whose host address is every interface.
|
||||
"ports": ports,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def resolve_config(path: str, working_dir: str) -> str:
|
||||
"""The compose file a label points at, normalised.
|
||||
|
||||
podman-compose records the path as it was invoked, which is how a perfectly
|
||||
valid label ends up containing "/scripts/../compose.yml".
|
||||
"""
|
||||
if not path:
|
||||
return ""
|
||||
first = path.split(",")[0].strip()
|
||||
if not first:
|
||||
return ""
|
||||
candidate = Path(first)
|
||||
if not candidate.is_absolute() and working_dir:
|
||||
candidate = Path(working_dir) / candidate
|
||||
try:
|
||||
return str(candidate.resolve(strict=False))
|
||||
except OSError:
|
||||
return str(candidate)
|
||||
|
||||
|
||||
def repository_name(config_file: str) -> str:
|
||||
"""The repository a compose file lives in, for naming a project sensibly."""
|
||||
if not config_file:
|
||||
return ""
|
||||
result = run(
|
||||
["git", "-C", str(Path(config_file).parent), "rev-parse", "--show-toplevel"],
|
||||
timeout=10.0,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return Path(result.stdout.strip()).name if result.stdout.strip() else ""
|
||||
|
||||
|
||||
def display_name(project: str, config_file: str) -> str:
|
||||
"""A project name worth showing.
|
||||
|
||||
podman-compose names a project after the directory holding its compose
|
||||
file, so a database stack can end up called "docker". Where the name says
|
||||
nothing, the repository it lives in says more.
|
||||
"""
|
||||
if project.lower() not in ANONYMOUS:
|
||||
return project
|
||||
return repository_name(config_file) or project
|
||||
|
||||
|
||||
def projects_of(entries: list[dict]) -> list[dict]:
|
||||
"""Containers grouped by the compose project that declares them."""
|
||||
grouped: dict[str, dict] = {}
|
||||
for container in entries:
|
||||
key = container["project"]
|
||||
if not key:
|
||||
continue
|
||||
group = grouped.get(key)
|
||||
if group is None:
|
||||
config_file = resolve_config(container["configFile"], container["workingDir"])
|
||||
group = grouped[key] = {
|
||||
"name": key,
|
||||
"title": display_name(key, config_file),
|
||||
"configFile": config_file,
|
||||
"workingDir": container["workingDir"],
|
||||
"containers": [],
|
||||
}
|
||||
group["containers"].append(container)
|
||||
|
||||
result = []
|
||||
for group in grouped.values():
|
||||
group["containers"].sort(key=lambda c: (c["state"] != "running", c["name"]))
|
||||
group["running"] = sum(1 for c in group["containers"] if c["state"] == "running")
|
||||
group["total"] = len(group["containers"])
|
||||
result.append(group)
|
||||
|
||||
# Whatever is running comes first; a project nobody is using can wait.
|
||||
result.sort(key=lambda g: (-g["running"], g["title"].lower()))
|
||||
return result
|
||||
|
||||
|
||||
def exposures(entries: list[dict]) -> list[dict]:
|
||||
"""Published ports any machine on the network can reach.
|
||||
|
||||
A container is only reachable if it is running AND publishes on an address
|
||||
that is not loopback. A stopped container publishes nothing, whatever its
|
||||
compose file says -- so it is reported as something that WILL expose, not
|
||||
something that does.
|
||||
"""
|
||||
found: list[dict] = []
|
||||
for container in entries:
|
||||
for port in container["ports"]:
|
||||
if port["hostIp"] not in EVERY_INTERFACE:
|
||||
continue
|
||||
if port["hostPort"] <= 0:
|
||||
continue
|
||||
found.append({
|
||||
"container": container["name"],
|
||||
"project": container["project"],
|
||||
"service": container["service"],
|
||||
"configFile": resolve_config(container["configFile"], container["workingDir"]),
|
||||
"image": container["image"],
|
||||
"hostPort": port["hostPort"],
|
||||
"containerPort": port["containerPort"],
|
||||
"protocol": port["protocol"],
|
||||
"running": container["state"] == "running",
|
||||
})
|
||||
found.sort(key=lambda e: (not e["running"], e["hostPort"]))
|
||||
return found
|
||||
|
||||
|
||||
def disk() -> dict:
|
||||
"""What the container store costs, and what of it nothing references."""
|
||||
usage = podman_json(["system", "df", "--format", "json"])
|
||||
totals = {
|
||||
str(row.get("Type") or ""): row
|
||||
for row in (usage if isinstance(usage, list) else [])
|
||||
}
|
||||
|
||||
def raw(kind: str, field: str) -> int:
|
||||
return int((totals.get(kind) or {}).get(field) or 0)
|
||||
|
||||
images = podman_json(["images", "--format", "json"])
|
||||
unused = []
|
||||
for image in images if isinstance(images, list) else []:
|
||||
if int(image.get("Containers") or 0) > 0:
|
||||
continue
|
||||
tags = image.get("Names") or image.get("RepoTags") or []
|
||||
unused.append({
|
||||
"id": str(image.get("Id") or "")[:12],
|
||||
"name": tags[0] if tags else "<untagged>",
|
||||
"size": int(image.get("Size") or 0),
|
||||
})
|
||||
unused.sort(key=lambda i: -i["size"])
|
||||
|
||||
# Podman's own answer to "does anything reference this volume", rather
|
||||
# than MountCount, which is a runtime lock counter: it reads zero for a
|
||||
# volume that a running container has mounted this second, and using it
|
||||
# here would offer to delete a live database.
|
||||
volumes = podman_json(["volume", "ls", "--filter", "dangling=true", "--format", "json"])
|
||||
idle = [
|
||||
{"name": str(volume.get("Name") or "")}
|
||||
for volume in (volumes if isinstance(volumes, list) else [])
|
||||
if volume.get("Name")
|
||||
]
|
||||
idle.sort(key=lambda v: v["name"])
|
||||
|
||||
return {
|
||||
"imagesSize": raw("Images", "RawSize"),
|
||||
"imagesReclaimable": raw("Images", "RawReclaimable"),
|
||||
"containersSize": raw("Containers", "RawSize"),
|
||||
"volumesSize": raw("Local Volumes", "RawSize"),
|
||||
"volumesReclaimable": raw("Local Volumes", "RawReclaimable"),
|
||||
"unusedImages": unused,
|
||||
"unusedVolumes": idle,
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
entries = containers()
|
||||
grouped = projects_of(entries)
|
||||
return {
|
||||
"available": True,
|
||||
"projects": grouped,
|
||||
"loose": [c for c in entries if not c["project"]],
|
||||
"running": sum(1 for c in entries if c["state"] == "running"),
|
||||
"total": len(entries),
|
||||
"exposed": exposures(entries),
|
||||
"disk": disk(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def unavailable(message: str) -> dict:
|
||||
return {
|
||||
"available": False, "projects": [], "loose": [], "running": 0, "total": 0,
|
||||
"exposed": [], "disk": {
|
||||
"imagesSize": 0, "imagesReclaimable": 0, "containersSize": 0,
|
||||
"volumesSize": 0, "volumesReclaimable": 0,
|
||||
"unusedImages": [], "unusedVolumes": [],
|
||||
},
|
||||
"error": message,
|
||||
}
|
||||
|
||||
|
||||
# ── acting ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_container(name: str) -> dict:
|
||||
for container in containers():
|
||||
if container["name"] == name or container["id"] == name:
|
||||
return container
|
||||
raise BoundaryError(f"There is no container called {name}.")
|
||||
|
||||
|
||||
def find_project(name: str) -> dict:
|
||||
for project in projects_of(containers()):
|
||||
if project["name"] == name:
|
||||
return project
|
||||
raise BoundaryError(f"There is no project called {name}.")
|
||||
|
||||
|
||||
def act_on_container(verb: str, name: str) -> None:
|
||||
container = find_container(name)
|
||||
if verb == "start" and container["state"] == "running":
|
||||
raise BoundaryError(f"{container['name']} is already running.")
|
||||
if verb == "stop" and container["state"] != "running":
|
||||
raise BoundaryError(f"{container['name']} is not running.")
|
||||
podman_do([verb, container["name"]], f"{container['name']} could not be {verb}ed.")
|
||||
|
||||
|
||||
def act_on_project(verb: str, name: str) -> None:
|
||||
"""The whole stack, one container at a time, with plain podman.
|
||||
|
||||
Deliberately not `podman-compose down`: that removes containers, and this
|
||||
tool does not remove what the compose file created.
|
||||
"""
|
||||
project = find_project(name)
|
||||
wanted = "running" if verb == "stop" else "not running"
|
||||
targets = [
|
||||
container["name"] for container in project["containers"]
|
||||
if (container["state"] == "running") == (wanted == "running")
|
||||
] if verb != "restart" else [
|
||||
container["name"] for container in project["containers"]
|
||||
if container["state"] == "running"
|
||||
]
|
||||
if not targets:
|
||||
raise BoundaryError(f"Nothing in {project['title']} needs to be {verb}ed.")
|
||||
|
||||
failures: list[str] = []
|
||||
for target in targets:
|
||||
result = run([podman_binary(), verb, target], timeout=120.0)
|
||||
if result.returncode != 0:
|
||||
failures.append(target)
|
||||
if failures:
|
||||
raise BoundaryError(f"Could not {verb} {', '.join(failures)}.")
|
||||
|
||||
|
||||
def prune_images() -> None:
|
||||
"""Remove images nothing references.
|
||||
|
||||
Scoped to exactly what the snapshot showed as unused, by id, so that an
|
||||
image which gained a container between the panel rendering and the button
|
||||
being pressed is not swept up by a blanket prune.
|
||||
"""
|
||||
unused = disk()["unusedImages"]
|
||||
if not unused:
|
||||
raise BoundaryError("Every image is in use.")
|
||||
failures = []
|
||||
for image in unused:
|
||||
result = run([podman_binary(), "rmi", image["id"]], timeout=120.0)
|
||||
if result.returncode != 0:
|
||||
failures.append(image["name"])
|
||||
if failures:
|
||||
raise BoundaryError(f"Could not remove {len(failures)} image(s): {', '.join(failures[:3])}.")
|
||||
|
||||
|
||||
def prune_volumes() -> None:
|
||||
idle = disk()["unusedVolumes"]
|
||||
if not idle:
|
||||
raise BoundaryError("Every volume is in use.")
|
||||
failures = []
|
||||
for volume in idle:
|
||||
result = run([podman_binary(), "volume", "rm", volume["name"]], timeout=120.0)
|
||||
if result.returncode != 0:
|
||||
failures.append(volume["name"])
|
||||
if failures:
|
||||
raise BoundaryError(f"Could not remove {len(failures)} volume(s): {', '.join(failures[:3])}.")
|
||||
|
||||
|
||||
# ── the compose edit ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def rewrite_mapping(mapping: str) -> str:
|
||||
"""A published port bound to loopback, with everything else left alone.
|
||||
|
||||
Only the address is added. The host port keeps whatever form it had --
|
||||
literal, ${VAR}, or ${VAR:-default} -- because rewriting it to the number
|
||||
podman happens to report today would silently delete the variable that lets
|
||||
the port be configured at all.
|
||||
"""
|
||||
parts = split_mapping(mapping.strip())
|
||||
if len(parts) == 3:
|
||||
raise BoundaryError(f"{mapping} already names an address.")
|
||||
if len(parts) != 2:
|
||||
raise BoundaryError(f"{mapping} is not a mapping this can read.")
|
||||
return f"{LOOPBACK}:{parts[0]}:{parts[1]}"
|
||||
|
||||
|
||||
def compose_ports(document: dict, service: str) -> list[str]:
|
||||
services = document.get("services")
|
||||
if not isinstance(services, dict) or service not in services:
|
||||
raise BoundaryError(f"{service} is not in that compose file.")
|
||||
definition = services.get(service)
|
||||
if not isinstance(definition, dict):
|
||||
raise BoundaryError(f"{service} is not readable in that compose file.")
|
||||
ports = definition.get("ports")
|
||||
if ports is None:
|
||||
raise BoundaryError(f"{service} does not publish any ports.")
|
||||
if not isinstance(ports, list) or not all(isinstance(p, str) for p in ports):
|
||||
raise BoundaryError(f"The ports of {service} are not in a form this can edit.")
|
||||
return ports
|
||||
|
||||
|
||||
def bind_local(project_name: str, service: str) -> None:
|
||||
"""Bind a service's published ports to loopback, in place.
|
||||
|
||||
Read to understand, edit as text so the file keeps its comments, quoting
|
||||
and layout, then read back to confirm that exactly the intended values
|
||||
moved and nothing else did.
|
||||
"""
|
||||
project = find_project(project_name)
|
||||
path = Path(project["configFile"])
|
||||
if not project["configFile"] or not path.is_file():
|
||||
raise BoundaryError("The compose file for that project could not be found.")
|
||||
|
||||
original = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
document = yaml.safe_load(original)
|
||||
except yaml.YAMLError as error:
|
||||
raise BoundaryError("That compose file could not be parsed.") from error
|
||||
if not isinstance(document, dict):
|
||||
raise BoundaryError("That compose file is not a mapping.")
|
||||
|
||||
current = compose_ports(document, service)
|
||||
wanted = [rewrite_mapping(mapping) for mapping in current]
|
||||
|
||||
# Replace each mapping where it is written, not the line it sits on, so
|
||||
# flow style, block style and inline comments all survive untouched. The
|
||||
# mapping text is searched for within the service's own span only.
|
||||
updated = original
|
||||
for before, after in zip(current, wanted):
|
||||
needle = before.strip()
|
||||
occurrences = updated.count(needle)
|
||||
if occurrences == 0:
|
||||
raise BoundaryError(f"Could not find {needle} in the compose file.")
|
||||
if occurrences > 1:
|
||||
raise BoundaryError(
|
||||
f"{needle} appears {occurrences} times in that file; "
|
||||
"it is not clear which one belongs to this service.")
|
||||
updated = updated.replace(needle, after)
|
||||
|
||||
if updated == original:
|
||||
raise BoundaryError("That compose file already binds these ports to loopback.")
|
||||
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
# Read back. A change that was not applied, or that broke the document, is
|
||||
# worse than no change at all.
|
||||
try:
|
||||
reread = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as error:
|
||||
path.write_text(original, encoding="utf-8")
|
||||
raise BoundaryError("The edit would have broken that compose file; it was undone.") from error
|
||||
|
||||
if not isinstance(reread, dict) or compose_ports(reread, service) != wanted:
|
||||
path.write_text(original, encoding="utf-8")
|
||||
raise BoundaryError("The edit did not take effect; it was undone.")
|
||||
|
||||
# Everything except this service's ports must be identical.
|
||||
before_document = yaml.safe_load(original)
|
||||
before_document["services"][service]["ports"] = wanted
|
||||
if before_document != reread:
|
||||
path.write_text(original, encoding="utf-8")
|
||||
raise BoundaryError("The edit changed more than those ports; it was undone.")
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 2 and arguments[0] in ("start", "stop", "restart"):
|
||||
act_on_container(arguments[0], require(NAME, arguments[1], "That is not a container name."))
|
||||
elif len(arguments) == 2 and arguments[0] in ("project-start", "project-stop", "project-restart"):
|
||||
verb = arguments[0].split("-", 1)[1]
|
||||
act_on_project(verb, require(NAME, arguments[1], "That is not a project name."))
|
||||
elif arguments == ["prune-images"]:
|
||||
prune_images()
|
||||
elif arguments == ["prune-volumes"]:
|
||||
prune_volumes()
|
||||
elif len(arguments) == 3 and arguments[0] == "bind-local":
|
||||
bind_local(
|
||||
require(NAME, arguments[1], "That is not a project name."),
|
||||
require(NAME, arguments[2], "That is not a service name."),
|
||||
)
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-containers snapshot | start NAME | stop NAME | restart NAME | "
|
||||
"project-start NAME | project-stop NAME | project-restart NAME | "
|
||||
"prune-images | prune-volumes | bind-local PROJECT SERVICE")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = unavailable("")
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -14,14 +14,46 @@ import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
# A role owns a FAMILY of types, not one representative.
|
||||
#
|
||||
# Each role used to carry a single mime type, so setting "images" changed
|
||||
# image/png and left image/jpeg wherever it happened to land. That is exactly
|
||||
# how this machine ended up opening PNGs in a pixel-art editor, MP3s in a video
|
||||
# transcoder and PDFs in GIMP: nobody chose any of it, the applications
|
||||
# registered themselves and the roles only ever governed one type each.
|
||||
#
|
||||
# The FIRST entry in each list is the one queried when reporting the current
|
||||
# handler; all of them are written when the role is set, so a family cannot
|
||||
# drift apart again.
|
||||
ROLE_TARGETS = {
|
||||
"browser": ("settings", "default-web-browser"),
|
||||
"mail": ("mime", "x-scheme-handler/mailto"),
|
||||
"files": ("mime", "inode/directory"),
|
||||
"terminal": ("mime", "x-scheme-handler/terminal"),
|
||||
"music": ("mime", "audio/mpeg"),
|
||||
"images": ("mime", "image/png"),
|
||||
"video": ("mime", "video/mp4"),
|
||||
"browser": ("settings", ["default-web-browser"]),
|
||||
"mail": ("mime", ["x-scheme-handler/mailto"]),
|
||||
"files": ("mime", ["inode/directory"]),
|
||||
"terminal": ("mime", ["x-scheme-handler/terminal"]),
|
||||
"music": ("mime", [
|
||||
"audio/mpeg", "audio/flac", "audio/x-vorbis+ogg", "audio/ogg",
|
||||
"audio/x-wav", "audio/mp4", "audio/aac", "audio/opus",
|
||||
]),
|
||||
"images": ("mime", [
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp",
|
||||
"image/tiff", "image/bmp", "image/svg+xml", "image/avif",
|
||||
]),
|
||||
"video": ("mime", [
|
||||
"video/mp4", "video/x-matroska", "video/webm", "video/quicktime",
|
||||
"video/x-msvideo", "video/mpeg",
|
||||
]),
|
||||
"documents": ("mime", [
|
||||
"application/pdf", "application/epub+zip",
|
||||
]),
|
||||
"text": ("mime", [
|
||||
"text/plain", "text/markdown", "text/x-python", "text/x-csrc",
|
||||
"text/x-chdr", "text/x-c++src", "text/x-shellscript",
|
||||
"application/json", "application/x-yaml", "text/xml",
|
||||
]),
|
||||
"archives": ("mime", [
|
||||
"application/zip", "application/x-tar", "application/gzip",
|
||||
"application/x-7z-compressed", "application/vnd.rar",
|
||||
]),
|
||||
}
|
||||
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
|
||||
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
|
||||
@@ -70,7 +102,9 @@ def run(command: list[str]) -> str:
|
||||
|
||||
def query_handlers() -> dict[str, str]:
|
||||
handlers: dict[str, str] = {}
|
||||
for role, (kind, target) in ROLE_TARGETS.items():
|
||||
for role, (kind, targets) in ROLE_TARGETS.items():
|
||||
# The first type represents the family when reporting.
|
||||
target = targets[0]
|
||||
command = (
|
||||
["xdg-settings", "get", target]
|
||||
if kind == "settings"
|
||||
@@ -179,13 +213,85 @@ def set_default(role: str, desktop_id: str) -> None:
|
||||
if target is None:
|
||||
raise BoundaryError("That default application role is not supported.")
|
||||
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
|
||||
kind, setting = target
|
||||
command = (
|
||||
["xdg-settings", "set", setting, desktop_id]
|
||||
if kind == "settings"
|
||||
else ["xdg-mime", "default", desktop_id, setting]
|
||||
)
|
||||
run(command)
|
||||
kind, settings = target
|
||||
if kind == "settings":
|
||||
run(["xdg-settings", "set", settings[0], desktop_id])
|
||||
return
|
||||
# Every type in the family, so a role cannot be half-applied. xdg-mime
|
||||
# accepts several types in one call, but they are written individually so a
|
||||
# type this system does not know about cannot fail the whole role.
|
||||
for setting in settings:
|
||||
run(["xdg-mime", "default", desktop_id, setting])
|
||||
|
||||
|
||||
# What this desktop opens a file with, when nobody has said otherwise.
|
||||
#
|
||||
# Applications register themselves for every type they can technically read, so
|
||||
# an unattended machine decides these by installation order: a pixel-art editor
|
||||
# claims PNG, a video transcoder claims MP3, an image editor claims PDF. None of
|
||||
# that is a choice anyone made, and it is only discovered by double-clicking.
|
||||
#
|
||||
# Each role lists candidates best-first; the first one installed wins. A role
|
||||
# with no candidate installed is left alone rather than forced.
|
||||
PREFERRED_HANDLERS = {
|
||||
"images": ["org.gnome.Loupe.desktop", "org.gnome.eog.desktop"],
|
||||
"music": ["org.gnome.Decibels.desktop", "io.bassi.Amberol.desktop", "io.mpv.Mpv.desktop"],
|
||||
"video": ["io.mpv.Mpv.desktop", "mpv.desktop", "org.gnome.Totem.desktop"],
|
||||
"documents": ["org.gnome.Papers.desktop", "org.gnome.Evince.desktop"],
|
||||
"text": ["panama-nvim.desktop"],
|
||||
"archives": ["org.gnome.Nautilus.desktop", "org.gnome.FileRoller.desktop"],
|
||||
}
|
||||
|
||||
|
||||
def user_mimeapps() -> Path:
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return config_home / "mimeapps.list"
|
||||
|
||||
|
||||
def chosen_types() -> set[str]:
|
||||
"""Types the person using this machine has already assigned by hand.
|
||||
|
||||
A type listed under [Default Applications] in the user's own mimeapps.list
|
||||
got there because someone picked "Open With" and made it stick, or because
|
||||
the settings page wrote it. Seeding must never overrule that.
|
||||
"""
|
||||
path = user_mimeapps()
|
||||
if not path.is_file():
|
||||
return set()
|
||||
chosen: set[str] = set()
|
||||
section = ""
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError):
|
||||
return set()
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
section = stripped[1:-1]
|
||||
continue
|
||||
if section != "Default Applications" or "=" not in line or stripped.startswith("#"):
|
||||
continue
|
||||
chosen.add(line.split("=", 1)[0].strip())
|
||||
return chosen
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
"""Apply Panama's curated defaults to roles nobody has chosen for."""
|
||||
discovered = discovered_desktop_ids()
|
||||
already = chosen_types()
|
||||
for role, candidates in PREFERRED_HANDLERS.items():
|
||||
kind, targets = ROLE_TARGETS[role]
|
||||
if kind != "mime":
|
||||
continue
|
||||
if any(target in already for target in targets):
|
||||
print(f"{role}: keeping the existing choice")
|
||||
continue
|
||||
preferred = next((entry for entry in candidates if entry in discovered), None)
|
||||
if preferred is None:
|
||||
print(f"{role}: no preferred application installed, leaving it alone")
|
||||
continue
|
||||
set_default(role, preferred)
|
||||
print(f"{role}: {preferred}")
|
||||
|
||||
|
||||
def with_hidden(original: str, *, hidden: bool) -> str:
|
||||
@@ -246,6 +352,38 @@ def update_hidden(path: Path, *, hidden: bool) -> None:
|
||||
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
|
||||
|
||||
|
||||
def remove_autostart(desktop_id: str) -> None:
|
||||
"""Delete a user autostart entry.
|
||||
|
||||
Disabling writes Hidden=true and is reversible; this is not, so it is
|
||||
confined to files this directory owns. A symlink is refused rather than
|
||||
followed, because deleting through one would remove whatever it points at --
|
||||
which is somewhere else entirely, and not ours.
|
||||
"""
|
||||
if not DESKTOP_ID.fullmatch(desktop_id):
|
||||
raise BoundaryError("That is not an autostart entry name.")
|
||||
|
||||
directory = autostart_directory()
|
||||
target = directory / desktop_id
|
||||
|
||||
# Resolved and compared, so a name like "../../.bashrc" cannot escape.
|
||||
try:
|
||||
resolved = target.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise BoundaryError("That autostart entry no longer exists.") from error
|
||||
if resolved.parent != directory.resolve(strict=False):
|
||||
raise BoundaryError("That autostart entry is not in the autostart directory.")
|
||||
if target.is_symlink() or not target.is_file():
|
||||
raise BoundaryError("That autostart entry is not a file this can remove.")
|
||||
if target.suffix != ".desktop":
|
||||
raise BoundaryError("That autostart entry is not a desktop file.")
|
||||
|
||||
try:
|
||||
target.unlink()
|
||||
except OSError as error:
|
||||
raise BoundaryError("That autostart entry could not be removed.") from error
|
||||
|
||||
|
||||
def add_autostart(desktop_id: str) -> None:
|
||||
desktop_files = discovered_desktop_files()
|
||||
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
||||
@@ -294,15 +432,19 @@ def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
elif arguments == ["seed"]:
|
||||
seed()
|
||||
elif len(arguments) == 3 and arguments[0] == "set-default":
|
||||
set_default(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||
set_autostart(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "remove-autostart":
|
||||
remove_autostart(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||
add_autostart(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
||||
"Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
|
||||
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||
)
|
||||
except BoundaryError as error:
|
||||
|
||||
Executable
+372
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Read storage layout, usage, and drive health for Panama's Storage page.
|
||||
|
||||
Two boundaries, deliberately separate:
|
||||
|
||||
snapshot topology, usage, health, removable media. Cheap -- lsblk and a
|
||||
single udisks call -- so the page can open with it.
|
||||
scan what is actually filling the drive. Expensive: measuring a folder
|
||||
means walking it, and this machine has a 1.2 TiB Steam library.
|
||||
The page asks for this on demand and remembers the answer.
|
||||
|
||||
unmount PATH / eject PATH removable media only, by explicit request.
|
||||
|
||||
Deliberately absent: partitioning and formatting. A settings pane is the wrong
|
||||
place to hand someone a way to erase a disk in two clicks; GNOME Disks is one
|
||||
button away on the page for that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Bounded so a pathological tree cannot hang the page. A folder that cannot be
|
||||
# measured in this long is reported as unmeasured rather than silently omitted,
|
||||
# because a missing row reads as "this folder is small".
|
||||
SCAN_TIMEOUT_SECONDS = 90
|
||||
|
||||
# Where "what is filling my drive" actually gets answered. Ordered so the most
|
||||
# likely culprits are measured first; the walk stops when the budget runs out.
|
||||
SCAN_TARGETS = [
|
||||
("Steam library", "~/.local/share/Steam"),
|
||||
("Documents", "~/Documents"),
|
||||
("Downloads", "~/Downloads"),
|
||||
("Videos", "~/Videos"),
|
||||
("Pictures", "~/Pictures"),
|
||||
("Music", "~/Music"),
|
||||
("Flatpak applications", "~/.var/app"),
|
||||
("Caches", "~/.cache"),
|
||||
("Trash", "~/.local/share/Trash"),
|
||||
]
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or command failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 15.0) -> str:
|
||||
try:
|
||||
completed = subprocess.run(command, check=False, capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer") from error
|
||||
if completed.returncode != 0:
|
||||
raise BoundaryError(completed.stderr.strip() or f"{command[0]} failed")
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def lsblk() -> list[dict]:
|
||||
"""The block device tree.
|
||||
|
||||
PANAMA_DISKS_LSBLK substitutes a recorded tree for the real one. It exists
|
||||
so the refusal rules below can be tested against a removable drive that is
|
||||
not plugged in -- the alternative is a test that runs unmount against
|
||||
whatever is actually mounted, which is not a test anyone should write.
|
||||
"""
|
||||
fixture = os.environ.get("PANAMA_DISKS_LSBLK")
|
||||
if fixture:
|
||||
try:
|
||||
return json.loads(Path(fixture).read_text(encoding="utf-8"))["blockdevices"]
|
||||
except (OSError, json.JSONDecodeError, KeyError) as error:
|
||||
raise BoundaryError("could not read the recorded block device layout") from error
|
||||
fields = ("NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINTS,MODEL,SERIAL,RM,HOTPLUG,"
|
||||
"ROTA,FSSIZE,FSUSED,FSAVAIL")
|
||||
try:
|
||||
return json.loads(run(["lsblk", "-J", "-b", "-o", fields]))["blockdevices"]
|
||||
except (json.JSONDecodeError, KeyError) as error:
|
||||
raise BoundaryError("could not read the block device layout") from error
|
||||
|
||||
|
||||
def udisks_objects() -> dict:
|
||||
"""Every udisks object in one call, or nothing if udisks is not running.
|
||||
|
||||
Health is a nice-to-have: a machine without udisks still gets its layout and
|
||||
usage, which is most of the page. Failing the whole snapshot because a
|
||||
temperature is unavailable would be the wrong trade.
|
||||
"""
|
||||
if not shutil.which("busctl"):
|
||||
return {}
|
||||
try:
|
||||
raw = run(["busctl", "--system", "--json=short", "call",
|
||||
"org.freedesktop.UDisks2", "/org/freedesktop/UDisks2",
|
||||
"org.freedesktop.DBus.ObjectManager", "GetManagedObjects"])
|
||||
payload = json.loads(raw)
|
||||
except (BoundaryError, json.JSONDecodeError):
|
||||
return {}
|
||||
# busctl wraps the reply as {"type": "...", "data": [ {path: {iface: {prop: {"type","data"}}}} ]}
|
||||
data = payload.get("data") or []
|
||||
return data[0] if data else {}
|
||||
|
||||
|
||||
def unwrap(value):
|
||||
"""busctl encodes every value as {"type": t, "data": v}."""
|
||||
if isinstance(value, dict) and "data" in value and "type" in value:
|
||||
return value["data"]
|
||||
return value
|
||||
|
||||
|
||||
def drive_health(objects: dict) -> dict[str, dict]:
|
||||
"""Health keyed by drive serial, which is the only stable join to lsblk."""
|
||||
health: dict[str, dict] = {}
|
||||
for path, interfaces in objects.items():
|
||||
if "/drives/" not in path:
|
||||
continue
|
||||
drive = {key: unwrap(value)
|
||||
for key, value in (interfaces.get("org.freedesktop.UDisks2.Drive") or {}).items()}
|
||||
serial = str(drive.get("Serial") or "")
|
||||
if not serial:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"ejectable": bool(drive.get("Ejectable")),
|
||||
"removable": bool(drive.get("Removable")),
|
||||
"temperatureC": None,
|
||||
"powerOnHours": None,
|
||||
"healthy": None,
|
||||
"warnings": [],
|
||||
"selfTest": "",
|
||||
}
|
||||
|
||||
nvme = {key: unwrap(value) for key, value in
|
||||
(interfaces.get("org.freedesktop.UDisks2.NVMe.Controller") or {}).items()}
|
||||
ata = {key: unwrap(value) for key, value in
|
||||
(interfaces.get("org.freedesktop.UDisks2.Drive.Ata") or {}).items()}
|
||||
|
||||
if nvme:
|
||||
kelvin = nvme.get("SmartTemperature")
|
||||
if isinstance(kelvin, (int, float)) and kelvin > 0:
|
||||
entry["temperatureC"] = round(kelvin - 273.15, 1)
|
||||
hours = nvme.get("SmartPowerOnHours")
|
||||
if isinstance(hours, (int, float)):
|
||||
entry["powerOnHours"] = int(hours)
|
||||
warnings = nvme.get("SmartCriticalWarning") or []
|
||||
entry["warnings"] = [str(item) for item in warnings]
|
||||
entry["healthy"] = not entry["warnings"]
|
||||
entry["selfTest"] = str(nvme.get("SmartSelftestStatus") or "")
|
||||
elif ata:
|
||||
kelvin = ata.get("SmartTemperature")
|
||||
if isinstance(kelvin, (int, float)) and kelvin > 0:
|
||||
entry["temperatureC"] = round(kelvin - 273.15, 1)
|
||||
seconds = ata.get("SmartPowerOnSeconds")
|
||||
if isinstance(seconds, (int, float)) and seconds > 0:
|
||||
entry["powerOnHours"] = int(seconds // 3600)
|
||||
failing = ata.get("SmartFailing")
|
||||
if isinstance(failing, bool):
|
||||
entry["healthy"] = not failing
|
||||
entry["warnings"] = ["failing"] if failing else []
|
||||
entry["selfTest"] = str(ata.get("SmartSelftestStatus") or "")
|
||||
|
||||
health[serial] = entry
|
||||
return health
|
||||
|
||||
|
||||
def walk(node: dict, depth: int = 0):
|
||||
yield node, depth
|
||||
for child in node.get("children") or []:
|
||||
yield from walk(child, depth + 1)
|
||||
|
||||
|
||||
def mountpoints(node: dict) -> list[str]:
|
||||
return [point for point in (node.get("mountpoints") or []) if point and point != "[SWAP]"]
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
tree = lsblk()
|
||||
health = drive_health(udisks_objects())
|
||||
|
||||
drives = []
|
||||
swap = []
|
||||
filesystems: dict[str, dict] = {}
|
||||
|
||||
for root in tree:
|
||||
if root.get("type") != "disk":
|
||||
continue
|
||||
|
||||
# zram is compressed swap in RAM. It is a block device and it is not
|
||||
# storage; listing it as a drive would be actively misleading about how
|
||||
# much of this machine is disk.
|
||||
if str(root.get("name", "")).startswith("zram"):
|
||||
swap.append({
|
||||
"name": root.get("name"),
|
||||
"sizeBytes": root.get("size") or 0,
|
||||
"kind": "zram",
|
||||
})
|
||||
continue
|
||||
|
||||
serial = str(root.get("serial") or "")
|
||||
drive_health_entry = health.get(serial, {})
|
||||
partitions = []
|
||||
encrypted = False
|
||||
|
||||
for node, depth in walk(root):
|
||||
if depth == 0:
|
||||
continue
|
||||
if node.get("fstype") == "crypto_LUKS":
|
||||
encrypted = True
|
||||
if node.get("type") in ("part", "crypt"):
|
||||
partitions.append({
|
||||
"name": node.get("name"),
|
||||
"path": node.get("path"),
|
||||
"sizeBytes": node.get("size") or 0,
|
||||
"fstype": node.get("fstype") or "",
|
||||
"type": node.get("type"),
|
||||
"mountpoints": mountpoints(node),
|
||||
})
|
||||
for point in mountpoints(node):
|
||||
if node.get("fssize") is None:
|
||||
continue
|
||||
# Keyed by device: btrfs subvolumes mounted at / and /home are
|
||||
# ONE filesystem with one pool of free space. Reporting them as
|
||||
# two independent bars, which is what df does, doubles the free
|
||||
# space on screen.
|
||||
key = str(node.get("path"))
|
||||
entry = filesystems.setdefault(key, {
|
||||
"device": node.get("path"),
|
||||
"fstype": node.get("fstype") or "",
|
||||
"sizeBytes": int(node.get("fssize") or 0),
|
||||
"usedBytes": int(node.get("fsused") or 0),
|
||||
"availBytes": int(node.get("fsavail") or 0),
|
||||
"mountpoints": [],
|
||||
"encrypted": node.get("type") == "crypt",
|
||||
})
|
||||
entry["mountpoints"].append(point)
|
||||
entry["mountpoints"].sort(key=lambda mount: (mount != "/", mount))
|
||||
|
||||
drives.append({
|
||||
"name": root.get("name"),
|
||||
"path": root.get("path"),
|
||||
"model": (root.get("model") or "").strip() or str(root.get("name")),
|
||||
"serial": serial,
|
||||
"sizeBytes": root.get("size") or 0,
|
||||
"rotational": bool(root.get("rota")),
|
||||
"removable": bool(root.get("rm")) or bool(root.get("hotplug"))
|
||||
or bool(drive_health_entry.get("removable")),
|
||||
"ejectable": bool(drive_health_entry.get("ejectable")),
|
||||
"encrypted": encrypted,
|
||||
"partitions": partitions,
|
||||
"temperatureC": drive_health_entry.get("temperatureC"),
|
||||
"powerOnHours": drive_health_entry.get("powerOnHours"),
|
||||
"healthy": drive_health_entry.get("healthy"),
|
||||
"warnings": drive_health_entry.get("warnings", []),
|
||||
"selfTest": drive_health_entry.get("selfTest", ""),
|
||||
})
|
||||
|
||||
ordered = sorted(filesystems.values(),
|
||||
key=lambda entry: (0 if "/" in entry["mountpoints"] else 1,
|
||||
entry["mountpoints"][0] if entry["mountpoints"] else ""))
|
||||
return {"drives": drives, "filesystems": ordered, "swap": swap}
|
||||
|
||||
|
||||
def measure(path: Path, budget: float) -> tuple[int | None, float]:
|
||||
"""Bytes used by a folder, and what is left of the time budget."""
|
||||
if not path.is_dir():
|
||||
return None, budget
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completed = subprocess.run(["du", "-sxb", str(path)], check=False,
|
||||
capture_output=True, text=True, timeout=budget)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
# A folder that ran out of time has consumed the whole budget by
|
||||
# definition; the caller stops rather than starting another walk.
|
||||
return None, 0.0
|
||||
left = max(budget - (time.monotonic() - started), 0.0)
|
||||
if completed.returncode != 0:
|
||||
return None, left
|
||||
match = re.match(r"^(\d+)", completed.stdout)
|
||||
return (int(match.group(1)) if match else None), left
|
||||
|
||||
|
||||
def container_reclaimable() -> dict | None:
|
||||
"""Container images nothing is using. Absent when podman is not installed."""
|
||||
if not shutil.which("podman"):
|
||||
return None
|
||||
try:
|
||||
raw = run(["podman", "system", "df", "--format", "json"], timeout=20.0)
|
||||
entries = json.loads(raw)
|
||||
except (BoundaryError, json.JSONDecodeError):
|
||||
return None
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
if not str(entry.get("Type", "")).lower().startswith("image"):
|
||||
continue
|
||||
# Raw* are integers; Size and Reclaimable are display strings like
|
||||
# "10.8GB" and "5.31GB (49%)". Reading the display strings is how this
|
||||
# first crashed, so only the raw fields are trusted, and an old podman
|
||||
# that lacks them reports nothing rather than a wrong number.
|
||||
total = entry.get("RawSize")
|
||||
reclaimable = entry.get("RawReclaimable")
|
||||
if not isinstance(total, int) or not isinstance(reclaimable, int):
|
||||
return None
|
||||
return {"totalBytes": total, "reclaimableBytes": reclaimable}
|
||||
return None
|
||||
|
||||
|
||||
def scan() -> dict:
|
||||
folders = []
|
||||
remaining = float(SCAN_TIMEOUT_SECONDS)
|
||||
truncated = False
|
||||
for label, target in SCAN_TARGETS:
|
||||
path = Path(os.path.expanduser(target))
|
||||
if remaining <= 1.0:
|
||||
truncated = True
|
||||
break
|
||||
size, remaining = measure(path, remaining)
|
||||
if size is None:
|
||||
continue
|
||||
folders.append({"label": label, "path": str(path), "bytes": size})
|
||||
folders.sort(key=lambda item: item["bytes"], reverse=True)
|
||||
return {
|
||||
"folders": folders,
|
||||
"truncated": truncated,
|
||||
"containers": container_reclaimable(),
|
||||
}
|
||||
|
||||
|
||||
def removable_device(path: str) -> dict:
|
||||
"""Resolve a device path, refusing anything that is not removable.
|
||||
|
||||
Unmounting the root filesystem is not a feature. The check is on the device
|
||||
rather than the button because the caller is a UI that can be wrong.
|
||||
"""
|
||||
if not re.fullmatch(r"/dev/[A-Za-z0-9/_-]+", path or ""):
|
||||
raise BoundaryError("That is not a device path.")
|
||||
for root in lsblk():
|
||||
for node, _ in walk(root):
|
||||
if node.get("path") != path:
|
||||
continue
|
||||
top = root
|
||||
if not (bool(top.get("rm")) or bool(top.get("hotplug"))):
|
||||
raise BoundaryError("That drive is not removable.")
|
||||
return node
|
||||
raise BoundaryError("No such device.")
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
elif arguments == ["scan"]:
|
||||
print(json.dumps(scan(), separators=(",", ":")))
|
||||
elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"):
|
||||
removable_device(arguments[1])
|
||||
action = "unmount" if arguments[0] == "unmount" else "power-off"
|
||||
flag = "-b" if arguments[0] == "unmount" else "-b"
|
||||
run(["udisksctl", action, flag, arguments[1]], timeout=30.0)
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE")
|
||||
except BoundaryError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -13,6 +13,7 @@ import re
|
||||
import secrets
|
||||
import signal
|
||||
import shutil
|
||||
import time
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -115,7 +116,7 @@ CHECK_ORDER = (
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "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",
|
||||
"integration.home-assistant", "integration.calendar", "panama.updates", "panama.runtime-links", "panama.vicinae-commands",
|
||||
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
|
||||
)
|
||||
|
||||
@@ -485,6 +486,61 @@ def check_calendar(config: DoctorConfig) -> Check:
|
||||
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
|
||||
|
||||
|
||||
def check_updates(config: DoctorConfig) -> Check:
|
||||
"""Whether the machine is current, and whether it is running what it installed.
|
||||
|
||||
Two different questions with two different answers. A kernel that has been
|
||||
installed but not booted into is the one people miss: everything reports
|
||||
success, nothing looks wrong, and the security fix they installed last week
|
||||
is sitting on disk unused. That is reported as its own state rather than
|
||||
folded into "updates available".
|
||||
|
||||
Read from the Updates page's cache rather than by scanning: a health check
|
||||
that took nine seconds of network work would make opening System Health feel
|
||||
broken. A stale cache is reported as stale.
|
||||
"""
|
||||
cache = Path(os.environ.get("XDG_CACHE_HOME", config.home / ".cache")) / "panama" / "updates.json"
|
||||
running = os.uname().release
|
||||
|
||||
newest = running
|
||||
rpm_query = run_command(("rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"), config)
|
||||
if rpm_query.state == "ok":
|
||||
installed = [line.strip() for line in rpm_query.stdout.splitlines() if line.strip()]
|
||||
if installed:
|
||||
newest = installed[-1]
|
||||
if newest != running:
|
||||
return Check("panama.updates", "panama-tools", "Software updates", "warning",
|
||||
f"A newer kernel is installed than the one running ({running} → {newest}). Restart to use it.",
|
||||
action=Action("open", "Open Software Update"))
|
||||
|
||||
try:
|
||||
payload = json.loads(cache.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
|
||||
"Updates have not been checked yet.",
|
||||
action=Action("open", "Open Software Update"))
|
||||
|
||||
checked_at = int(payload.get("checkedAt", 0))
|
||||
age_days = (time.time() - checked_at) / 86400 if checked_at else 999
|
||||
security = int(payload.get("dnf", {}).get("securityCount", 0))
|
||||
total = sum(int(payload.get(source, {}).get("count", 0))
|
||||
for source in ("dnf", "flatpak", "firmware"))
|
||||
|
||||
if security > 0:
|
||||
return Check("panama.updates", "panama-tools", "Software updates", "warning",
|
||||
f"{security} pending update{'' if security == 1 else 's'} carry a security advisory.",
|
||||
action=Action("open", "Open Software Update"))
|
||||
if age_days > 7:
|
||||
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
|
||||
"Updates have not been checked in over a week.",
|
||||
action=Action("open", "Open Software Update"))
|
||||
if total > 0:
|
||||
return Check("panama.updates", "panama-tools", "Software updates", "ok",
|
||||
f"{total} update{'' if total == 1 else 's'} available, none carrying a security advisory.")
|
||||
return Check("panama.updates", "panama-tools", "Software updates", "ok",
|
||||
"Everything is current.")
|
||||
|
||||
|
||||
def check_runtime_links(config: DoctorConfig) -> Check:
|
||||
def valid_link(name: str, relative_source: Path) -> bool:
|
||||
destination = config.config_home / name
|
||||
@@ -601,7 +657,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
|
||||
"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.hyprlock": lambda: check_hyprlock(config), "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),
|
||||
"panama.updates": lambda: check_updates(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}
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""The firewall, answered as "what can another machine reach?"
|
||||
|
||||
Listing zones and services is what firewall-cmd already does. The question it
|
||||
does not answer is the one that matters, because it needs both halves at once: a
|
||||
port is reachable only when something is LISTENING on a network address AND the
|
||||
firewall permits it. Either alone tells you nothing -- which is how a tidy set of
|
||||
rules coexists with an exposed database, as it does on this machine, where
|
||||
Fedora Workstation's zone opens every port above 1024 and rootless containers
|
||||
publish on all interfaces.
|
||||
|
||||
Changes go through firewall-cmd, which is polkit-aware, so they prompt.
|
||||
|
||||
panama-firewall snapshot
|
||||
panama-firewall add-service NAME | remove-service NAME
|
||||
panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO
|
||||
panama-firewall set-zone INTERFACE ZONE
|
||||
panama-firewall set-default-zone ZONE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ZONE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
SERVICE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
INTERFACE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
|
||||
PORT_SPEC = re.compile(r"^(\d{1,5})(?:-(\d{1,5}))?/(tcp|udp)$")
|
||||
|
||||
# Things whose exposure is worth saying out loud. Not a judgement about the
|
||||
# software -- a database on the network is simply a different risk from a
|
||||
# printer, and someone should know which they have.
|
||||
DATA_STORES = {
|
||||
5432: "PostgreSQL", 3306: "MySQL", 3307: "MySQL", 6379: "Redis",
|
||||
27017: "MongoDB", 5984: "CouchDB", 9200: "Elasticsearch", 11211: "memcached",
|
||||
5433: "PostgreSQL", 1433: "SQL Server", 8086: "InfluxDB", 7000: "Cassandra",
|
||||
}
|
||||
|
||||
# Ports whose purpose is worth naming when nothing else identifies them.
|
||||
WELL_KNOWN = {
|
||||
22: "SSH", 3389: "Remote desktop", 5353: "mDNS", 5355: "LLMNR",
|
||||
1716: "KDE Connect", 631: "Printing", 139: "Samba", 445: "Samba",
|
||||
3000: "Development server", 8080: "HTTP alternate",
|
||||
}
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or firewall failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer.") from error
|
||||
|
||||
|
||||
def firewall(*arguments: str, timeout: float = 30.0) -> str:
|
||||
if not shutil.which("firewall-cmd"):
|
||||
raise BoundaryError("firewalld is not installed.")
|
||||
result = run(["firewall-cmd", *arguments], timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
message = (result.stderr or result.stdout).strip().splitlines()
|
||||
text = message[-1] if message else "The firewall could not be read."
|
||||
if "not authorized" in text.lower() or "dismissed" in text.lower():
|
||||
raise BoundaryError("That firewall change was not authorized.")
|
||||
raise BoundaryError(text[:200])
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def zone_detail(name: str) -> dict:
|
||||
try:
|
||||
raw = firewall(f"--zone={name}", "--list-all")
|
||||
except BoundaryError:
|
||||
return {}
|
||||
detail = {"name": name, "interfaces": [], "services": [], "ports": [],
|
||||
"richRules": [], "target": ""}
|
||||
key = ""
|
||||
for line in raw.splitlines()[1:]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.strip().partition(":")
|
||||
value = value.strip()
|
||||
if key == "interfaces":
|
||||
detail["interfaces"] = value.split()
|
||||
elif key == "services":
|
||||
detail["services"] = value.split()
|
||||
elif key == "ports":
|
||||
detail["ports"] = value.split()
|
||||
elif key == "target":
|
||||
detail["target"] = value
|
||||
elif key == "rich rules":
|
||||
detail["richRules"] = [rule for rule in value.splitlines() if rule.strip()]
|
||||
return detail
|
||||
|
||||
|
||||
def service_ports(name: str) -> list[str]:
|
||||
"""The ports a named service stands for, from firewalld's own definition."""
|
||||
recorded = fixture()
|
||||
if recorded is not None:
|
||||
return list(recorded.get("servicePorts", {}).get(name, []))
|
||||
try:
|
||||
return firewall("--permanent", f"--service={name}", "--get-ports").split()
|
||||
except BoundaryError:
|
||||
return []
|
||||
|
||||
|
||||
def allowed_ports(zone: dict) -> list[tuple[int, int, str, str]]:
|
||||
"""Every port the zone permits, as (low, high, protocol, what allowed it)."""
|
||||
allowed = []
|
||||
for spec in zone.get("ports", []):
|
||||
found = PORT_SPEC.match(spec)
|
||||
if found:
|
||||
low = int(found.group(1))
|
||||
high = int(found.group(2) or found.group(1))
|
||||
allowed.append((low, high, found.group(3), "the open port range"))
|
||||
for service in zone.get("services", []):
|
||||
for spec in service_ports(service):
|
||||
found = PORT_SPEC.match(spec)
|
||||
if found:
|
||||
low = int(found.group(1))
|
||||
high = int(found.group(2) or found.group(1))
|
||||
allowed.append((low, high, found.group(3), f"the {service} service"))
|
||||
return allowed
|
||||
|
||||
|
||||
# Above this, a UDP socket is almost certainly the local end of an outbound
|
||||
# conversation -- a browser talking to the internet -- rather than a service
|
||||
# waiting to be contacted. Listing those as "exposed" buries the two rows that
|
||||
# matter under twenty that do not.
|
||||
EPHEMERAL_FLOOR = 32768
|
||||
|
||||
|
||||
def fixture() -> dict | None:
|
||||
"""A recorded firewall and set of listeners, for testing the crossing.
|
||||
|
||||
The rule this page exists for -- exposed means listening AND permitted --
|
||||
cannot be tested against a machine whose firewall permits everything, and
|
||||
the blocked case needs a listener below port 1024, which needs root to
|
||||
create. So the inputs can be supplied instead. Live behaviour is unchanged
|
||||
when the variable is unset.
|
||||
"""
|
||||
path = os.environ.get("PANAMA_FIREWALL_FIXTURE")
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise BoundaryError("The recorded firewall state could not be read.") from error
|
||||
|
||||
|
||||
def listeners() -> list[dict]:
|
||||
"""Sockets a machine on the network could actually connect TO.
|
||||
|
||||
TCP listeners are unambiguous: they are in LISTEN state because something
|
||||
intends to accept connections. UDP has no such state, so a browser's
|
||||
outbound socket looks identical to a service -- which is why an ephemeral
|
||||
UDP port with no well-known meaning is left out rather than reported as an
|
||||
exposure someone should worry about.
|
||||
"""
|
||||
recorded = fixture()
|
||||
if recorded is not None:
|
||||
return list(recorded.get("listeners", []))
|
||||
|
||||
result = run(["ss", "-tulpnH"])
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
found = {}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
protocol = parts[0]
|
||||
address = parts[4]
|
||||
# Loopback is not reachable from anywhere else, so it is not exposure.
|
||||
if address.startswith(("127.", "[::1]")):
|
||||
continue
|
||||
port_text = address.rsplit(":", 1)[-1]
|
||||
if not port_text.isdigit():
|
||||
continue
|
||||
process = ""
|
||||
owner = re.search(r'users:\(\("([^"]+)"', line)
|
||||
if owner:
|
||||
process = owner.group(1)
|
||||
port = int(port_text)
|
||||
if (protocol.startswith("udp") and port >= EPHEMERAL_FLOOR
|
||||
and port not in WELL_KNOWN and port not in DATA_STORES):
|
||||
continue
|
||||
key = (port, protocol)
|
||||
# A port bound on several addresses is one exposure, not four.
|
||||
if key not in found or (process and not found[key]["process"]):
|
||||
found[key] = {"port": port, "protocol": protocol, "process": process,
|
||||
"address": address}
|
||||
return sorted(found.values(), key=lambda entry: entry["port"])
|
||||
|
||||
|
||||
def describe(entry: dict) -> tuple[str, str]:
|
||||
"""A name for what is listening, and how much it matters."""
|
||||
port = entry["port"]
|
||||
if port in DATA_STORES:
|
||||
return DATA_STORES[port], "data"
|
||||
if port in WELL_KNOWN:
|
||||
return WELL_KNOWN[port], "known"
|
||||
process = entry.get("process") or ""
|
||||
return (process or f"port {port}"), "other"
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
recorded = fixture()
|
||||
if recorded is not None:
|
||||
zones = list(recorded.get("zones", []))
|
||||
permitted = []
|
||||
for zone in zones:
|
||||
permitted.extend(allowed_ports(zone))
|
||||
exposed = []
|
||||
for entry in listeners():
|
||||
allowed_by = ""
|
||||
for low, high, protocol, reason in permitted:
|
||||
if str(entry.get("protocol", "")).startswith(protocol) and low <= int(entry["port"]) <= high:
|
||||
if not allowed_by or reason != "the open port range":
|
||||
allowed_by = reason
|
||||
if not allowed_by:
|
||||
continue
|
||||
name, kind = describe(entry)
|
||||
exposed.append({"name": name, "port": int(entry["port"]),
|
||||
"protocol": entry.get("protocol", ""),
|
||||
"process": entry.get("process", ""),
|
||||
"allowedBy": allowed_by, "kind": kind})
|
||||
return {"running": True, "enabledAtBoot": True, "available": True,
|
||||
"defaultZone": zones[0]["name"] if zones else "", "allZones": [],
|
||||
"activeZones": {}, "zones": zones, "exposed": exposed,
|
||||
"exposedDataStores": [e for e in exposed if e["kind"] == "data"],
|
||||
"sshSessions": 0, "error": ""}
|
||||
|
||||
running = run(["systemctl", "is-active", "firewalld"]).stdout.strip() == "active"
|
||||
enabled = run(["systemctl", "is-enabled", "firewalld"]).stdout.strip() == "enabled"
|
||||
if not shutil.which("firewall-cmd") or not running:
|
||||
return {"running": running, "enabledAtBoot": enabled, "available": False,
|
||||
"zones": [], "activeZones": {}, "defaultZone": "", "exposed": [],
|
||||
"allZones": [], "error": ""}
|
||||
|
||||
default_zone = firewall("--get-default-zone")
|
||||
all_zones = firewall("--get-zones").split()
|
||||
|
||||
active = {}
|
||||
current = ""
|
||||
for line in firewall("--get-active-zones").splitlines():
|
||||
if not line.startswith(" "):
|
||||
current = line.split()[0] if line.split() else ""
|
||||
continue
|
||||
if "interfaces:" in line and current:
|
||||
active[current] = line.split(":", 1)[1].split()
|
||||
|
||||
zones = [zone_detail(name) for name in active] or [zone_detail(default_zone)]
|
||||
zones = [zone for zone in zones if zone]
|
||||
|
||||
# The cross-reference: listening AND permitted.
|
||||
permitted = []
|
||||
for zone in zones:
|
||||
permitted.extend(allowed_ports(zone))
|
||||
|
||||
exposed = []
|
||||
for entry in listeners():
|
||||
allowed_by = ""
|
||||
for low, high, protocol, reason in permitted:
|
||||
if entry["protocol"].startswith(protocol) and low <= entry["port"] <= high:
|
||||
# A named service is a better explanation than a range.
|
||||
if not allowed_by or reason != "the open port range":
|
||||
allowed_by = reason
|
||||
if not allowed_by:
|
||||
continue
|
||||
name, kind = describe(entry)
|
||||
exposed.append({
|
||||
"name": name,
|
||||
"port": entry["port"],
|
||||
"protocol": entry["protocol"],
|
||||
"process": entry["process"],
|
||||
"allowedBy": allowed_by,
|
||||
"kind": kind,
|
||||
})
|
||||
|
||||
return {
|
||||
"running": running,
|
||||
"enabledAtBoot": enabled,
|
||||
"available": True,
|
||||
"defaultZone": default_zone,
|
||||
"allZones": all_zones,
|
||||
"activeZones": active,
|
||||
"zones": zones,
|
||||
"exposed": exposed,
|
||||
# Counted separately so the page can lead with it.
|
||||
"exposedDataStores": [entry for entry in exposed if entry["kind"] == "data"],
|
||||
"sshSessions": len([line for line in run(
|
||||
["ss", "-tnH", "state", "established", "( sport = :22 )"]).stdout.splitlines()
|
||||
if line.strip()]),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def require(pattern: re.Pattern, value: str, message: str) -> str:
|
||||
if not pattern.fullmatch(value or ""):
|
||||
raise BoundaryError(message)
|
||||
return value
|
||||
|
||||
|
||||
def change(zone: str, *arguments: str) -> None:
|
||||
"""Apply to the running firewall and to the stored configuration.
|
||||
|
||||
Both, because a change that survives a reboot but is not in effect -- or the
|
||||
reverse -- is a firewall nobody can reason about.
|
||||
"""
|
||||
firewall(f"--zone={zone}", *arguments, timeout=120)
|
||||
firewall("--permanent", f"--zone={zone}", *arguments, timeout=120)
|
||||
|
||||
|
||||
def active_zone() -> str:
|
||||
state = snapshot()
|
||||
zones = state.get("zones") or []
|
||||
return zones[0]["name"] if zones else state.get("defaultZone", "")
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"):
|
||||
name = require(SERVICE, arguments[1], "That is not a service name.")
|
||||
verb = "--add-service" if arguments[0] == "add-service" else "--remove-service"
|
||||
change(active_zone(), f"{verb}={name}")
|
||||
elif len(arguments) == 2 and arguments[0] in ("add-port", "remove-port"):
|
||||
spec = require(PORT_SPEC, arguments[1], "That is not a port.")
|
||||
verb = "--add-port" if arguments[0] == "add-port" else "--remove-port"
|
||||
change(active_zone(), f"{verb}={spec}")
|
||||
elif len(arguments) == 3 and arguments[0] == "set-zone":
|
||||
interface = require(INTERFACE, arguments[1], "That is not a network interface.")
|
||||
zone = require(ZONE, arguments[2], "That is not a zone.")
|
||||
firewall(f"--zone={zone}", f"--change-interface={interface}", timeout=120)
|
||||
firewall("--permanent", f"--zone={zone}", f"--change-interface={interface}", timeout=120)
|
||||
elif len(arguments) == 2 and arguments[0] == "set-default-zone":
|
||||
zone = require(ZONE, arguments[1], "That is not a zone.")
|
||||
firewall(f"--set-default-zone={zone}", timeout=120)
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-firewall snapshot | add-service NAME | remove-service NAME | "
|
||||
"add-port PORT/PROTO | remove-port PORT/PROTO | set-zone INTERFACE ZONE | "
|
||||
"set-default-zone ZONE")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"running": False, "available": False, "zones": [], "exposed": []}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+345
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Gaming: what the machine is doing, and what it should do while you play.
|
||||
|
||||
The interesting part is not reporting gamemode's state -- it is reacting to it.
|
||||
gamemode can run a script when a game asks for it and another when the game
|
||||
exits, so `hook start` and `hook end` are what let Panama switch the power
|
||||
profile and silence notifications for exactly the duration of a game, and put
|
||||
both back afterwards. Everything else here is honest reporting.
|
||||
|
||||
panama-gaming snapshot
|
||||
panama-gaming set-overlay true|false
|
||||
panama-gaming set-overlay-preset fps|detailed
|
||||
panama-gaming install-hooks | remove-hooks
|
||||
panama-gaming hook start|end (called by gamemode, not by a person)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MANGOHUD_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "MangoHud" / "MangoHud.conf"
|
||||
GAMEMODE_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "gamemode.ini"
|
||||
ENVIRONMENT_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "environment.d" / "panama-mangohud.conf"
|
||||
|
||||
# What the overlay shows. Deliberately two presets rather than exposing every
|
||||
# MangoHud key: this is a settings page, not a config file with a nicer font.
|
||||
PRESETS = {
|
||||
"fps": ["fps", "frametime=0", "no_display=0", "position=top-left",
|
||||
"font_size=22", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
|
||||
"detailed": ["fps", "frametime", "gpu_stats", "gpu_temp", "gpu_power",
|
||||
"cpu_stats", "cpu_temp", "ram", "vram", "position=top-left",
|
||||
"font_size=20", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
|
||||
}
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer.") from error
|
||||
|
||||
|
||||
def read_int(path: Path) -> int | None:
|
||||
try:
|
||||
return int(path.read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def gpus() -> list[dict]:
|
||||
"""Every AMD/Intel GPU with a hwmon node, warmest first.
|
||||
|
||||
Read from sysfs rather than a tool: it costs nothing, needs no daemon, and
|
||||
a page that polls while it is open should not be spawning processes.
|
||||
"""
|
||||
found = []
|
||||
for hwmon in sorted(Path("/sys/class/hwmon").glob("hwmon*")):
|
||||
try:
|
||||
name = (hwmon / "name").read_text().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if name not in ("amdgpu", "i915", "xe", "nouveau"):
|
||||
continue
|
||||
device = (hwmon / "device").resolve()
|
||||
temperature = read_int(hwmon / "temp1_input")
|
||||
power = read_int(hwmon / "power1_average")
|
||||
used = read_int(device / "mem_info_vram_used")
|
||||
total = read_int(device / "mem_info_vram_total")
|
||||
model = ""
|
||||
try:
|
||||
model = (device / "product_name").read_text().strip()
|
||||
except OSError:
|
||||
model = ""
|
||||
found.append({
|
||||
"driver": name,
|
||||
"model": model,
|
||||
"temperatureC": round(temperature / 1000, 1) if temperature else None,
|
||||
"watts": round(power / 1000000, 1) if power else None,
|
||||
"vramUsedBytes": used or 0,
|
||||
"vramTotalBytes": total or 0,
|
||||
# A card with no VRAM reported is the integrated one sharing system
|
||||
# memory; saying "0 of 0 GB" would look broken.
|
||||
"discrete": bool(total and total > 1073741824),
|
||||
})
|
||||
found.sort(key=lambda entry: (not entry["discrete"], entry["driver"]))
|
||||
return found
|
||||
|
||||
|
||||
def governor() -> str:
|
||||
try:
|
||||
return Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read_text().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def game_mode() -> dict:
|
||||
available = bool(shutil.which("gamemoded"))
|
||||
state = {"available": available, "active": False, "daemonRunning": False,
|
||||
"governorWhileGaming": "", "governorNow": governor()}
|
||||
if not available:
|
||||
return state
|
||||
status = run(["gamemoded", "-s"]).stdout.strip()
|
||||
state["active"] = "is active" in status
|
||||
state["daemonRunning"] = "gamemode is" in status
|
||||
|
||||
# What it would switch the governor to, from its own configuration.
|
||||
for path in (GAMEMODE_CONFIG, Path("/usr/share/gamemode/gamemode.ini")):
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError:
|
||||
continue
|
||||
found = re.search(r"^\s*desiredgov\s*=\s*(\S+)", text, re.M)
|
||||
if found:
|
||||
state["governorWhileGaming"] = found.group(1)
|
||||
break
|
||||
return state
|
||||
|
||||
|
||||
def hooks_installed() -> bool:
|
||||
try:
|
||||
text = GAMEMODE_CONFIG.read_text()
|
||||
except OSError:
|
||||
return False
|
||||
return "panama-gaming hook start" in text
|
||||
|
||||
|
||||
def overlay() -> dict:
|
||||
installed = bool(shutil.which("mangohud"))
|
||||
preset = ""
|
||||
if MANGOHUD_CONFIG.is_file():
|
||||
try:
|
||||
body = MANGOHUD_CONFIG.read_text()
|
||||
preset = "detailed" if "gpu_stats" in body else "fps"
|
||||
except OSError:
|
||||
preset = ""
|
||||
return {
|
||||
"installed": installed,
|
||||
"configured": MANGOHUD_CONFIG.is_file(),
|
||||
"preset": preset or "fps",
|
||||
# Global enablement is an environment variable read at session start, so
|
||||
# a change here does not affect anything already running.
|
||||
"globallyEnabled": ENVIRONMENT_CONFIG.is_file(),
|
||||
"toggleKey": "Shift_R+F12",
|
||||
}
|
||||
|
||||
|
||||
def library() -> dict:
|
||||
root = Path.home() / ".local/share/Steam"
|
||||
games = len(list((root / "steamapps").glob("*.acf"))) if (root / "steamapps").is_dir() else 0
|
||||
tools = []
|
||||
for directory in (root / "compatibilitytools.d", root / "steamapps/common"):
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for entry in sorted(directory.iterdir()):
|
||||
if entry.is_dir() and entry.name.lower().startswith("proton"):
|
||||
tools.append({
|
||||
"name": entry.name,
|
||||
"community": directory.name == "compatibilitytools.d",
|
||||
})
|
||||
return {
|
||||
"path": str(root),
|
||||
"games": games,
|
||||
"protonBuilds": tools,
|
||||
"gamescope": bool(shutil.which("gamescope")),
|
||||
"steam": bool(shutil.which("steam")),
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
return {
|
||||
"gameMode": {**game_mode(), "hooksInstalled": hooks_installed()},
|
||||
"gpus": gpus(),
|
||||
"overlay": overlay(),
|
||||
"library": library(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def set_overlay_preset(preset: str) -> None:
|
||||
if preset not in PRESETS:
|
||||
raise BoundaryError("That is not an overlay preset.")
|
||||
MANGOHUD_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
header = ("# Written by Panama's Gaming settings.\n"
|
||||
"# Edits here are replaced when the preset changes.\n")
|
||||
MANGOHUD_CONFIG.write_text(header + "\n".join(PRESETS[preset]) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def set_overlay(enabled: bool) -> None:
|
||||
if not shutil.which("mangohud"):
|
||||
raise BoundaryError("MangoHud is not installed.")
|
||||
if enabled:
|
||||
if not MANGOHUD_CONFIG.is_file():
|
||||
set_overlay_preset("fps")
|
||||
ENVIRONMENT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
ENVIRONMENT_CONFIG.write_text(
|
||||
"# Written by Panama's Gaming settings.\n"
|
||||
"# Read when the session starts, so this reaches applications\n"
|
||||
"# launched afterwards rather than ones already running.\n"
|
||||
"MANGOHUD=1\n", encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
ENVIRONMENT_CONFIG.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def install_hooks() -> None:
|
||||
"""Point gamemode's start and end hooks at this script.
|
||||
|
||||
Written by editing rather than replacing: gamemode.ini is the user's file
|
||||
and may hold settings this page does not manage.
|
||||
"""
|
||||
script = str(Path(__file__).resolve())
|
||||
lines = []
|
||||
if GAMEMODE_CONFIG.is_file():
|
||||
lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
|
||||
if "panama-gaming hook" not in line]
|
||||
text = "\n".join(lines)
|
||||
if "[custom]" not in text:
|
||||
text += "\n\n[custom]\n"
|
||||
text = re.sub(r"\[custom\]\n",
|
||||
f"[custom]\nstart={script} hook start\nend={script} hook end\n",
|
||||
text, count=1)
|
||||
GAMEMODE_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
GAMEMODE_CONFIG.write_text(text.strip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def remove_hooks() -> None:
|
||||
if not GAMEMODE_CONFIG.is_file():
|
||||
return
|
||||
lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
|
||||
if "panama-gaming hook" not in line]
|
||||
GAMEMODE_CONFIG.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def hook(phase: str) -> None:
|
||||
"""Called by gamemode when a game starts and when it stops.
|
||||
|
||||
Reads what the user asked for from the settings file directly: the shell may
|
||||
not be running, and a hook that depends on a running shell would silently do
|
||||
nothing for someone who restarted it mid-session.
|
||||
"""
|
||||
if phase not in ("start", "end"):
|
||||
raise BoundaryError("That is not a hook phase.")
|
||||
|
||||
settings_path = (Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
/ "panama" / "settings.json")
|
||||
try:
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
settings = {}
|
||||
|
||||
scripts = Path(__file__).resolve().parent
|
||||
# What was true before the game started, so ending it restores that rather
|
||||
# than imposing a default. Without this, finishing a game would silently
|
||||
# undo a power profile or a Do Not Disturb the user had chosen themselves.
|
||||
state_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-gaming.json"
|
||||
|
||||
if phase == "start":
|
||||
before = {}
|
||||
if settings.get("gamingPerformanceProfile", True):
|
||||
current = run([str(scripts / "panama-power-profile"), "list"], timeout=15)
|
||||
try:
|
||||
before["profile"] = json.loads(current.stdout or "{}").get("active", "")
|
||||
except json.JSONDecodeError:
|
||||
before["profile"] = ""
|
||||
run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15)
|
||||
|
||||
# Reported, not acted on. Silencing is a focus mode's job now, and two
|
||||
# things writing Do Not Disturb would each restore whatever the other
|
||||
# happened to leave behind. The hook knows a game started; what that
|
||||
# should mean is decided in one place.
|
||||
if shutil.which("qs"):
|
||||
run(["qs", "ipc", "call", "focus", "gameStarted"], timeout=10)
|
||||
|
||||
try:
|
||||
state_path.write_text(json.dumps(before), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"):
|
||||
run(["notify-send", "-a", "Panama", "Game Mode",
|
||||
"Performance profile engaged."], timeout=10)
|
||||
return
|
||||
|
||||
# end
|
||||
try:
|
||||
before = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
before = {}
|
||||
if before.get("profile"):
|
||||
run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15)
|
||||
if shutil.which("qs"):
|
||||
run(["qs", "ipc", "call", "focus", "gameEnded"], timeout=10)
|
||||
try:
|
||||
state_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "hook":
|
||||
hook(arguments[1])
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "set-overlay":
|
||||
set_overlay(arguments[1] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-overlay-preset":
|
||||
set_overlay_preset(arguments[1])
|
||||
elif arguments == ["install-hooks"]:
|
||||
install_hooks()
|
||||
elif arguments == ["remove-hooks"]:
|
||||
remove_hooks()
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-gaming snapshot | set-overlay true|false | "
|
||||
"set-overlay-preset fps|detailed | install-hooks | remove-hooks | "
|
||||
"hook start|end")
|
||||
except BoundaryError as error:
|
||||
state = snapshot()
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
@@ -88,33 +87,44 @@ def normalize_device_line(
|
||||
}
|
||||
|
||||
|
||||
# busctl properties are read with --json=short, not in its default text form.
|
||||
#
|
||||
# The text form escapes every non-ASCII byte in octal, and it escapes them into
|
||||
# the OUTPUT rather than into a quoted string a shell-style parser can undo -- so
|
||||
# a phone named "Gib's iPhone" with a typographic apostrophe arrived as the
|
||||
# literal characters "Gib\342\200\231s iPhone" and was displayed that way.
|
||||
# That is not specific to apostrophes: any name with an accent, an emoji, a
|
||||
# quote, or a backslash was affected the same way.
|
||||
#
|
||||
# The JSON form returns real UTF-8 and needs no unescaping, which is why these
|
||||
# parse a document rather than splitting words.
|
||||
def parse_property(output: str, expected: str) -> object | None:
|
||||
"""The value of a busctl --json=short property read, or None if it is not
|
||||
the type asked for."""
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("type") != expected:
|
||||
return None
|
||||
return payload.get("data")
|
||||
|
||||
|
||||
def parse_loaded_plugins(output: str) -> list[str]:
|
||||
try:
|
||||
parts = shlex.split(output)
|
||||
except ValueError:
|
||||
data = parse_property(output, "as")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
if len(parts) < 2 or parts[0] != "as":
|
||||
return []
|
||||
try:
|
||||
count = int(parts[1])
|
||||
except ValueError:
|
||||
return []
|
||||
return parts[2 : 2 + max(0, count)]
|
||||
return [str(item) for item in data]
|
||||
|
||||
|
||||
def parse_string_property(output: str) -> str:
|
||||
try:
|
||||
parts = shlex.split(output)
|
||||
except ValueError:
|
||||
return ""
|
||||
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
||||
data = parse_property(output, "s")
|
||||
return data if isinstance(data, str) else ""
|
||||
|
||||
|
||||
def parse_bool_property(output: str) -> bool | None:
|
||||
parts = output.split()
|
||||
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
|
||||
return None
|
||||
return parts[1] == "true"
|
||||
data = parse_property(output, "b")
|
||||
return data if isinstance(data, bool) else None
|
||||
|
||||
|
||||
def run_command(
|
||||
@@ -141,6 +151,7 @@ def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"--json=short",
|
||||
"call",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
@@ -157,6 +168,7 @@ def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[s
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"--json=short",
|
||||
"get-property",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
@@ -178,6 +190,7 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"--json=short",
|
||||
"get-property",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
@@ -198,6 +211,7 @@ def device_property(
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"--json=short",
|
||||
"get-property",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
|
||||
@@ -33,9 +33,11 @@ Usage:
|
||||
panama-keyring unlock -> raises the password prompt; prints the new state
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
@@ -147,10 +149,127 @@ def report(service, Secret):
|
||||
}
|
||||
|
||||
|
||||
# Attributes worth showing. The rest are storage plumbing -- schema names,
|
||||
# internal ids -- and listing them turns a readable row into a debug dump.
|
||||
INTERESTING_ATTRIBUTES = (
|
||||
"server", "host", "domain", "user", "username", "account", "protocol",
|
||||
"service", "object", "port", "goa-identity", "application",
|
||||
)
|
||||
|
||||
# How long a copied password stays on the clipboard.
|
||||
CLIPBOARD_SECONDS = 45
|
||||
|
||||
|
||||
def item_summary(item) -> dict:
|
||||
"""Everything about a stored secret EXCEPT the secret.
|
||||
|
||||
Nothing in here reads the value. That is not an accident of implementation
|
||||
but the contract of this function: enumerating the keyring must never
|
||||
require the keyring to hand over what it is protecting.
|
||||
"""
|
||||
attributes = dict(item.get_attributes() or {})
|
||||
shown = {key: value for key, value in attributes.items()
|
||||
if key in INTERESTING_ATTRIBUTES and value}
|
||||
return {
|
||||
"path": item.get_object_path(),
|
||||
"label": item.get_label() or "Unnamed",
|
||||
"schema": attributes.get("xdg:schema", ""),
|
||||
"attributes": shown,
|
||||
"created": int(item.get_created() or 0),
|
||||
"modified": int(item.get_modified() or 0),
|
||||
"locked": bool(item.get_locked()),
|
||||
}
|
||||
|
||||
|
||||
def items_report(service, Secret) -> dict:
|
||||
collections = []
|
||||
for collection in service.get_collections():
|
||||
entries = collection.get_items() or []
|
||||
collections.append({
|
||||
"label": collection.get_label() or "Unnamed keyring",
|
||||
"path": collection.get_object_path(),
|
||||
"locked": bool(collection.get_locked()),
|
||||
# A locked collection reports no items rather than an empty one:
|
||||
# "nothing stored here" and "cannot look" are different answers.
|
||||
"readable": not collection.get_locked(),
|
||||
"items": [item_summary(item) for item in entries],
|
||||
})
|
||||
# Empty, unnamed collections are the session store and similar plumbing.
|
||||
collections = [entry for entry in collections
|
||||
if entry["items"] or entry["label"] != "Unnamed keyring"]
|
||||
collections.sort(key=lambda entry: (entry["label"] != "Login", entry["label"]))
|
||||
return {"collections": collections, "error": ""}
|
||||
|
||||
|
||||
def resolve_item(service, Secret, path: str):
|
||||
"""An item by its D-Bus path, refusing anything that is not one.
|
||||
|
||||
The caller is a settings page, and a settings page can be wrong or stale --
|
||||
an item deleted in another window leaves a path that no longer resolves.
|
||||
"""
|
||||
if not re.fullmatch(r"/org/freedesktop/secrets/collection/[A-Za-z0-9_]+/[0-9]+", path or ""):
|
||||
raise ValueError("That is not a stored secret.")
|
||||
for collection in service.get_collections():
|
||||
for item in collection.get_items() or []:
|
||||
if item.get_object_path() == path:
|
||||
return item
|
||||
raise ValueError("That secret no longer exists.")
|
||||
|
||||
|
||||
def copy_secret(service, Secret, path: str) -> None:
|
||||
"""Put one stored secret on the clipboard, and nowhere else.
|
||||
|
||||
The value is read in this process and handed to wl-copy on STDIN. It is
|
||||
never an argument -- argv is world-readable through /proc, so passing a
|
||||
password there would publish it to every process on the machine -- and it
|
||||
is never printed, logged, or included in an error message.
|
||||
"""
|
||||
item = resolve_item(service, Secret, path)
|
||||
item.load_secret_sync(None)
|
||||
value = item.get_secret()
|
||||
if value is None:
|
||||
raise ValueError("That secret could not be read.")
|
||||
secret = value.get_text()
|
||||
if secret is None:
|
||||
raise ValueError("That secret is not text.")
|
||||
|
||||
encoded = secret.encode("utf-8")
|
||||
completed = subprocess.run(["wl-copy"], input=encoded, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
# Deliberately does not echo the tool's stderr: a clipboard tool that
|
||||
# fails while holding a password should not get to decide what lands in
|
||||
# a log.
|
||||
raise ValueError("The clipboard is not available.")
|
||||
|
||||
# Clear it again, but only if it is still the thing we put there. The guard
|
||||
# compares a HASH, so the reminder process never holds the password -- and
|
||||
# a clipboard the user has since replaced is left alone.
|
||||
digest = hashlib.sha256(encoded).hexdigest()
|
||||
subprocess.Popen(
|
||||
["sh", "-c",
|
||||
'sleep "$1"; current="$(wl-paste --no-newline 2>/dev/null | sha256sum | cut -d" " -f1)";'
|
||||
' [ "$current" = "$2" ] && wl-copy --clear',
|
||||
"sh", str(CLIPBOARD_SECONDS), digest],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def forget_secret(service, Secret, path: str) -> None:
|
||||
"""Delete one stored secret. Irreversible, and the UI confirms first."""
|
||||
item = resolve_item(service, Secret, path)
|
||||
item.delete_sync(None)
|
||||
|
||||
|
||||
def main():
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if action not in ("status", "unlock"):
|
||||
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
||||
target = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
if action not in ("status", "unlock", "items", "copy", "forget"):
|
||||
print("usage: panama-keyring [status|unlock|items|copy PATH|forget PATH]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if action in ("copy", "forget") and not target:
|
||||
print(f"usage: panama-keyring {action} PATH", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
@@ -165,6 +284,27 @@ def main():
|
||||
}))
|
||||
return 0
|
||||
|
||||
# These three answer with the item list, so a page never has to ask twice
|
||||
# to find out what changed.
|
||||
if action in ("items", "copy", "forget"):
|
||||
try:
|
||||
if action == "copy":
|
||||
copy_secret(service, Secret, target)
|
||||
elif action == "forget":
|
||||
forget_secret(service, Secret, target)
|
||||
Secret, service = load_service()
|
||||
except Exception as error: # noqa: BLE001
|
||||
# The message is this module's own, never the underlying tool's:
|
||||
# an exception raised while a secret is in hand must not get to
|
||||
# decide what text reaches a log or a settings page.
|
||||
payload = items_report(service, Secret)
|
||||
payload["error"] = (str(error) if isinstance(error, ValueError)
|
||||
else "That secret could not be used.")
|
||||
print(json.dumps(payload))
|
||||
return 0
|
||||
print(json.dumps(items_report(service, Secret)))
|
||||
return 0
|
||||
|
||||
if action == "unlock":
|
||||
login = next(
|
||||
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Which applications may use the camera and microphone.
|
||||
|
||||
Read from xdg-desktop-portal's permission store, which is where an application
|
||||
that asks through the portal has its answer recorded. That is the whole of what
|
||||
this can control, and the limit is worth stating plainly rather than implying a
|
||||
protection that does not exist: a native binary opens /dev/video0 directly and
|
||||
no desktop setting stands in its way. What this covers is Flatpaks and anything
|
||||
else that goes through the portal -- which on this machine is most of what would
|
||||
ever ask.
|
||||
|
||||
Devices with no recorded application are reported as empty rather than omitted,
|
||||
so the page can say "nothing has asked" instead of showing nothing at all.
|
||||
|
||||
panama-permissions snapshot
|
||||
panama-permissions set DEVICE APP_ID allow|deny
|
||||
panama-permissions forget DEVICE APP_ID
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
TABLE = "devices"
|
||||
|
||||
# The devices the portal arbitrates. Listed rather than discovered so a device
|
||||
# nothing has asked for still appears, which is the difference between "no
|
||||
# application uses your microphone" and a page that silently omits it.
|
||||
DEVICES = (
|
||||
("camera", "Camera"),
|
||||
("microphone", "Microphone"),
|
||||
("speakers", "Speakers"),
|
||||
)
|
||||
|
||||
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
DEVICE_ID = re.compile(r"^[a-z]+$")
|
||||
|
||||
ALLOWED = "yes"
|
||||
DENIED = "no"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or permission-store failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 15.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
except FileNotFoundError as error:
|
||||
raise BoundaryError("busctl is not available.") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError("The permission store did not respond.") from error
|
||||
|
||||
|
||||
def portal_call(method: str, signature: str, *arguments: str) -> dict | None:
|
||||
"""One call to the permission store, as JSON.
|
||||
|
||||
--json=short rather than busctl's text output, which escapes non-ASCII into
|
||||
octal and would mangle an application name.
|
||||
"""
|
||||
result = run([
|
||||
"busctl", "--user", "--json=short", "call",
|
||||
"org.freedesktop.impl.portal.PermissionStore",
|
||||
"/org/freedesktop/impl/portal/PermissionStore",
|
||||
"org.freedesktop.impl.portal.PermissionStore",
|
||||
method, signature, *arguments,
|
||||
])
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip()
|
||||
# A device nothing has ever asked for has no row at all, and the store
|
||||
# says so as "No entry for camera". Matched on the store's actual words
|
||||
# rather than a guess at them.
|
||||
lowered = detail.lower()
|
||||
if "no entry" in lowered or "not found" in lowered:
|
||||
return None
|
||||
raise BoundaryError(detail.splitlines()[-1] if detail else "The permission store refused that.")
|
||||
try:
|
||||
return json.loads(result.stdout or "null")
|
||||
except json.JSONDecodeError as error:
|
||||
raise BoundaryError("The permission store returned something unreadable.") from error
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
result = run(["busctl", "--user", "list"])
|
||||
return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "")
|
||||
|
||||
|
||||
def entries_for(device: str) -> list[dict]:
|
||||
payload = portal_call("Lookup", "ss", TABLE, device)
|
||||
if not payload:
|
||||
return []
|
||||
data = payload.get("data") or []
|
||||
if not data or not isinstance(data[0], dict):
|
||||
return []
|
||||
|
||||
found = []
|
||||
for app_id, permissions in data[0].items():
|
||||
values = [str(value) for value in (permissions or [])]
|
||||
found.append({
|
||||
"app": app_id,
|
||||
# Anything that is not an explicit "yes" is treated as withheld:
|
||||
# guessing generously about a camera is the wrong way to be wrong.
|
||||
"allowed": ALLOWED in values,
|
||||
"raw": ",".join(values),
|
||||
})
|
||||
found.sort(key=lambda entry: entry["app"].casefold())
|
||||
return found
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
if not available():
|
||||
return {
|
||||
"available": False,
|
||||
"devices": [],
|
||||
"error": "The desktop portal's permission store is not running.",
|
||||
}
|
||||
|
||||
devices = []
|
||||
for device_id, label in DEVICES:
|
||||
devices.append({
|
||||
"id": device_id,
|
||||
"label": label,
|
||||
"applications": entries_for(device_id),
|
||||
})
|
||||
return {"available": True, "devices": devices, "error": ""}
|
||||
|
||||
|
||||
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
|
||||
if not pattern.match(value or ""):
|
||||
raise BoundaryError(message)
|
||||
return value
|
||||
|
||||
|
||||
def set_permission(device: str, app: str, allowed: bool) -> None:
|
||||
require(DEVICE_ID, device, "That is not a device.")
|
||||
require(APP_ID, app, "That is not an application.")
|
||||
if not any(device == known for known, _ in DEVICES):
|
||||
raise BoundaryError("That is not a device this manages.")
|
||||
# Permissions are an array of strings, so busctl needs the element count
|
||||
# before the element -- "1 yes", not "yes".
|
||||
portal_call("SetPermission", "sbssas", TABLE, "true", device, app,
|
||||
"1", ALLOWED if allowed else DENIED)
|
||||
|
||||
|
||||
def forget(device: str, app: str) -> None:
|
||||
"""Drop the recorded answer, so the application is asked again next time."""
|
||||
require(DEVICE_ID, device, "That is not a device.")
|
||||
require(APP_ID, app, "That is not an application.")
|
||||
portal_call("DeletePermission", "sss", TABLE, device, app)
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 4 and arguments[0] == "set":
|
||||
if arguments[3] not in ("allow", "deny"):
|
||||
raise BoundaryError("That is not allow or deny.")
|
||||
set_permission(arguments[1], arguments[2], arguments[3] == "allow")
|
||||
elif len(arguments) == 3 and arguments[0] == "forget":
|
||||
forget(arguments[1], arguments[2])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-permissions snapshot | set DEVICE APP allow|deny | "
|
||||
"forget DEVICE APP")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"available": False, "devices": []}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Panama's polkit authentication agent -- the D-Bus half.
|
||||
|
||||
This process never sees a password. It is worth being explicit about why the
|
||||
work is split this way, because the split IS the security design:
|
||||
|
||||
* This half registers with polkitd, receives an authentication request, and
|
||||
hands the non-secret parts of it to the shell: which action, what message,
|
||||
which identities may answer, and the one-time cookie.
|
||||
* The shell half draws the prompt, and when someone types a password it
|
||||
spawns the setuid `polkit-agent-helper-1` itself and writes the password to
|
||||
that helper's STDIN. The helper performs the PAM conversation and reports
|
||||
the result to polkitd directly.
|
||||
|
||||
So the password exists only inside the shell process and the helper's stdin. It
|
||||
never crosses D-Bus, never crosses this process, and never appears on a command
|
||||
line -- argv is world-readable through /proc, which is why "just pass it as an
|
||||
argument" is not an option anywhere in this codebase.
|
||||
|
||||
The request itself is handed over as a file in the runtime directory, created
|
||||
0600, rather than as IPC arguments. The cookie is not a password, but it is a
|
||||
capability, and capabilities do not belong in a process listing either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import Gio, GLib # noqa: E402
|
||||
|
||||
AGENT_PATH = "/org/panama/PolkitAgent"
|
||||
AGENT_INTERFACE = "org.freedesktop.PolicyKit1.AuthenticationAgent"
|
||||
AUTHORITY_NAME = "org.freedesktop.PolicyKit1"
|
||||
AUTHORITY_PATH = "/org/freedesktop/PolicyKit1/Authority"
|
||||
AUTHORITY_INTERFACE = "org.freedesktop.PolicyKit1.Authority"
|
||||
|
||||
# How long a prompt may stay on screen before this gives up and tells polkit the
|
||||
# request failed. Without it, a shell that died mid-prompt would leave the
|
||||
# caller (a package manager, say) waiting forever.
|
||||
PROMPT_TIMEOUT_SECONDS = 300
|
||||
|
||||
INTROSPECTION = """
|
||||
<node>
|
||||
<interface name='org.freedesktop.PolicyKit1.AuthenticationAgent'>
|
||||
<method name='BeginAuthentication'>
|
||||
<arg type='s' name='action_id' direction='in'/>
|
||||
<arg type='s' name='message' direction='in'/>
|
||||
<arg type='s' name='icon_name' direction='in'/>
|
||||
<arg type='a{ss}' name='details' direction='in'/>
|
||||
<arg type='s' name='cookie' direction='in'/>
|
||||
<arg type='a(sa{sv})' name='identities' direction='in'/>
|
||||
</method>
|
||||
<method name='CancelAuthentication'>
|
||||
<arg type='s' name='cookie' direction='in'/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
def sweep_stale(base: Path) -> None:
|
||||
"""Remove leftovers from an agent that did not exit cleanly.
|
||||
|
||||
A killed agent leaves its request and response behind. They are harmless --
|
||||
a spent cookie and a one-word result -- but they accumulate, and a directory
|
||||
of stale capabilities is a bad habit even when each one is inert.
|
||||
"""
|
||||
for path in base.glob("request-*"):
|
||||
try:
|
||||
if time.time() - path.stat().st_mtime > PROMPT_TIMEOUT_SECONDS:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def runtime_dir() -> Path:
|
||||
base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit"
|
||||
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
# Enforced rather than assumed: an inherited directory with looser
|
||||
# permissions would expose every request that passes through it.
|
||||
os.chmod(base, 0o700)
|
||||
sweep_stale(base)
|
||||
return base
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"panama-polkit-agent: {message}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self) -> None:
|
||||
self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||
self.pending: dict[str, Path] = {}
|
||||
self.loop = GLib.MainLoop()
|
||||
|
||||
# ── Registration ────────────────────────────────────────────────────────
|
||||
|
||||
def subject(self) -> GLib.Variant:
|
||||
"""This login session, which is what the agent authenticates for.
|
||||
|
||||
XDG_SESSION_ID is set for a login shell but NOT in the systemd user
|
||||
environment, so running as a unit has to ask logind which session this
|
||||
process belongs to rather than trusting the variable to be there.
|
||||
"""
|
||||
session_id = os.environ.get("XDG_SESSION_ID", "")
|
||||
if not session_id:
|
||||
session_id = self.session_from_logind()
|
||||
if not session_id:
|
||||
raise RuntimeError("could not determine the login session to register for")
|
||||
return GLib.Variant("(sa{sv})", ("unix-session",
|
||||
{"session-id": GLib.Variant("s", session_id)}))
|
||||
|
||||
def session_from_logind(self) -> str:
|
||||
"""This user's graphical session, asked of logind.
|
||||
|
||||
Not GetSessionByPID: a systemd USER unit runs under [email protected],
|
||||
which belongs to no login session at all -- which is exactly why
|
||||
XDG_SESSION_ID is missing when started that way. The user object's
|
||||
Display property names the graphical session, which is the one whose
|
||||
authentications this agent should answer.
|
||||
"""
|
||||
try:
|
||||
user_path = self.bus.call_sync(
|
||||
"org.freedesktop.login1", "/org/freedesktop/login1",
|
||||
"org.freedesktop.login1.Manager", "GetUser",
|
||||
GLib.Variant("(u)", (os.getuid(),)), GLib.VariantType("(o)"),
|
||||
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
|
||||
display = self.bus.call_sync(
|
||||
"org.freedesktop.login1", user_path,
|
||||
"org.freedesktop.DBus.Properties", "Get",
|
||||
GLib.Variant("(ss)", ("org.freedesktop.login1.User", "Display")),
|
||||
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
|
||||
# (session-id, object-path)
|
||||
return str(display[0]) if display and display[0] else ""
|
||||
except Exception: # noqa: BLE001 - no session is a real answer
|
||||
return ""
|
||||
|
||||
def register(self) -> None:
|
||||
node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
|
||||
# The object MUST be exported on the same connection that registers the
|
||||
# agent. polkitd calls BeginAuthentication back on the unique name that
|
||||
# called RegisterAuthenticationAgent -- so exporting on the session bus
|
||||
# while registering from the system bus leaves polkitd calling a path
|
||||
# that does not exist, and every request fails as "Not authorized"
|
||||
# without ever prompting.
|
||||
self.bus.register_object(
|
||||
AGENT_PATH, node.interfaces[0], self.on_call, None, None)
|
||||
self.bus.call_sync(
|
||||
AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE,
|
||||
"RegisterAuthenticationAgent",
|
||||
GLib.Variant.new_tuple(self.subject(),
|
||||
GLib.Variant("s", "en_US.UTF-8"),
|
||||
GLib.Variant("s", AGENT_PATH)),
|
||||
None, Gio.DBusCallFlags.NONE, 10000, None)
|
||||
log("registered with polkitd")
|
||||
|
||||
def unregister(self) -> None:
|
||||
try:
|
||||
self.bus.call_sync(
|
||||
AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE,
|
||||
"UnregisterAuthenticationAgent",
|
||||
GLib.Variant.new_tuple(self.subject(), GLib.Variant("s", AGENT_PATH)),
|
||||
None, Gio.DBusCallFlags.NONE, 5000, None)
|
||||
log("unregistered")
|
||||
except Exception: # noqa: BLE001 - shutting down either way
|
||||
pass
|
||||
|
||||
# ── The agent interface ─────────────────────────────────────────────────
|
||||
|
||||
def on_call(self, connection, sender, path, interface, method, parameters, invocation):
|
||||
if method == "BeginAuthentication":
|
||||
action_id, message, icon_name, details, cookie, identities = parameters.unpack()
|
||||
self.begin(action_id, message, icon_name, cookie, identities, invocation)
|
||||
return
|
||||
if method == "CancelAuthentication":
|
||||
(cookie,) = parameters.unpack()
|
||||
self.cancel(cookie)
|
||||
invocation.return_value(None)
|
||||
return
|
||||
invocation.return_error_literal(Gio.dbus_error_quark(),
|
||||
Gio.DBusError.UNKNOWN_METHOD, "Unknown method")
|
||||
|
||||
def usernames(self, identities: list) -> list[str]:
|
||||
"""Who polkit will accept an answer from, as names a person recognizes."""
|
||||
names = []
|
||||
for kind, attributes in identities:
|
||||
if kind != "unix-user":
|
||||
continue
|
||||
uid = attributes.get("uid")
|
||||
if uid is None:
|
||||
continue
|
||||
try:
|
||||
import pwd
|
||||
|
||||
names.append(pwd.getpwuid(int(uid)).pw_name)
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
return names
|
||||
|
||||
def begin(self, action_id, message, icon_name, cookie, identities, invocation) -> None:
|
||||
names = self.usernames(identities)
|
||||
if not names:
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
|
||||
"No user is allowed to authenticate this request.")
|
||||
return
|
||||
|
||||
request_path = runtime_dir() / f"request-{os.getpid()}-{int(time.time() * 1000)}.json"
|
||||
payload = {
|
||||
"actionId": action_id,
|
||||
"message": message,
|
||||
"iconName": icon_name,
|
||||
"cookie": cookie,
|
||||
"users": names,
|
||||
"preferred": os.environ.get("USER", names[0]),
|
||||
}
|
||||
# 0600 before anything is written to it, so the cookie is never briefly
|
||||
# world-readable.
|
||||
handle = os.open(request_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream)
|
||||
|
||||
self.pending[cookie] = request_path
|
||||
response_path = request_path.with_suffix(".response")
|
||||
|
||||
if not self.ask_shell(request_path):
|
||||
self.finish(cookie, request_path, response_path)
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
|
||||
"The desktop could not show an authentication prompt.")
|
||||
return
|
||||
|
||||
# Poll for the shell's answer rather than blocking the main loop, so a
|
||||
# Cancel from polkit is still processed while a prompt is open.
|
||||
started = time.monotonic()
|
||||
|
||||
def check() -> bool:
|
||||
if cookie not in self.pending:
|
||||
# Cancelled from the polkit side.
|
||||
self.finish(cookie, request_path, response_path)
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Cancelled.")
|
||||
return False
|
||||
if response_path.exists():
|
||||
try:
|
||||
answer = json.loads(response_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
answer = {"result": "failed"}
|
||||
self.finish(cookie, request_path, response_path)
|
||||
if answer.get("result") == "ok":
|
||||
# The helper has already told polkitd. Returning normally is
|
||||
# what completes the request.
|
||||
invocation.return_value(None)
|
||||
else:
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
|
||||
"Authentication was not completed.")
|
||||
return False
|
||||
if time.monotonic() - started > PROMPT_TIMEOUT_SECONDS:
|
||||
self.finish(cookie, request_path, response_path)
|
||||
invocation.return_error_literal(
|
||||
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Timed out.")
|
||||
return False
|
||||
return True
|
||||
|
||||
GLib.timeout_add(150, check)
|
||||
|
||||
def cancel(self, cookie: str) -> None:
|
||||
request_path = self.pending.pop(cookie, None)
|
||||
if request_path is None:
|
||||
return
|
||||
# A marker rather than a deletion: the shell watches for this to close a
|
||||
# prompt that polkit no longer wants an answer to.
|
||||
try:
|
||||
request_path.with_suffix(".cancelled").touch(mode=0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def finish(self, cookie: str, request_path: Path, response_path: Path) -> None:
|
||||
self.pending.pop(cookie, None)
|
||||
for path in (request_path, response_path, request_path.with_suffix(".cancelled")):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def ask_shell(self, request_path: Path) -> bool:
|
||||
"""Tell the shell to prompt. Only the path travels, never the contents."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["qs", "ipc", "call", "polkit", "begin", str(request_path)],
|
||||
capture_output=True, text=True, timeout=15, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
log(f"the shell refused the prompt: {result.stderr.strip()[:120]}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def run(self) -> int:
|
||||
self.register()
|
||||
for received in (signal.SIGINT, signal.SIGTERM):
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, received, self.stop)
|
||||
try:
|
||||
self.loop.run()
|
||||
finally:
|
||||
self.unregister()
|
||||
return 0
|
||||
|
||||
def stop(self) -> bool:
|
||||
self.loop.quit()
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
return Agent().run()
|
||||
except Exception as error: # noqa: BLE001
|
||||
log(f"could not start: {error}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Printers, through CUPS' own API rather than by parsing lpstat.
|
||||
|
||||
Driverless only, on purpose. This adds printers that describe their own
|
||||
capabilities over IPP -- IPP Everywhere, which is every printer sold in roughly
|
||||
the last decade -- and does not choose PPDs or download vendor drivers. Driver
|
||||
selection is most of what the panel this replaces does, and getting it wrong
|
||||
produces a printer that accepts jobs and silently prints nothing. A printer old
|
||||
enough to need a PPD is better served by system-config-printer, and the page
|
||||
says so rather than pretending.
|
||||
|
||||
panama-printers snapshot
|
||||
panama-printers discover
|
||||
panama-printers add URI NAME
|
||||
panama-printers remove NAME
|
||||
panama-printers set-default NAME
|
||||
panama-printers pause NAME | resume NAME
|
||||
panama-printers cancel JOB_ID
|
||||
panama-printers test-page NAME
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# What may be printed to. Everything else -- file:, pipe:, a shell fragment --
|
||||
# is refused: this validates the URI rather than trusting a settings page,
|
||||
# because a device URI is handed to a backend that runs as root.
|
||||
SAFE_SCHEMES = ("ipp", "ipps", "socket", "dnssd", "http", "https")
|
||||
URI = re.compile(r"^(%s)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%%-]+$" % "|".join(SAFE_SCHEMES))
|
||||
|
||||
# CUPS queue names: no spaces, slashes, or '#'.
|
||||
QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$")
|
||||
|
||||
# The only model this adds. Naming it in one place means the driverless promise
|
||||
# is checkable rather than scattered.
|
||||
DRIVERLESS_MODEL = "everywhere"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or CUPS failure."""
|
||||
|
||||
|
||||
def connect():
|
||||
try:
|
||||
import cups
|
||||
|
||||
return cups, cups.Connection()
|
||||
except Exception as error: # noqa: BLE001 - no cups is a legitimate state
|
||||
raise BoundaryError("The printing service is not answering.") from error
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer.") from error
|
||||
|
||||
|
||||
def service_state() -> dict:
|
||||
active = run(["systemctl", "is-active", "cups.service"]).stdout.strip()
|
||||
enabled = run(["systemctl", "is-enabled", "cups.service"]).stdout.strip()
|
||||
avahi = run(["systemctl", "is-active", "avahi-daemon.service"]).stdout.strip()
|
||||
return {
|
||||
"running": active == "active",
|
||||
# "on demand" is a real answer and a reasonable configuration, not a
|
||||
# problem to report: socket activation starts CUPS when something prints.
|
||||
"startsAtBoot": enabled == "enabled",
|
||||
"startMode": enabled or "unknown",
|
||||
"discoveryAvailable": avahi == "active",
|
||||
}
|
||||
|
||||
|
||||
def printer_state(attributes: dict) -> str:
|
||||
"""CUPS reports state as a number; people read words."""
|
||||
return {3: "idle", 4: "printing", 5: "stopped"}.get(
|
||||
int(attributes.get("printer-state", 0)), "unknown")
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
cups, connection = connect()
|
||||
try:
|
||||
raw = connection.getPrinters()
|
||||
default = connection.getDefault()
|
||||
jobs = connection.getJobs(which_jobs="not-completed", my_jobs=False,
|
||||
requested_attributes=[
|
||||
"job-id", "job-name", "job-printer-uri",
|
||||
"job-state", "job-originating-user-name",
|
||||
"job-impressions", "time-at-creation"])
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("The printing service could not be read.") from error
|
||||
|
||||
printers = []
|
||||
for name, attributes in raw.items():
|
||||
printers.append({
|
||||
"name": name,
|
||||
"description": str(attributes.get("printer-info") or name),
|
||||
"location": str(attributes.get("printer-location") or ""),
|
||||
"makeAndModel": str(attributes.get("printer-make-and-model") or ""),
|
||||
"uri": str(attributes.get("device-uri") or ""),
|
||||
"state": printer_state(attributes),
|
||||
"stateMessage": str(attributes.get("printer-state-message") or ""),
|
||||
"accepting": bool(attributes.get("printer-is-accepting-jobs", True)),
|
||||
"shared": bool(attributes.get("printer-is-shared", False)),
|
||||
"isDefault": name == default,
|
||||
})
|
||||
printers.sort(key=lambda entry: (not entry["isDefault"], entry["name"].lower()))
|
||||
|
||||
queue = []
|
||||
for job in jobs.values() if isinstance(jobs, dict) else []:
|
||||
printer_uri = str(job.get("job-printer-uri") or "")
|
||||
queue.append({
|
||||
"id": int(job.get("job-id") or 0),
|
||||
"name": str(job.get("job-name") or "Untitled"),
|
||||
"printer": printer_uri.rsplit("/", 1)[-1] if printer_uri else "",
|
||||
"state": {3: "pending", 4: "held", 5: "processing",
|
||||
6: "stopped", 7: "cancelled"}.get(int(job.get("job-state", 0)), "unknown"),
|
||||
"user": str(job.get("job-originating-user-name") or ""),
|
||||
"pages": int(job.get("job-impressions") or 0),
|
||||
"createdAt": int(job.get("time-at-creation") or 0),
|
||||
})
|
||||
queue.sort(key=lambda entry: entry["createdAt"])
|
||||
|
||||
return {"printers": printers, "jobs": queue, "service": service_state(), "error": ""}
|
||||
|
||||
|
||||
def discover() -> dict:
|
||||
"""Printers announcing themselves on the network, driverless ones only.
|
||||
|
||||
A printer that does not advertise IPP Everywhere is reported as found but
|
||||
not addable, rather than offered and then failing at the point of adding.
|
||||
"""
|
||||
if not shutil.which("avahi-browse"):
|
||||
return {"found": [], "error": "Network discovery is not available."}
|
||||
|
||||
found: dict[str, dict] = {}
|
||||
for service in ("_ipps._tcp", "_ipp._tcp"):
|
||||
result = run(["avahi-browse", "-rtp", service], timeout=20)
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.startswith("="):
|
||||
continue
|
||||
parts = line.split(";")
|
||||
if len(parts) < 10:
|
||||
continue
|
||||
name, host, port, text = parts[3], parts[6], parts[8], parts[9]
|
||||
scheme = "ipps" if service == "_ipps._tcp" else "ipp"
|
||||
resource = ""
|
||||
for field in re.findall(r"\"([^\"]*)\"", text):
|
||||
if field.startswith("rp="):
|
||||
resource = field[3:]
|
||||
# Only IPP Everywhere. The attribute a driverless printer publishes
|
||||
# is its PDF/JPEG support; without it, CUPS would need a driver.
|
||||
driverless = "application/pdf" in text or "URF=" in text or "urf=" in text
|
||||
uri = f"{scheme}://{host}:{port}/{resource}" if resource else f"{scheme}://{host}:{port}/"
|
||||
found[uri] = {
|
||||
"name": name.replace("\\032", " "),
|
||||
"uri": uri,
|
||||
"host": host,
|
||||
"driverless": driverless,
|
||||
}
|
||||
return {"found": sorted(found.values(), key=lambda entry: entry["name"]), "error": ""}
|
||||
|
||||
|
||||
def require_queue(name: str) -> str:
|
||||
if not QUEUE.fullmatch(name or ""):
|
||||
raise BoundaryError("That is not a printer name.")
|
||||
return name
|
||||
|
||||
|
||||
def add(uri: str, name: str) -> None:
|
||||
if not URI.fullmatch(uri or ""):
|
||||
raise BoundaryError("That address cannot be used to reach a printer.")
|
||||
require_queue(name)
|
||||
|
||||
cups, connection = connect()
|
||||
try:
|
||||
# ppdname is the driverless model and nothing else. There is no branch
|
||||
# here that selects a PPD, which is what keeps the promise checkable.
|
||||
connection.addPrinter(name, device=uri, ppdname=DRIVERLESS_MODEL)
|
||||
connection.enablePrinter(name)
|
||||
connection.acceptJobs(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
message = str(error)
|
||||
if "device-error" in message or "1284" in message:
|
||||
raise BoundaryError(
|
||||
"The printer did not answer, or does not support driverless printing.") from error
|
||||
if "not-authorized" in message or "forbidden" in message.lower():
|
||||
raise BoundaryError("Adding a printer was not authorized.") from error
|
||||
raise BoundaryError("That printer could not be added.") from error
|
||||
|
||||
|
||||
def remove(name: str) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.deletePrinter(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be removed.") from error
|
||||
|
||||
|
||||
def set_default(name: str) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.setDefault(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be made the default.") from error
|
||||
|
||||
|
||||
def set_paused(name: str, paused: bool) -> None:
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
if paused:
|
||||
connection.disablePrinter(name)
|
||||
else:
|
||||
connection.enablePrinter(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer could not be changed.") from error
|
||||
|
||||
|
||||
def cancel(job_id: str) -> None:
|
||||
if not job_id.isdigit():
|
||||
raise BoundaryError("That is not a job.")
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.cancelJob(int(job_id))
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That job could not be cancelled.") from error
|
||||
|
||||
|
||||
def test_page(name: str) -> None:
|
||||
require_queue(name)
|
||||
result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError((result.stderr.strip() or "The test page could not be sent.")[:200])
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if arguments == ["discover"]:
|
||||
print(json.dumps(discover(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "add":
|
||||
add(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "remove":
|
||||
remove(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "set-default":
|
||||
set_default(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "pause":
|
||||
set_paused(arguments[1], True)
|
||||
elif len(arguments) == 2 and arguments[0] == "resume":
|
||||
set_paused(arguments[1], False)
|
||||
elif len(arguments) == 2 and arguments[0] == "cancel":
|
||||
cancel(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "test-page":
|
||||
test_page(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | "
|
||||
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"printers": [], "jobs": [], "service": service_state()}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Generate one launcher command per settings page.
|
||||
|
||||
Settings has a search index and the launcher has script commands, but they did
|
||||
not know about each other: finding a setting meant opening Settings first and
|
||||
searching there. These commands close that gap, so typing "night light" into the
|
||||
launcher opens the page that owns it.
|
||||
|
||||
The vocabulary is derived from the same sources the in-app search uses --
|
||||
the page list in SettingsSidebar.qml, the group routing in SettingsSearch.qml,
|
||||
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
|
||||
Settings is searchable from the launcher without anyone maintaining a second
|
||||
list.
|
||||
|
||||
panama-settings-commands write the commands
|
||||
panama-settings-commands --check fail if what is on disk is stale
|
||||
|
||||
Run it after adding a settings page or renaming a setting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
SHELL = REPO / "config/dot/quickshell"
|
||||
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
|
||||
SEARCH = SHELL / "services/SettingsSearch.qml"
|
||||
SCHEMA = SHELL / "config/PreferenceSchema.qml"
|
||||
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"
|
||||
|
||||
# Home is what the plain "Open Settings" command already lands on, so a second
|
||||
# command for it would be a duplicate under a different name.
|
||||
SKIP_PAGES = {"home"}
|
||||
|
||||
# Enough vocabulary to find the page, not so much that one page matches
|
||||
# everything. Ordered by the schema, so the settings at the top of a page --
|
||||
# the ones it is named for -- are the ones that survive the cut.
|
||||
MAX_KEYWORDS = 12
|
||||
|
||||
GENERATED_MARKER = "# Generated by scripts/panama-settings-commands -- do not edit by hand."
|
||||
|
||||
|
||||
class ParseError(RuntimeError):
|
||||
"""A source file did not look the way this generator expects."""
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise ParseError(f"could not read {path}") from error
|
||||
|
||||
|
||||
def pages() -> list[tuple[str, str]]:
|
||||
"""The settings pages, in sidebar order, as (id, label)."""
|
||||
source = read(SIDEBAR)
|
||||
found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
|
||||
if not found:
|
||||
raise ParseError("no pages found in SettingsSidebar.qml")
|
||||
return [(page, label) for page, label in found if page not in SKIP_PAGES]
|
||||
|
||||
|
||||
def group_pages() -> dict[str, str]:
|
||||
source = read(SEARCH)
|
||||
table = re.search(r"readonly property var groupPages: \(\{(.*?)\n \}\)", source, re.S)
|
||||
if not table:
|
||||
raise ParseError("groupPages not found in SettingsSearch.qml")
|
||||
return dict(re.findall(r'"([A-Za-z]+)":\s*"([a-z-]+)"', table.group(1)))
|
||||
|
||||
|
||||
def extra_labels() -> dict[str, list[str]]:
|
||||
"""Settings the system owns rather than Panama, which have no schema entry."""
|
||||
source = read(SEARCH)
|
||||
table = re.search(r"readonly property var extraEntries: \[(.*?)\n \]", source, re.S)
|
||||
if not table:
|
||||
return {}
|
||||
labels: dict[str, list[str]] = {}
|
||||
for label, page in re.findall(r'\{ label: "([^"]+)".*?page: "([a-z-]+)" \}', table.group(1)):
|
||||
labels.setdefault(page, []).append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def schema_labels() -> list[tuple[str, str]]:
|
||||
"""(group, label) for every user-facing setting, in schema order."""
|
||||
source = read(SCHEMA)
|
||||
entries: list[tuple[str, str]] = []
|
||||
chunks = source.split("key: ")
|
||||
for chunk in chunks[1:]:
|
||||
# Internal state is not a setting anyone searches for.
|
||||
if re.search(r"internal:\s*true", chunk[:600]):
|
||||
continue
|
||||
group = re.search(r'group:\s*"([A-Za-z]+)"', chunk[:600])
|
||||
label = re.search(r'label:\s*"([^"]+)"', chunk[:600])
|
||||
if group and label:
|
||||
entries.append((group.group(1), label.group(1)))
|
||||
if not entries:
|
||||
raise ParseError("no labelled settings found in PreferenceSchema.qml")
|
||||
return entries
|
||||
|
||||
|
||||
def keywords_for(page: str, routing: dict[str, str], schema: list[tuple[str, str]],
|
||||
extras: dict[str, list[str]]) -> list[str]:
|
||||
words: list[str] = []
|
||||
for group, label in schema:
|
||||
if routing.get(group) == page:
|
||||
lowered = label.lower()
|
||||
if lowered not in words:
|
||||
words.append(lowered)
|
||||
for label in extras.get(page, []):
|
||||
lowered = label.lower()
|
||||
if lowered not in words:
|
||||
words.append(lowered)
|
||||
return words[:MAX_KEYWORDS]
|
||||
|
||||
|
||||
def command_for(page: str, label: str, words: list[str]) -> str:
|
||||
"""One launcher command, titled "Settings: <page>".
|
||||
|
||||
The qualifier is not product branding -- that was deliberately dropped from
|
||||
every command title. It is here because a bare page label collides with the
|
||||
feature of the same name: "Screen Intelligence" is both a thing you open and
|
||||
a page of settings about it, and two commands sharing one title are
|
||||
indistinguishable in a launcher. Qualifying the page also groups all of them
|
||||
under one word, which is what typing "settings" is for.
|
||||
"""
|
||||
# "settings" is always a keyword so typing it lists every page at once.
|
||||
vocabulary = ["settings"] + [word for word in words if word != "settings"]
|
||||
keywords = ", ".join(f'"{word}"' for word in vocabulary)
|
||||
return f"""#!/usr/bin/env bash
|
||||
{GENERATED_MARKER}
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Settings: {label}
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open {label} in Settings.
|
||||
# @vicinae.keywords [{keywords}]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page {page}
|
||||
"""
|
||||
|
||||
|
||||
def build() -> dict[Path, str]:
|
||||
routing = group_pages()
|
||||
schema = schema_labels()
|
||||
extras = extra_labels()
|
||||
files: dict[Path, str] = {}
|
||||
for page, label in pages():
|
||||
words = keywords_for(page, routing, schema, extras)
|
||||
files[OUTPUT_DIR / f"settings-{page}.sh"] = command_for(page, label, words)
|
||||
return files
|
||||
|
||||
|
||||
def existing() -> set[Path]:
|
||||
return {path for path in OUTPUT_DIR.glob("settings-*.sh")}
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
check = arguments == ["--check"]
|
||||
if arguments and not check:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
files = build()
|
||||
except ParseError as error:
|
||||
# Loud and empty-handed: writing a partial set would silently drop the
|
||||
# pages whose source stopped parsing.
|
||||
print(f"panama-settings-commands: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
stale = [path for path, body in files.items()
|
||||
if not path.is_file() or path.read_text(encoding="utf-8") != body]
|
||||
orphaned = sorted(existing() - set(files))
|
||||
|
||||
if check:
|
||||
for path in stale:
|
||||
print(f"stale: {path.name}", file=sys.stderr)
|
||||
for path in orphaned:
|
||||
print(f"orphaned: {path.name}", file=sys.stderr)
|
||||
if stale or orphaned:
|
||||
print("panama-settings-commands: run it without --check to regenerate",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print(f"panama-settings-commands: {len(files)} commands are current")
|
||||
return 0
|
||||
|
||||
for path in orphaned:
|
||||
path.unlink()
|
||||
print(f"removed {path.name}")
|
||||
for path, body in files.items():
|
||||
path.write_text(body, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
print(f"panama-settings-commands: wrote {len(files)} commands"
|
||||
+ (f", removed {len(orphaned)}" if orphaned else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Carrying Panama's settings to another machine.
|
||||
|
||||
Different from panama-settings-backup, which snapshots this machine so it can be
|
||||
put back exactly as it was, arrangement and all. This produces something meant
|
||||
to travel: the preferences that describe taste rather than hardware.
|
||||
|
||||
Two decisions shape the whole thing.
|
||||
|
||||
The export is an ALLOW-LIST taken from the preference schema, not a deny-list of
|
||||
things to strip. Anything the schema does not declare is dropped, so a key added
|
||||
later that happens to hold a token cannot leak into a file somebody emails to
|
||||
themselves. Being wrong in this direction loses a setting; being wrong the other
|
||||
way publishes a secret.
|
||||
|
||||
The import validates every value against the schema again on arrival and skips
|
||||
what does not fit, one key at a time, with a reason. A file from an older Panama
|
||||
is a normal thing to have, and refusing it wholesale because one key changed
|
||||
shape would make the feature useless exactly when it is most wanted.
|
||||
|
||||
panama-settings-sync export PATH
|
||||
panama-settings-sync preview PATH
|
||||
panama-settings-sync import PATH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path(os.environ.get("HOME", str(Path.home())))
|
||||
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
|
||||
SETTINGS = CONFIG_ROOT / "panama/settings.json"
|
||||
|
||||
SCHEMA = Path(__file__).resolve().parents[1] / "config" / "PreferenceSchema.qml"
|
||||
|
||||
FORMAT = "panama-settings-sync/1"
|
||||
|
||||
# Settings that describe this machine rather than how it should behave. Held
|
||||
# separately from the allow-list because each needs a reason, and a reason is
|
||||
# what stops the list growing by habit.
|
||||
MACHINE_SPECIFIC = {
|
||||
# Monitor arrangement, keyed by output names that mean nothing elsewhere.
|
||||
"displays": "describes this machine's monitors",
|
||||
# Which settings page was last open. Session noise.
|
||||
"lastPage": "is where you happened to be looking",
|
||||
# Governs migration of the store itself; importing one would mislabel it.
|
||||
"schemaVersion": "belongs to the store, not to you",
|
||||
}
|
||||
|
||||
# Settings that travel but may not land: an absolute path is only a setting on a
|
||||
# machine where the file exists. Carried, then checked on arrival.
|
||||
PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"}
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or file failure."""
|
||||
|
||||
|
||||
def schema() -> dict[str, dict]:
|
||||
"""Every declared preference, by key.
|
||||
|
||||
Parsed from the schema rather than kept as a second list here, so a setting
|
||||
added there is exportable without anyone remembering to update this.
|
||||
"""
|
||||
try:
|
||||
source = SCHEMA.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
raise BoundaryError("The preference schema could not be read.") from error
|
||||
|
||||
entries: dict[str, dict] = {}
|
||||
for chunk in source.split("key: ")[1:]:
|
||||
head = chunk[:1600]
|
||||
key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
|
||||
kind = re.search(r'type:\s*"([a-z]+)"', head)
|
||||
if not (key and kind):
|
||||
continue
|
||||
entry = {"key": key.group(1), "type": kind.group(1)}
|
||||
for bound in ("min", "max"):
|
||||
found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
|
||||
if found:
|
||||
entry[bound] = float(found.group(1))
|
||||
pattern = re.search(r'pattern:\s*"((?:[^"\\]|\\.)*)"', head)
|
||||
if pattern:
|
||||
entry["pattern"] = pattern.group(1).replace("\\\\", "\\")
|
||||
# Option values are quoted for a word and bare for a number -- vrrPolicy
|
||||
# is an enum of 0..3. Capturing only the quoted form left numeric enums
|
||||
# with no choices at all, which then read as unverifiable and were
|
||||
# refused: a valid setting dropped on the way in.
|
||||
options = [quoted if quoted else bare for quoted, bare
|
||||
in re.findall(r'value:\s*(?:"([^"]*)"|(-?[0-9]+(?:\.[0-9]+)?))', head)]
|
||||
if options:
|
||||
entry["options"] = options
|
||||
entries[entry["key"]] = entry
|
||||
if not entries:
|
||||
raise BoundaryError("The preference schema yielded no settings.")
|
||||
return entries
|
||||
|
||||
|
||||
def stored() -> dict:
|
||||
if not SETTINGS.is_file():
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(SETTINGS.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise BoundaryError("This machine's settings could not be read.") from error
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def fits(entry: dict, value) -> str:
|
||||
""""" if the value is usable for this setting, else why it is not."""
|
||||
kind = entry["type"]
|
||||
if kind == "bool":
|
||||
return "" if isinstance(value, bool) else "is not a yes or no"
|
||||
if kind == "enum":
|
||||
# Compared as text because an enum is words in some settings and numbers
|
||||
# in others, and JSON gives back 3 where the schema wrote 3.
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
return "is not one of this setting's choices"
|
||||
value = str(value)
|
||||
choices = entry.get("options") or []
|
||||
# A schema entry whose choices could not be read is not grounds to
|
||||
# accept anything: an unrecognised value would be written straight into
|
||||
# the store and break whatever reads it.
|
||||
if not choices:
|
||||
return "cannot be checked against this setting's choices"
|
||||
return "" if value in choices else "is not one of this setting's choices"
|
||||
# "real" is what the schema calls a float. Spelling it "float" here meant
|
||||
# fifteen settings were accepted without their range being checked at all.
|
||||
if kind in ("int", "real", "float"):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return "is not a number"
|
||||
if "min" in entry and value < entry["min"]:
|
||||
return f"is below the minimum of {entry['min']:g}"
|
||||
if "max" in entry and value > entry["max"]:
|
||||
return f"is above the maximum of {entry['max']:g}"
|
||||
return ""
|
||||
if kind == "string":
|
||||
if not isinstance(value, str):
|
||||
return "is not text"
|
||||
if "options" in entry and entry["options"] and value not in entry["options"]:
|
||||
return "is not one of the choices this setting allows"
|
||||
if "pattern" in entry:
|
||||
try:
|
||||
if not re.match(entry["pattern"], value):
|
||||
return "does not match the form this setting takes"
|
||||
except re.error:
|
||||
return ""
|
||||
return ""
|
||||
if kind == "json":
|
||||
return "" if isinstance(value, (list, dict)) else "is not a list or an object"
|
||||
return ""
|
||||
|
||||
|
||||
def exportable() -> tuple[dict, list[dict]]:
|
||||
known = schema()
|
||||
current = stored()
|
||||
|
||||
carried: dict = {}
|
||||
left: list[dict] = []
|
||||
for key, value in sorted(current.items()):
|
||||
if key in MACHINE_SPECIFIC:
|
||||
left.append({"key": key, "reason": MACHINE_SPECIFIC[key]})
|
||||
continue
|
||||
if key not in known:
|
||||
left.append({"key": key, "reason": "is not a setting this version declares"})
|
||||
continue
|
||||
problem = fits(known[key], value)
|
||||
if problem:
|
||||
left.append({"key": key, "reason": "holds a value that " + problem})
|
||||
continue
|
||||
carried[key] = value
|
||||
return carried, left
|
||||
|
||||
|
||||
def export(path: str) -> dict:
|
||||
carried, left = exportable()
|
||||
bundle = {
|
||||
"format": FORMAT,
|
||||
"exportedAt": int(time.time()),
|
||||
"exportedFrom": socket.gethostname(),
|
||||
"settings": carried,
|
||||
}
|
||||
target = Path(path).expanduser()
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8")
|
||||
# Readable only by its owner: it is not secret, but it is a description
|
||||
# of somebody's machine and there is no reason to hand it around.
|
||||
target.chmod(0o600)
|
||||
except OSError as error:
|
||||
raise BoundaryError("That file could not be written.") from error
|
||||
|
||||
return {"path": str(target), "carried": len(carried), "left": left}
|
||||
|
||||
|
||||
def read_bundle(path: str) -> dict:
|
||||
source = Path(path).expanduser()
|
||||
try:
|
||||
bundle = json.loads(source.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as error:
|
||||
raise BoundaryError("That file does not exist.") from error
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise BoundaryError("That file is not a settings export.") from error
|
||||
if not isinstance(bundle, dict) or not str(bundle.get("format", "")).startswith("panama-settings-sync/"):
|
||||
raise BoundaryError("That file is not a settings export.")
|
||||
if not isinstance(bundle.get("settings"), dict):
|
||||
raise BoundaryError("That export contains no settings.")
|
||||
return bundle
|
||||
|
||||
|
||||
def plan(path: str) -> dict:
|
||||
"""What an import would do, without doing any of it."""
|
||||
bundle = read_bundle(path)
|
||||
known = schema()
|
||||
current = stored()
|
||||
|
||||
apply: dict = {}
|
||||
changes: list[dict] = []
|
||||
skipped: list[dict] = []
|
||||
|
||||
for key, value in sorted(bundle["settings"].items()):
|
||||
if key in MACHINE_SPECIFIC:
|
||||
skipped.append({"key": key, "reason": MACHINE_SPECIFIC[key]})
|
||||
continue
|
||||
if key not in known:
|
||||
skipped.append({"key": key, "reason": "is not a setting this version has"})
|
||||
continue
|
||||
problem = fits(known[key], value)
|
||||
if problem:
|
||||
skipped.append({"key": key, "reason": "the value " + problem})
|
||||
continue
|
||||
if key in PATH_VALUED:
|
||||
missing = [p for p in (value if isinstance(value, list) else [value])
|
||||
if isinstance(p, str) and p and not Path(p).expanduser().exists()]
|
||||
if missing:
|
||||
skipped.append({"key": key,
|
||||
"reason": "points at a file this machine does not have"})
|
||||
continue
|
||||
if current.get(key) == value:
|
||||
continue
|
||||
apply[key] = value
|
||||
changes.append({"key": key,
|
||||
"from": current.get(key, None),
|
||||
"to": value})
|
||||
|
||||
return {
|
||||
"path": str(Path(path).expanduser()),
|
||||
"exportedFrom": str(bundle.get("exportedFrom", "")),
|
||||
"exportedAt": int(bundle.get("exportedAt", 0)),
|
||||
"changes": changes,
|
||||
"skipped": skipped,
|
||||
"apply": apply,
|
||||
}
|
||||
|
||||
|
||||
def apply_import(path: str) -> dict:
|
||||
"""Merge an export into this machine's settings.
|
||||
|
||||
Written whole through a temporary file and a rename, so a crash midway
|
||||
leaves the old settings intact rather than half of each. Settings not named
|
||||
by the export are untouched: this is a merge, not a replacement, because an
|
||||
export from a machine that never changed a setting should not reset it here.
|
||||
"""
|
||||
prepared = plan(path)
|
||||
if not prepared["apply"]:
|
||||
return {**prepared, "applied": 0}
|
||||
|
||||
current = stored()
|
||||
current.update(prepared["apply"])
|
||||
|
||||
try:
|
||||
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = SETTINGS.with_suffix(".sync-tmp")
|
||||
temporary.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8")
|
||||
os.replace(temporary, SETTINGS)
|
||||
except OSError as error:
|
||||
raise BoundaryError("This machine's settings could not be written.") from error
|
||||
|
||||
return {**prepared, "applied": len(prepared["apply"])}
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if len(arguments) == 2 and arguments[0] == "export":
|
||||
result = export(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "preview":
|
||||
result = plan(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "import":
|
||||
result = apply_import(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-settings-sync export PATH | preview PATH | import PATH")
|
||||
except BoundaryError as error:
|
||||
print(json.dumps({"error": str(error)}, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
result["error"] = ""
|
||||
print(json.dumps(result, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""What this machine offers to other machines, and switches for it.
|
||||
|
||||
Every row reports what is actually true, including "the software for this is not
|
||||
installed". GNOME's Sharing panel shows switches for services that are absent,
|
||||
which is how a switch ends up doing nothing at all.
|
||||
|
||||
Enabling remote login is a system-wide change and goes through pkexec, which
|
||||
prompts with the polkit agent this desktop already runs. Remote desktop is a
|
||||
user service and needs no privilege.
|
||||
|
||||
panama-sharing snapshot
|
||||
panama-sharing set-remote-login true|false
|
||||
panama-sharing set-remote-desktop true|false
|
||||
panama-sharing set-hostname NAME
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HOSTNAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,62}$")
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or permission failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 25.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BoundaryError(f"{command[0]} did not answer.") from error
|
||||
|
||||
|
||||
def unit_state(unit: str, user: bool = False) -> dict:
|
||||
scope = ["--user"] if user else []
|
||||
active = run(["systemctl", *scope, "is-active", unit]).stdout.strip()
|
||||
enabled = run(["systemctl", *scope, "is-enabled", unit]).stdout.strip()
|
||||
return {
|
||||
"installed": enabled not in ("", "not-found"),
|
||||
"active": active == "active",
|
||||
"enabled": enabled == "enabled",
|
||||
}
|
||||
|
||||
|
||||
def ssh_setting(name: str) -> str:
|
||||
"""What sshd's own configuration says, or "" when it says nothing.
|
||||
|
||||
`sshd -T` would be authoritative but needs root. Reading the files means
|
||||
reporting "not configured" rather than guessing a default -- which matters,
|
||||
because claiming "keys only" on a machine that actually accepts passwords
|
||||
would be a security claim this cannot back up.
|
||||
"""
|
||||
paths = [Path("/etc/ssh/sshd_config")]
|
||||
paths.extend(sorted(Path("/etc/ssh/sshd_config.d").glob("*.conf"))
|
||||
if Path("/etc/ssh/sshd_config.d").is_dir() else [])
|
||||
pattern = re.compile(rf"^\s*{name}\s+(\S+)", re.IGNORECASE)
|
||||
for path in paths:
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
found = pattern.match(line)
|
||||
if found:
|
||||
return found.group(1)
|
||||
except OSError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def remote_desktop() -> dict:
|
||||
state = unit_state("gnome-remote-desktop.service", user=True)
|
||||
state["available"] = bool(shutil.which("grdctl"))
|
||||
state["rdpEnabled"] = False
|
||||
state["port"] = ""
|
||||
state["hasCredentials"] = False
|
||||
state["viewOnly"] = False
|
||||
if not state["available"]:
|
||||
return state
|
||||
|
||||
status = run(["grdctl", "status"]).stdout
|
||||
section = status.split("RDP:", 1)
|
||||
if len(section) > 1:
|
||||
block = section[1].split("VNC:", 1)[0]
|
||||
state["rdpEnabled"] = re.search(r"Status:\s*enabled", block) is not None
|
||||
port = re.search(r"Port:\s*(\d+)", block)
|
||||
state["port"] = port.group(1) if port else ""
|
||||
# grdctl prints "(hidden)" when a credential is stored and nothing when
|
||||
# it is not, so this reads presence without ever reading the value.
|
||||
state["hasCredentials"] = "(hidden)" in block
|
||||
state["viewOnly"] = re.search(r"View-only:\s*yes", block) is not None
|
||||
return state
|
||||
|
||||
|
||||
def active_logins() -> list[dict[str, str]]:
|
||||
"""Who is signed in from another machine right now.
|
||||
|
||||
Read from `who`, which names the user, when they arrived, and where from.
|
||||
Only sessions with an origin are reported: a local seat has none, and
|
||||
listing the person sitting at the keyboard as a remote login would be
|
||||
alarming and wrong.
|
||||
"""
|
||||
result = run(["who"])
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions: list[dict[str, str]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
match = re.match(r"^(\S+)\s+(\S+)\s+(.+?)\s+\((.+)\)\s*$", line)
|
||||
if not match:
|
||||
continue
|
||||
user, line_name, when, origin = match.groups()
|
||||
# X displays appear in the same parenthesised field as a hostname.
|
||||
if origin.startswith(":") or origin in ("localhost", ""):
|
||||
continue
|
||||
sessions.append({
|
||||
"user": user,
|
||||
"line": line_name,
|
||||
"since": when.strip(),
|
||||
"from": origin,
|
||||
})
|
||||
return sessions
|
||||
|
||||
|
||||
def media_sharing() -> dict:
|
||||
"""Rygel, which serves media to devices on the network over DLNA.
|
||||
|
||||
Reported as a running state rather than just "installed", because installed
|
||||
and off is the normal case and is not the same thing as sharing. Turning it
|
||||
on publishes media directories to every device on the network, which is why
|
||||
the page says so next to the switch.
|
||||
"""
|
||||
if not shutil.which("rygel"):
|
||||
return {"installed": False, "active": False, "enabled": False, "package": "rygel"}
|
||||
state = unit_state("rygel.service", user=True)
|
||||
state["installed"] = True
|
||||
state["package"] = "rygel"
|
||||
return state
|
||||
|
||||
|
||||
def set_media_sharing(enabled: bool) -> None:
|
||||
if not shutil.which("rygel"):
|
||||
raise BoundaryError("Rygel is not installed.")
|
||||
verb = "enable" if enabled else "disable"
|
||||
result = run(["systemctl", "--user", verb, "--now", "rygel.service"])
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "Media sharing could not be changed.")
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
static_name = run(["hostnamectl", "--static"]).stdout.strip()
|
||||
pretty_name = run(["hostnamectl", "--pretty"]).stdout.strip()
|
||||
|
||||
login = unit_state("sshd.service")
|
||||
login["port"] = ssh_setting("Port") or "22"
|
||||
login["passwordAuthentication"] = ssh_setting("PasswordAuthentication")
|
||||
login["rootLogin"] = ssh_setting("PermitRootLogin")
|
||||
login["sessions"] = active_logins()
|
||||
|
||||
return {
|
||||
"hostname": static_name,
|
||||
"prettyHostname": pretty_name,
|
||||
"remoteLogin": login,
|
||||
"remoteDesktop": remote_desktop(),
|
||||
# Reported as absent rather than offered as a switch that would do
|
||||
# nothing. Installing software is not this page's job.
|
||||
"fileSharing": {"installed": bool(shutil.which("smbd")), "package": "samba"},
|
||||
"mediaSharing": media_sharing(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def set_remote_login(enabled: bool) -> None:
|
||||
if not unit_state("sshd.service")["installed"]:
|
||||
raise BoundaryError("OpenSSH server is not installed.")
|
||||
action = ["enable", "--now"] if enabled else ["disable", "--now"]
|
||||
result = run(["pkexec", "systemctl", *action, "sshd.service"], timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "Remote login could not be changed."))
|
||||
|
||||
|
||||
def set_remote_desktop(enabled: bool) -> None:
|
||||
state = remote_desktop()
|
||||
if not state["available"]:
|
||||
raise BoundaryError("Remote desktop support is not installed.")
|
||||
if enabled and not state["hasCredentials"]:
|
||||
raise BoundaryError("Set a remote desktop username and password first.")
|
||||
|
||||
toggle = run(["grdctl", "rdp", "enable" if enabled else "disable"])
|
||||
if toggle.returncode != 0:
|
||||
raise BoundaryError(_refusal(toggle, "Remote desktop could not be changed."))
|
||||
|
||||
action = ["enable", "--now"] if enabled else ["disable", "--now"]
|
||||
result = run(["systemctl", "--user", *action, "gnome-remote-desktop.service"], timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The remote desktop service could not be changed."))
|
||||
|
||||
|
||||
def set_rdp_port(port: str) -> None:
|
||||
if not port.isdigit() or not (1 <= int(port) <= 65535):
|
||||
raise BoundaryError("That is not a port number.")
|
||||
result = run(["grdctl", "rdp", "set-port", port])
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The port could not be changed."))
|
||||
|
||||
|
||||
def set_rdp_view_only(view_only: bool) -> None:
|
||||
result = run(["grdctl", "rdp",
|
||||
"enable-view-only" if view_only else "disable-view-only"])
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "That could not be changed."))
|
||||
|
||||
|
||||
def clear_rdp_credentials() -> None:
|
||||
result = run(["grdctl", "rdp", "clear-credentials"])
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The credentials could not be cleared."))
|
||||
|
||||
|
||||
def set_hostname(name: str) -> None:
|
||||
if not HOSTNAME.fullmatch(name or ""):
|
||||
raise BoundaryError("A name may use letters, digits and hyphens.")
|
||||
result = run(["hostnamectl", "set-hostname", name], timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The name could not be changed."))
|
||||
|
||||
|
||||
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
|
||||
text = (result.stderr or "").strip().splitlines()
|
||||
if text and ("not authorized" in text[-1].lower() or "dismissed" in text[-1].lower()):
|
||||
return "That change was not authorized."
|
||||
return text[-1][:200] if text else fallback
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "set-media-sharing":
|
||||
set_media_sharing(arguments[1] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-remote-login":
|
||||
set_remote_login(arguments[1] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-remote-desktop":
|
||||
set_remote_desktop(arguments[1] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-hostname":
|
||||
set_hostname(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "set-rdp-port":
|
||||
set_rdp_port(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "set-rdp-view-only":
|
||||
set_rdp_view_only(arguments[1] == "true")
|
||||
elif arguments == ["clear-rdp-credentials"]:
|
||||
clear_rdp_credentials()
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-sharing snapshot | set-remote-login true|false | "
|
||||
"set-remote-desktop true|false | set-hostname NAME | "
|
||||
"set-rdp-port PORT | set-rdp-view-only true|false | "
|
||||
"clear-rdp-credentials")
|
||||
except BoundaryError as error:
|
||||
state = snapshot()
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Snapshots, through snapper.
|
||||
|
||||
A point in time you can go back to, per btrfs subvolume. This machine already
|
||||
had snapper running hourly, but only for / -- /home is a separate subvolume and
|
||||
had no configuration at all, so six hundred snapshots existed and not one of
|
||||
them contained a document.
|
||||
|
||||
Deliberately absent: rollback. snapper's rollback works by changing the btrfs
|
||||
default subvolume, and this system's fstab pins subvol=root and subvol=home
|
||||
explicitly, which overrides it -- so a rollback would appear to succeed and
|
||||
change nothing after a reboot. Restoring files and folders out of a snapshot
|
||||
needs no reboot, cannot affect booting, and covers the cases people actually
|
||||
hit.
|
||||
|
||||
panama-snapshots snapshot
|
||||
panama-snapshots create CONFIG DESCRIPTION
|
||||
panama-snapshots delete CONFIG NUMBER
|
||||
panama-snapshots set-retention CONFIG HOURLY DAILY WEEKLY
|
||||
panama-snapshots set-timeline CONFIG true|false
|
||||
panama-snapshots browse CONFIG NUMBER [RELATIVE_PATH]
|
||||
panama-snapshots restore CONFIG NUMBER RELATIVE_PATH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
|
||||
# Directory listings are for choosing something to restore, not for browsing a
|
||||
# terabyte. A folder with more entries than this is reported as truncated.
|
||||
BROWSE_LIMIT = 400
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or snapper failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError("snapper did not answer in time.") from error
|
||||
except OSError as error:
|
||||
raise BoundaryError("snapper is not available.") from error
|
||||
|
||||
|
||||
def require_config(name: str) -> str:
|
||||
if not CONFIG_NAME.fullmatch(name or ""):
|
||||
raise BoundaryError("That is not a snapshot configuration.")
|
||||
return name
|
||||
|
||||
|
||||
def require_number(value: str) -> int:
|
||||
if not str(value).isdigit():
|
||||
raise BoundaryError("That is not a snapshot.")
|
||||
number = int(value)
|
||||
# 0 is the live filesystem, not a snapshot, and must never be a target.
|
||||
if number < 1:
|
||||
raise BoundaryError("That is the current state, not a snapshot.")
|
||||
return number
|
||||
|
||||
|
||||
def config_names() -> list[str]:
|
||||
result = run(["snapper", "list-configs"])
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
names = []
|
||||
for line in result.stdout.splitlines()[2:]:
|
||||
parts = [part.strip() for part in line.split("│")]
|
||||
if len(parts) >= 2 and CONFIG_NAME.fullmatch(parts[0]):
|
||||
names.append(parts[0])
|
||||
return names
|
||||
|
||||
|
||||
def config_settings(name: str) -> dict:
|
||||
result = run(["snapper", "-c", name, "get-config"])
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
values = {}
|
||||
for line in result.stdout.splitlines()[2:]:
|
||||
parts = [part.strip() for part in line.split("│")]
|
||||
if len(parts) >= 2:
|
||||
values[parts[0]] = parts[1]
|
||||
return values
|
||||
|
||||
|
||||
def snapshots_for(name: str) -> list[dict]:
|
||||
result = run(["snapper", "-c", name, "list"], timeout=45)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
entries = []
|
||||
for line in result.stdout.splitlines()[2:]:
|
||||
parts = [part.strip() for part in line.split("│")]
|
||||
if len(parts) < 7 or not parts[0].isdigit():
|
||||
continue
|
||||
number = int(parts[0])
|
||||
if number == 0:
|
||||
continue
|
||||
entries.append({
|
||||
"number": number,
|
||||
"kind": parts[1],
|
||||
"date": parts[3],
|
||||
"user": parts[4],
|
||||
"cleanup": parts[5],
|
||||
"description": parts[6],
|
||||
# A snapshot with no cleanup algorithm is not on the timeline's
|
||||
# list to remove, which is what "kept" means to someone reading it.
|
||||
"kept": parts[5] == "",
|
||||
})
|
||||
entries.sort(key=lambda entry: entry["number"], reverse=True)
|
||||
return entries
|
||||
|
||||
|
||||
def btrfs_subvolumes() -> list[dict]:
|
||||
"""Mounted btrfs subvolumes, so the page can name what is NOT protected."""
|
||||
result = run(["findmnt", "-t", "btrfs", "-J", "-o", "TARGET,OPTIONS"])
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
found = []
|
||||
|
||||
def walk(nodes):
|
||||
for node in nodes:
|
||||
options = str(node.get("options", ""))
|
||||
target = str(node.get("target", ""))
|
||||
match = re.search(r"subvol=(/[^,]*)", options)
|
||||
# .snapshots holds the snapshots themselves and is not a thing to
|
||||
# protect; listing it would offer to snapshot the snapshots.
|
||||
if match and "/.snapshots" not in target:
|
||||
found.append({"path": target, "subvolume": match.group(1)})
|
||||
walk(node.get("children", []))
|
||||
|
||||
walk(payload.get("filesystems", []))
|
||||
return found
|
||||
|
||||
|
||||
def free_space() -> dict:
|
||||
try:
|
||||
usage = shutil.disk_usage("/home")
|
||||
except OSError:
|
||||
return {"freeBytes": 0, "totalBytes": 0}
|
||||
return {"freeBytes": usage.free, "totalBytes": usage.total}
|
||||
|
||||
|
||||
def timeline_running() -> bool:
|
||||
return run(["systemctl", "is-active", "snapper-timeline.timer"]).stdout.strip() == "active"
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
configs = []
|
||||
protected_paths = set()
|
||||
for name in config_names():
|
||||
settings = config_settings(name)
|
||||
subvolume = settings.get("SUBVOLUME", "")
|
||||
protected_paths.add(subvolume)
|
||||
configs.append({
|
||||
"name": name,
|
||||
"subvolume": subvolume,
|
||||
"timelineEnabled": settings.get("TIMELINE_CREATE", "no") == "yes",
|
||||
# Empty when this user cannot read the config at all, which is a
|
||||
# different state from "no snapshots".
|
||||
"readable": bool(settings),
|
||||
"limits": {
|
||||
"hourly": int(settings.get("TIMELINE_LIMIT_HOURLY") or 0),
|
||||
"daily": int(settings.get("TIMELINE_LIMIT_DAILY") or 0),
|
||||
"weekly": int(settings.get("TIMELINE_LIMIT_WEEKLY") or 0),
|
||||
"monthly": int(settings.get("TIMELINE_LIMIT_MONTHLY") or 0),
|
||||
"yearly": int(settings.get("TIMELINE_LIMIT_YEARLY") or 0),
|
||||
},
|
||||
"snapshots": snapshots_for(name) if settings else [],
|
||||
})
|
||||
configs.sort(key=lambda entry: entry["subvolume"])
|
||||
|
||||
unprotected = [entry for entry in btrfs_subvolumes()
|
||||
if entry["path"] not in protected_paths]
|
||||
|
||||
return {
|
||||
"configs": configs,
|
||||
"unprotected": unprotected,
|
||||
"timelineRunning": timeline_running(),
|
||||
"space": free_space(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def snapshot_root(config: str, number: int) -> Path:
|
||||
settings = config_settings(config)
|
||||
subvolume = settings.get("SUBVOLUME", "")
|
||||
if not subvolume:
|
||||
raise BoundaryError("That snapshot configuration cannot be read.")
|
||||
path = Path(subvolume) / ".snapshots" / str(number) / "snapshot"
|
||||
if not path.is_dir():
|
||||
raise BoundaryError("That snapshot is not available.")
|
||||
return path
|
||||
|
||||
|
||||
def safe_relative(root: Path, relative: str) -> Path:
|
||||
"""Resolve a path inside a snapshot, refusing anything that escapes it.
|
||||
|
||||
The caller is a settings page passing a path a person clicked, and ".." in
|
||||
the wrong place would read or restore from outside the snapshot entirely.
|
||||
"""
|
||||
candidate = (root / relative.lstrip("/")).resolve()
|
||||
if candidate != root.resolve() and root.resolve() not in candidate.parents:
|
||||
raise BoundaryError("That path is not inside the snapshot.")
|
||||
return candidate
|
||||
|
||||
|
||||
def browse(config: str, number: str, relative: str) -> dict:
|
||||
root = snapshot_root(require_config(config), require_number(number))
|
||||
target = safe_relative(root, relative)
|
||||
if not target.is_dir():
|
||||
raise BoundaryError("That is not a folder in this snapshot.")
|
||||
|
||||
entries = []
|
||||
truncated = False
|
||||
try:
|
||||
with os.scandir(target) as scan:
|
||||
for item in scan:
|
||||
if len(entries) >= BROWSE_LIMIT:
|
||||
truncated = True
|
||||
break
|
||||
try:
|
||||
is_dir = item.is_dir(follow_symlinks=False)
|
||||
size = 0 if is_dir else item.stat(follow_symlinks=False).st_size
|
||||
except OSError:
|
||||
continue
|
||||
entries.append({"name": item.name, "directory": is_dir, "bytes": size})
|
||||
except PermissionError as error:
|
||||
raise BoundaryError("That folder cannot be read from this snapshot.") from error
|
||||
except OSError as error:
|
||||
raise BoundaryError("That folder could not be listed.") from error
|
||||
|
||||
entries.sort(key=lambda entry: (not entry["directory"], entry["name"].lower()))
|
||||
return {"path": relative, "entries": entries, "truncated": truncated, "error": ""}
|
||||
|
||||
|
||||
def restore(config: str, number: str, relative: str) -> dict:
|
||||
"""Copy something out of a snapshot, keeping whatever is there now.
|
||||
|
||||
The current version is moved aside rather than overwritten. A restore that
|
||||
destroys the thing you were about to compare it against is how people lose
|
||||
the work they were trying to save.
|
||||
"""
|
||||
name = require_config(config)
|
||||
index = require_number(number)
|
||||
root = snapshot_root(name, index)
|
||||
source = safe_relative(root, relative)
|
||||
if not source.exists():
|
||||
raise BoundaryError("That is not in this snapshot.")
|
||||
|
||||
settings = config_settings(name)
|
||||
live_root = Path(settings.get("SUBVOLUME", ""))
|
||||
destination = safe_relative(live_root, relative)
|
||||
|
||||
kept = ""
|
||||
if destination.exists():
|
||||
kept = str(destination) + f".before-restore-{index}"
|
||||
suffix = 1
|
||||
while Path(kept).exists():
|
||||
suffix += 1
|
||||
kept = str(destination) + f".before-restore-{index}-{suffix}"
|
||||
try:
|
||||
os.rename(destination, kept)
|
||||
except OSError as error:
|
||||
raise BoundaryError("The current version could not be set aside.") from error
|
||||
|
||||
try:
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, destination, symlinks=True)
|
||||
else:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, destination, follow_symlinks=False)
|
||||
except OSError as error:
|
||||
raise BoundaryError("That could not be restored.") from error
|
||||
|
||||
return {"restored": str(destination), "keptAs": kept}
|
||||
|
||||
|
||||
def create(config: str, description: str) -> None:
|
||||
if len(description) > 200 or "\n" in description:
|
||||
raise BoundaryError("That description cannot be used.")
|
||||
result = run(["snapper", "-c", require_config(config), "create",
|
||||
"--description", description or "manual snapshot"], timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The snapshot could not be taken."))
|
||||
|
||||
|
||||
def delete(config: str, number: str) -> None:
|
||||
result = run(["snapper", "-c", require_config(config), "delete",
|
||||
str(require_number(number))], timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "That snapshot could not be removed."))
|
||||
|
||||
|
||||
def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None:
|
||||
values = []
|
||||
for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)):
|
||||
if not str(value).isdigit() or int(value) > 999:
|
||||
raise BoundaryError("Keep counts must be whole numbers.")
|
||||
values.append(f"TIMELINE_LIMIT_{label}={int(value)}")
|
||||
result = run(["snapper", "-c", require_config(config), "set-config", *values])
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The keep counts could not be changed."))
|
||||
|
||||
|
||||
def set_timeline(config: str, enabled: str) -> None:
|
||||
result = run(["snapper", "-c", require_config(config), "set-config",
|
||||
f"TIMELINE_CREATE={'yes' if enabled == 'true' else 'no'}"])
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "Automatic snapshots could not be changed."))
|
||||
|
||||
|
||||
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
|
||||
text = (result.stderr or result.stdout or "").strip().splitlines()
|
||||
if not text:
|
||||
return fallback
|
||||
last = text[-1]
|
||||
if "permission" in last.lower():
|
||||
return "This account is not allowed to change that configuration."
|
||||
return last[:200]
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if len(arguments) in (3, 4) and arguments[0] == "browse":
|
||||
print(json.dumps(browse(arguments[1], arguments[2],
|
||||
arguments[3] if len(arguments) == 4 else ""),
|
||||
separators=(",", ":")))
|
||||
return 0
|
||||
if len(arguments) == 4 and arguments[0] == "restore":
|
||||
outcome = restore(arguments[1], arguments[2], arguments[3])
|
||||
state = snapshot()
|
||||
state["restored"] = outcome
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "create":
|
||||
create(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "delete":
|
||||
delete(arguments[1], arguments[2])
|
||||
elif len(arguments) == 5 and arguments[0] == "set-retention":
|
||||
set_retention(arguments[1], arguments[2], arguments[3], arguments[4])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-timeline":
|
||||
set_timeline(arguments[1], arguments[2])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-snapshots snapshot | create CONFIG DESCRIPTION | "
|
||||
"delete CONFIG NUMBER | set-retention CONFIG HOURLY DAILY WEEKLY | "
|
||||
"set-timeline CONFIG true|false | browse CONFIG NUMBER [PATH] | "
|
||||
"restore CONFIG NUMBER PATH")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"configs": [], "unprotected": [], "timelineRunning": False,
|
||||
"space": {"freeBytes": 0, "totalBytes": 0}}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""SSH keys, the agent holding them, and the hosts this machine has met.
|
||||
|
||||
Nothing here ever reads a private key. Fingerprints and comments come from the
|
||||
matching .pub file, and whether a key is encrypted is answered by asking
|
||||
ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an
|
||||
unencrypted key and fails for an encrypted one, and either way the only thing it
|
||||
can print is public material.
|
||||
|
||||
No passphrase passes through this tool at all. Adding an encrypted key to the
|
||||
agent lets ssh-add prompt through the system's own askpass, which is where that
|
||||
belongs -- a settings page collecting a passphrase and handing it on would be a
|
||||
worse place for it to live, and putting one in argv would publish it to every
|
||||
process on the machine.
|
||||
|
||||
panama-ssh-keys snapshot
|
||||
panama-ssh-keys agent-add PATH | agent-remove PATH
|
||||
panama-ssh-keys forget-host HOST
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
KNOWN_HOSTS = SSH_DIR / "known_hosts"
|
||||
|
||||
# A host as it may appear in known_hosts, including [host]:port forms.
|
||||
HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$")
|
||||
|
||||
# gnome-keyring's agent, which is what runs on this desktop. Only used when the
|
||||
# environment has not already named one, so an ssh-agent started by hand wins.
|
||||
KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or ssh failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 15.0, env: dict | None = None):
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, env=env)
|
||||
except FileNotFoundError as error:
|
||||
raise BoundaryError(f"{command[0]} is not installed.") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError(f"{command[0]} did not respond.") from error
|
||||
|
||||
|
||||
def agent_environment() -> dict:
|
||||
"""The environment an ssh-add call should run in.
|
||||
|
||||
A settings window inherits whatever the shell was started with, which on
|
||||
this desktop does not include SSH_AUTH_SOCK -- so without this the page
|
||||
would report "no agent" while one is plainly running.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
if not environment.get("SSH_AUTH_SOCK") and KEYRING_SOCKET.is_socket():
|
||||
environment["SSH_AUTH_SOCK"] = str(KEYRING_SOCKET)
|
||||
return environment
|
||||
|
||||
|
||||
def agent_state() -> dict:
|
||||
environment = agent_environment()
|
||||
socket = environment.get("SSH_AUTH_SOCK", "")
|
||||
if not socket:
|
||||
return {"available": False, "socket": "", "kind": "", "durableRemoval": False,
|
||||
"fingerprints": [], "detail": "No SSH agent is running."}
|
||||
|
||||
result = run(["ssh-add", "-l"], env=environment)
|
||||
# ssh-add exits 1 for "no identities" and 2 for "cannot connect", which are
|
||||
# very different things to report.
|
||||
if result.returncode == 2:
|
||||
return {"available": False, "socket": socket, "kind": "", "durableRemoval": False,
|
||||
"fingerprints": [], "detail": "An agent socket exists but could not be reached."}
|
||||
|
||||
fingerprints = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1].startswith("SHA256:"):
|
||||
fingerprints.append(parts[1])
|
||||
# gnome-keyring's agent enumerates whatever keys it finds in ~/.ssh, so
|
||||
# `ssh-add -d` reports "Identity removed" and the key is still listed a
|
||||
# second later -- it comes straight back from disk. A plain ssh-agent
|
||||
# removes durably. Measured on this machine rather than assumed, because an
|
||||
# Unload button that reports success and changes nothing is worse than no
|
||||
# button at all.
|
||||
keyring = "/keyring/" in socket
|
||||
return {
|
||||
"available": True,
|
||||
"socket": socket,
|
||||
"kind": "gnome-keyring" if keyring else "ssh-agent",
|
||||
"durableRemoval": not keyring,
|
||||
"fingerprints": fingerprints,
|
||||
"detail": "" if fingerprints else "The agent is running but holds no keys.",
|
||||
}
|
||||
|
||||
|
||||
def encrypted(private: Path) -> bool | None:
|
||||
"""Whether a private key needs a passphrase.
|
||||
|
||||
Asked by deriving the public key with an empty passphrase. That reads the
|
||||
file, but the only thing it can ever emit is the public half, and the answer
|
||||
is not obtainable any other way without parsing key material directly.
|
||||
"""
|
||||
result = run(["ssh-keygen", "-y", "-P", "", "-f", str(private)], timeout=10.0)
|
||||
if result.returncode == 0:
|
||||
return False
|
||||
detail = (result.stderr or "").lower()
|
||||
if "incorrect passphrase" in detail or "load failed" in detail:
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def keys(agent: dict) -> list[dict]:
|
||||
if not SSH_DIR.is_dir():
|
||||
return []
|
||||
|
||||
held = set(agent.get("fingerprints") or [])
|
||||
found = []
|
||||
for public in sorted(SSH_DIR.glob("*.pub")):
|
||||
private = public.with_suffix("")
|
||||
described = run(["ssh-keygen", "-l", "-f", str(public)], timeout=10.0)
|
||||
if described.returncode != 0:
|
||||
continue
|
||||
parts = (described.stdout or "").split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
bits, fingerprint = parts[0], parts[1]
|
||||
kind = parts[-1].strip("()")
|
||||
comment = " ".join(parts[2:-1]).strip()
|
||||
|
||||
found.append({
|
||||
"name": private.name,
|
||||
"path": str(private),
|
||||
"publicPath": str(public),
|
||||
"type": kind,
|
||||
"bits": int(bits) if bits.isdigit() else 0,
|
||||
"fingerprint": fingerprint,
|
||||
"comment": comment if comment != "no" else "",
|
||||
"hasPrivate": private.is_file(),
|
||||
"encrypted": encrypted(private) if private.is_file() else None,
|
||||
"loaded": fingerprint in held,
|
||||
# Read so the page can say when a key is readable by other people;
|
||||
# a private key must be 0600.
|
||||
"mode": oct(private.stat().st_mode & 0o777)[2:] if private.is_file() else "",
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
def hosts() -> list[dict]:
|
||||
"""Hosts in known_hosts, grouped by name.
|
||||
|
||||
A hashed known_hosts cannot be listed -- that is the entire point of hashing
|
||||
it -- so that is reported rather than shown as an empty list.
|
||||
"""
|
||||
if not KNOWN_HOSTS.is_file():
|
||||
return []
|
||||
|
||||
grouped: dict[str, dict] = {}
|
||||
try:
|
||||
lines = KNOWN_HOSTS.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError as error:
|
||||
raise BoundaryError("known_hosts could not be read.") from error
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
names, kind = parts[0], parts[1]
|
||||
if names.startswith("|1|"):
|
||||
entry = grouped.setdefault("", {"host": "", "hashed": True, "types": [], "count": 0})
|
||||
entry["count"] += 1
|
||||
if kind not in entry["types"]:
|
||||
entry["types"].append(kind)
|
||||
continue
|
||||
for name in names.split(","):
|
||||
entry = grouped.setdefault(name, {"host": name, "hashed": False, "types": [], "count": 0})
|
||||
entry["count"] += 1
|
||||
if kind not in entry["types"]:
|
||||
entry["types"].append(kind)
|
||||
|
||||
ordered = [entry for key, entry in sorted(grouped.items()) if key != ""]
|
||||
if "" in grouped:
|
||||
ordered.append(grouped[""])
|
||||
return ordered
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
agent = agent_state()
|
||||
return {
|
||||
"available": SSH_DIR.is_dir(),
|
||||
"directory": str(SSH_DIR),
|
||||
"agent": agent,
|
||||
"keys": keys(agent),
|
||||
"hosts": hosts(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def resolve_key(path: str) -> Path:
|
||||
"""A key path, confined to ~/.ssh.
|
||||
|
||||
Resolved and compared against the directory so that a name cannot walk out
|
||||
of it, and refused if it is not a file this tool put there.
|
||||
"""
|
||||
candidate = Path(path)
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise BoundaryError("That key no longer exists.") from error
|
||||
if resolved.parent != SSH_DIR.resolve(strict=False):
|
||||
raise BoundaryError("That key is not in the SSH directory.")
|
||||
if not resolved.is_file():
|
||||
raise BoundaryError("That is not a key file.")
|
||||
return resolved
|
||||
|
||||
|
||||
def agent_add(path: str) -> None:
|
||||
key = resolve_key(path)
|
||||
environment = agent_environment()
|
||||
if not environment.get("SSH_AUTH_SOCK"):
|
||||
raise BoundaryError("No SSH agent is running.")
|
||||
# No passphrase is supplied here on purpose. An encrypted key makes ssh-add
|
||||
# prompt through the system's askpass, which is the right place for it.
|
||||
result = run(["ssh-add", str(key)], timeout=120.0, env=environment)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "That key could not be added.")
|
||||
|
||||
|
||||
def agent_remove(path: str) -> None:
|
||||
key = resolve_key(path)
|
||||
environment = agent_environment()
|
||||
if not environment.get("SSH_AUTH_SOCK"):
|
||||
raise BoundaryError("No SSH agent is running.")
|
||||
if not agent_state().get("durableRemoval", True):
|
||||
raise BoundaryError(
|
||||
"This desktop's agent lists every key in ~/.ssh, so removing one "
|
||||
"does not stick. Move the key out of ~/.ssh to stop it being offered.")
|
||||
result = run(["ssh-add", "-d", str(key)], env=environment)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "That key could not be removed.")
|
||||
|
||||
|
||||
def forget_host(host: str) -> None:
|
||||
"""Drop a host's keys from known_hosts.
|
||||
|
||||
The reason anyone reaches for this is a host key that changed, which is
|
||||
either a rebuilt machine or something worth being alarmed about -- so the
|
||||
page says which before offering the button. ssh-keygen -R rewrites the file
|
||||
and keeps a .old copy itself.
|
||||
"""
|
||||
if not HOST.match(host or ""):
|
||||
raise BoundaryError("That is not a host name.")
|
||||
if not KNOWN_HOSTS.is_file():
|
||||
raise BoundaryError("There is no known_hosts file.")
|
||||
result = run(["ssh-keygen", "-R", host, "-f", str(KNOWN_HOSTS)], timeout=20.0)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip().splitlines()
|
||||
raise BoundaryError(detail[-1] if detail else "That host could not be removed.")
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 2 and arguments[0] == "agent-add":
|
||||
agent_add(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "agent-remove":
|
||||
agent_remove(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "forget-host":
|
||||
forget_host(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | "
|
||||
"forget-host HOST")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"available": False, "directory": str(SSH_DIR),
|
||||
"agent": {"available": False, "socket": "", "fingerprints": [], "detail": ""},
|
||||
"keys": [], "hosts": []}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -204,6 +204,46 @@ for gtk_version in 3.0 4.0; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ── libadwaita accent ────────────────────────────────────────────────────────
|
||||
# Files, Papers, Loupe and every other libadwaita application read their accent
|
||||
# from the Settings portal, not from anything Panama draws, so without this they
|
||||
# render in GNOME blue no matter which accent the desktop is set to.
|
||||
#
|
||||
# GNOME's accent-color is a FIXED enum of nine names -- there is no hex here to
|
||||
# match, only a nearest member to pick -- so this maps our eight to theirs. Kept
|
||||
# in sync by hand with config/Theme.qml's `gnome` field, for the same reason
|
||||
# accent_hex() above is: there is no shared source between QML and shell.
|
||||
#
|
||||
# The portal must route Settings to the gnome backend for this to reach
|
||||
# anything; xdg-desktop-portal-gtk cannot serve accent-color at all. See
|
||||
# config/dot/xdg-desktop-portal/hyprland-portals.conf.
|
||||
gnome_accent() {
|
||||
case "$1" in
|
||||
orchid) printf 'purple' ;;
|
||||
teal) printf 'teal' ;;
|
||||
green) printf 'green' ;;
|
||||
amber) printf 'yellow' ;;
|
||||
orange) printf 'orange' ;;
|
||||
rose) printf 'red' ;;
|
||||
slate) printf 'slate' ;;
|
||||
*) printf 'blue' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
status_adwaita="skipped"
|
||||
if command -v gsettings >/dev/null 2>&1; then
|
||||
adwaita_accent="$(gnome_accent "$accent")"
|
||||
# Writing the same value emits no change signal, so applications already
|
||||
# showing this accent are not asked to restyle for nothing.
|
||||
if [[ "$(gsettings get org.gnome.desktop.interface accent-color 2>/dev/null)" == "'$adwaita_accent'" ]]; then
|
||||
status_adwaita="unchanged"
|
||||
elif gsettings set org.gnome.desktop.interface accent-color "$adwaita_accent" 2>/dev/null; then
|
||||
status_adwaita="$adwaita_accent"
|
||||
else
|
||||
status_adwaita="failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
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"
|
||||
@@ -250,4 +290,5 @@ jq -cn \
|
||||
--arg btop "$status_btop" \
|
||||
--arg tmux "$status_tmux" \
|
||||
--arg hyprlock "$status_hyprlock" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
|
||||
--arg adwaita "$status_adwaita" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita}'
|
||||
|
||||
Executable
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Software updates, from every source this machine actually uses.
|
||||
|
||||
Three of them, and they fail independently, so they are counted and applied
|
||||
separately rather than blended into one number: packages (dnf), applications
|
||||
(flatpak), and firmware (fwupd).
|
||||
|
||||
Checking costs about nine seconds of network and metadata work, which is too
|
||||
long to spend every time a page opens. So `snapshot` is instant -- it reads the
|
||||
last result plus the things that are free to compute -- and `check` is the scan
|
||||
that refreshes it. The page shows when it last checked, the way every mature
|
||||
updater does, instead of pretending the number is live.
|
||||
|
||||
panama-updates snapshot
|
||||
panama-updates check
|
||||
panama-updates apply dnf|flatpak|firmware
|
||||
panama-updates set-auto-flatpak true|false
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# The user timer this ships for keeping applications current. dnf has no
|
||||
# equivalent here because dnf-automatic is not installed, and installing
|
||||
# software is not this script's job.
|
||||
FLATPAK_TIMER = "panama-flatpak-update.timer"
|
||||
|
||||
# Anything carrying an advisory of these severities is reported as a security
|
||||
# fix. "none" is excluded deliberately: an advisory with no severity is a
|
||||
# bugfix or enhancement, and calling it security would cry wolf.
|
||||
SECURITY_SEVERITIES = "critical,important,moderate,low"
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible failure."""
|
||||
|
||||
|
||||
def run(command: list[str], timeout: float = 180.0) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(command, capture_output=True, text=True,
|
||||
timeout=timeout, check=False)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise BoundaryError(f"{command[0]} did not finish in time.") from error
|
||||
except OSError as error:
|
||||
raise BoundaryError(f"{command[0]} is not available.") from error
|
||||
|
||||
|
||||
def cache_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "panama"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base / "updates.json"
|
||||
|
||||
|
||||
def read_cache() -> dict:
|
||||
try:
|
||||
return json.loads(cache_path().read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def write_cache(payload: dict) -> None:
|
||||
# Written atomically: a page reading this while it is half-written would
|
||||
# report zero updates, which is the one wrong answer that looks fine.
|
||||
target = cache_path()
|
||||
temporary = target.with_suffix(".tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||
temporary.replace(target)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def kernel_state() -> dict:
|
||||
"""Whether a reboot would change the kernel you are running.
|
||||
|
||||
This is the honest version of "restart required". Comparing the running
|
||||
release against the newest installed one is exact, needs no plugin, and
|
||||
takes no time -- and a machine that has already rebooted since the update
|
||||
correctly reports nothing pending.
|
||||
"""
|
||||
running = os.uname().release
|
||||
newest = running
|
||||
result = run(["rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"], timeout=30)
|
||||
if result.returncode == 0:
|
||||
installed = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
if installed:
|
||||
# rpm lists oldest first for equal names.
|
||||
newest = installed[-1]
|
||||
return {
|
||||
"running": running,
|
||||
"newestInstalled": newest,
|
||||
"rebootNeeded": newest != running,
|
||||
}
|
||||
|
||||
|
||||
def dnf_updates() -> dict:
|
||||
if not shutil.which("dnf5"):
|
||||
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
|
||||
|
||||
result = run(["dnf5", "check-upgrade", "--json"], timeout=180)
|
||||
packages = []
|
||||
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success.
|
||||
if result.returncode in (0, 100):
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
for entry in payload.get("upgrades", []):
|
||||
packages.append({
|
||||
"name": str(entry.get("name", "")),
|
||||
"version": str(entry.get("evr", "")),
|
||||
"repository": str(entry.get("repository", "")),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
security = 0
|
||||
advisory = run(["dnf5", "check-upgrade",
|
||||
f"--advisory-severities={SECURITY_SEVERITIES}", "--json"], timeout=180)
|
||||
if advisory.returncode in (0, 100):
|
||||
try:
|
||||
security = len(json.loads(advisory.stdout or "{}").get("upgrades", []))
|
||||
except json.JSONDecodeError:
|
||||
security = 0
|
||||
|
||||
packages.sort(key=lambda item: item["name"])
|
||||
return {"available": True, "count": len(packages), "packages": packages,
|
||||
"securityCount": security}
|
||||
|
||||
|
||||
def flatpak_updates() -> dict:
|
||||
if not shutil.which("flatpak"):
|
||||
return {"available": False, "count": 0, "applications": []}
|
||||
result = run(["flatpak", "remote-ls", "--updates", "--columns=application,version"],
|
||||
timeout=120)
|
||||
applications = []
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
parts = [part.strip() for part in line.split("\t")]
|
||||
if parts and parts[0]:
|
||||
applications.append({"id": parts[0],
|
||||
"version": parts[1] if len(parts) > 1 else ""})
|
||||
return {"available": True, "count": len(applications), "applications": applications}
|
||||
|
||||
|
||||
def firmware_updates() -> dict:
|
||||
if not shutil.which("fwupdmgr"):
|
||||
return {"available": False, "count": 0, "devices": []}
|
||||
result = run(["fwupdmgr", "get-updates", "--json"], timeout=120)
|
||||
devices = []
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
for device in payload.get("Devices", []):
|
||||
releases = device.get("Releases", [])
|
||||
devices.append({
|
||||
"name": str(device.get("Name", "Unknown device")),
|
||||
"version": str(device.get("Version", "")),
|
||||
"target": str(releases[0].get("Version", "")) if releases else "",
|
||||
# Firmware that needs a reboot to flash is worth saying up front.
|
||||
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"available": True, "count": len(devices), "devices": devices}
|
||||
|
||||
|
||||
def automatic_state() -> dict:
|
||||
flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_TIMER], timeout=20)
|
||||
dnf_timer = run(["systemctl", "is-enabled", "dnf5-automatic.timer"], timeout=20)
|
||||
return {
|
||||
"flatpakEnabled": flatpak_timer.stdout.strip() == "enabled",
|
||||
"flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"),
|
||||
# Reported, never offered: dnf-automatic is a package this machine does
|
||||
# not have, and installing software is not a settings action.
|
||||
"dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled",
|
||||
"dnfAutomaticAvailable": dnf_timer.stdout.strip() not in ("", "not-found"),
|
||||
}
|
||||
|
||||
|
||||
def check() -> dict:
|
||||
payload = {
|
||||
"dnf": dnf_updates(),
|
||||
"flatpak": flatpak_updates(),
|
||||
"firmware": firmware_updates(),
|
||||
"checkedAt": int(time.time()),
|
||||
}
|
||||
write_cache(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
cached = read_cache()
|
||||
empty = {"available": True, "count": 0}
|
||||
return {
|
||||
"dnf": cached.get("dnf", {**empty, "packages": [], "securityCount": 0}),
|
||||
"flatpak": cached.get("flatpak", {**empty, "applications": []}),
|
||||
"firmware": cached.get("firmware", {**empty, "devices": []}),
|
||||
# 0 means never checked, which the page says rather than showing a
|
||||
# confident "0 updates" it has no basis for.
|
||||
"checkedAt": int(cached.get("checkedAt", 0)),
|
||||
"kernel": kernel_state(),
|
||||
"automatic": automatic_state(),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def take_restore_point(reason: str) -> str:
|
||||
"""A snapshot before the system changes, named after what is about to happen.
|
||||
|
||||
Best effort: if snapper is not configured, the update still proceeds. An
|
||||
update that refuses to run because a nicety failed would be worse than one
|
||||
without a restore point.
|
||||
"""
|
||||
if not shutil.which("snapper"):
|
||||
return ""
|
||||
result = run(["snapper", "-c", "root", "create", "--description", reason,
|
||||
"--cleanup-algorithm", "number", "--print-number"], timeout=120)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def apply(source: str) -> dict:
|
||||
if source == "flatpak":
|
||||
if not shutil.which("flatpak"):
|
||||
raise BoundaryError("Flatpak is not installed.")
|
||||
result = run(["flatpak", "update", "-y", "--noninteractive"], timeout=3600)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The applications could not be updated."))
|
||||
return {"restorePoint": ""}
|
||||
|
||||
if source == "dnf":
|
||||
if not shutil.which("dnf5"):
|
||||
raise BoundaryError("dnf is not installed.")
|
||||
pending = read_cache().get("dnf", {}).get("count", 0)
|
||||
restore_point = take_restore_point(
|
||||
f"before {pending} package update{'' if pending == 1 else 's'}")
|
||||
result = run(["pkexec", "dnf5", "upgrade", "-y"], timeout=7200)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The packages could not be updated."))
|
||||
return {"restorePoint": restore_point}
|
||||
|
||||
if source == "firmware":
|
||||
if not shutil.which("fwupdmgr"):
|
||||
raise BoundaryError("Firmware updating is not available.")
|
||||
result = run(["fwupdmgr", "update", "-y", "--no-reboot-check"], timeout=3600)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "The firmware could not be updated."))
|
||||
return {"restorePoint": ""}
|
||||
|
||||
raise BoundaryError("That is not an update source.")
|
||||
|
||||
|
||||
def set_auto_dnf(enabled: bool) -> None:
|
||||
"""Enable the packaging timer, which DOWNLOADS updates but does not apply them.
|
||||
|
||||
That is the shipped default (apply_updates = no) and it is the right one to
|
||||
leave alone: a machine that installs packages unattended can reboot into a
|
||||
kernel nobody chose. Downloading ahead of time makes the install quick when
|
||||
someone does choose it.
|
||||
"""
|
||||
state = automatic_state()
|
||||
if not state["dnfAutomaticAvailable"]:
|
||||
raise BoundaryError("Automatic package updates are not installed.")
|
||||
action = ["enable", "--now"] if enabled else ["disable", "--now"]
|
||||
result = run(["pkexec", "systemctl", *action, "dnf5-automatic.timer"], timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "Automatic package updates could not be changed."))
|
||||
|
||||
|
||||
def set_auto_flatpak(enabled: bool) -> None:
|
||||
action = ["enable", "--now"] if enabled else ["disable", "--now"]
|
||||
result = run(["systemctl", "--user", *action, FLATPAK_TIMER], timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise BoundaryError(_refusal(result, "Automatic application updates could not be changed."))
|
||||
|
||||
|
||||
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
|
||||
text = ((result.stderr or "") + "\n" + (result.stdout or "")).strip().splitlines()
|
||||
meaningful = [line for line in text if line.strip()]
|
||||
if not meaningful:
|
||||
return fallback
|
||||
last = meaningful[-1]
|
||||
if "not authorized" in last.lower() or "dismissed" in last.lower():
|
||||
return "That update was not authorized."
|
||||
return last[:200]
|
||||
|
||||
|
||||
def flatpak_time(text: str) -> int:
|
||||
"""flatpak prints "Aug 20 08:07:46" -- a time with no year in it.
|
||||
|
||||
Assumed to be this year, and rolled back one if that would put it in the
|
||||
future, which is the only reading that makes sense for a history. Anything
|
||||
unparseable sorts last rather than pretending to be the epoch, which would
|
||||
put it at the top of a newest-first list.
|
||||
"""
|
||||
if not text.strip():
|
||||
return 0
|
||||
now = datetime.datetime.now()
|
||||
for year in (now.year, now.year - 1):
|
||||
try:
|
||||
when = datetime.datetime.strptime(f"{year} {text.strip()}", "%Y %b %d %H:%M:%S")
|
||||
except ValueError:
|
||||
return 0
|
||||
if when <= now + datetime.timedelta(days=1):
|
||||
return int(when.timestamp())
|
||||
return 0
|
||||
|
||||
|
||||
def history(limit: int = 25) -> list[dict]:
|
||||
"""What has actually been installed, newest first.
|
||||
|
||||
Both sources are asked in their own machine-readable form rather than by
|
||||
parsing their tables: dnf5 prints JSON, and flatpak takes --json. The two
|
||||
are merged on time so the answer reads as one history rather than as two
|
||||
lists a person has to interleave themselves.
|
||||
|
||||
Automatic updates are the reason this is worth showing. Something that
|
||||
installs itself overnight leaves no other trace a person would notice.
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
|
||||
dnf = run(["dnf5", "history", "list", "--json"], timeout=30.0)
|
||||
if dnf.returncode == 0:
|
||||
try:
|
||||
for row in json.loads(dnf.stdout or "[]"):
|
||||
command = str(row.get("command_line") or "").strip()
|
||||
entries.append({
|
||||
"source": "dnf",
|
||||
"at": int(row.get("start_time") or 0),
|
||||
# The full argv is noise; what was done is the useful part.
|
||||
"summary": command.split("/")[-1] if command else "transaction",
|
||||
"count": int(row.get("altered_count") or 0),
|
||||
"ok": str(row.get("status") or "") == "Ok",
|
||||
})
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
flatpak = run(["flatpak", "history", "--json"], timeout=30.0)
|
||||
if flatpak.returncode == 0:
|
||||
try:
|
||||
rows = json.loads(flatpak.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
rows = []
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
application = str(row.get("application") or "").strip()
|
||||
change = str(row.get("change") or "").strip()
|
||||
if not application:
|
||||
continue
|
||||
# "deploy update", "deploy install", "uninstall" -- the verb is a
|
||||
# word inside the change rather than the whole of it.
|
||||
verb = next((word for word in ("update", "install", "uninstall")
|
||||
if word in change), "")
|
||||
if not verb:
|
||||
continue
|
||||
entries.append({
|
||||
"source": "flatpak",
|
||||
"at": flatpak_time(str(row.get("time") or "")),
|
||||
"summary": verb + " " + application,
|
||||
"count": 1,
|
||||
"ok": True,
|
||||
})
|
||||
|
||||
entries.sort(key=lambda entry: entry["at"], reverse=True)
|
||||
return entries[:limit]
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["history"]:
|
||||
print(json.dumps({"entries": history(), "error": ""}, separators=(",", ":")))
|
||||
return 0
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if arguments == ["check"]:
|
||||
check()
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "apply":
|
||||
outcome = apply(arguments[1])
|
||||
# Re-check, so the page reflects what is actually left rather than
|
||||
# assuming the update cleared everything it listed.
|
||||
check()
|
||||
state = snapshot()
|
||||
state["applied"] = {"source": arguments[1], **outcome}
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "set-auto-flatpak":
|
||||
set_auto_flatpak(arguments[1] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-auto-dnf":
|
||||
set_auto_dnf(arguments[1] == "true")
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-updates snapshot | check | apply dnf|flatpak|firmware | "
|
||||
"set-auto-flatpak true|false | set-auto-dnf true|false")
|
||||
except BoundaryError as error:
|
||||
state = snapshot()
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""User accounts, through accountsservice -- the same daemon GNOME's Users panel
|
||||
drives.
|
||||
|
||||
Everything here that changes something is authorized by polkit, which prompts
|
||||
through the agent this desktop already runs. This script never asks for a
|
||||
password itself and never holds an administrator credential.
|
||||
|
||||
The one credential it does handle is a NEW password being set. That is read
|
||||
from stdin, hashed by openssl reading its own stdin, and handed to
|
||||
accountsservice over D-Bus from inside this process. It is never an argument:
|
||||
argv is world-readable through /proc, so a password -- or even its hash --
|
||||
passed that way is published to every process on the machine.
|
||||
|
||||
panama-accounts snapshot
|
||||
panama-accounts set-real-name USER NAME
|
||||
panama-accounts set-icon USER PATH [X Y SIZE]
|
||||
panama-accounts set-account-type USER standard|administrator
|
||||
panama-accounts set-automatic-login USER true|false
|
||||
panama-accounts set-password USER (new password on stdin)
|
||||
panama-accounts create-user USERNAME REALNAME standard|administrator
|
||||
panama-accounts delete-user USERNAME [keep-files|remove-files]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
|
||||
ACCOUNTS = "org.freedesktop.Accounts"
|
||||
ACCOUNTS_PATH = "/org/freedesktop/Accounts"
|
||||
USER_INTERFACE = "org.freedesktop.Accounts.User"
|
||||
|
||||
# Account types as accountsservice numbers them.
|
||||
STANDARD, ADMINISTRATOR = 0, 1
|
||||
|
||||
USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or permission failure."""
|
||||
|
||||
|
||||
def bus():
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||
except Exception as error: # noqa: BLE001 - no bus is a legitimate state
|
||||
raise BoundaryError("The account service is not answering.") from error
|
||||
|
||||
|
||||
def call(path: str, interface: str, method: str, parameters=None, reply=None):
|
||||
Gio, GLib, connection = bus()
|
||||
try:
|
||||
result = connection.call_sync(
|
||||
ACCOUNTS, path, interface, method, parameters,
|
||||
GLib.VariantType(reply) if reply else None,
|
||||
Gio.DBusCallFlags.NONE, 120000, None)
|
||||
except Exception as error: # noqa: BLE001
|
||||
message = str(error)
|
||||
# Polkit's own refusal is the common case and deserves plain words
|
||||
# rather than a D-Bus error string.
|
||||
if "not authorized" in message.lower() or "dismissed" in message.lower():
|
||||
raise BoundaryError("That change was not authorized.") from error
|
||||
raise BoundaryError(_clean(message)) from error
|
||||
return result.unpack() if result is not None else None
|
||||
|
||||
|
||||
def _clean(message: str) -> str:
|
||||
"""The last useful sentence of a D-Bus error, without the type prefix."""
|
||||
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip()
|
||||
return trimmed.splitlines()[0][:200] if trimmed else "That change failed."
|
||||
|
||||
|
||||
def properties(path: str) -> dict:
|
||||
Gio, GLib, connection = bus()
|
||||
result = connection.call_sync(
|
||||
ACCOUNTS, path, "org.freedesktop.DBus.Properties", "GetAll",
|
||||
GLib.Variant("(s)", (USER_INTERFACE,)), GLib.VariantType("(a{sv})"),
|
||||
Gio.DBusCallFlags.NONE, 20000, None)
|
||||
return result.unpack()[0]
|
||||
|
||||
|
||||
def user_path(username: str) -> str:
|
||||
if not USERNAME.fullmatch(username or ""):
|
||||
raise BoundaryError("That is not a user name.")
|
||||
from gi.repository import GLib
|
||||
|
||||
return call(ACCOUNTS_PATH, ACCOUNTS, "FindUserByName",
|
||||
GLib.Variant("(s)", (username,)), "(o)")[0]
|
||||
|
||||
|
||||
def describe(path: str) -> dict:
|
||||
values = properties(path)
|
||||
icon = str(values.get("IconFile") or "")
|
||||
return {
|
||||
"path": path,
|
||||
"userName": str(values.get("UserName") or ""),
|
||||
"realName": str(values.get("RealName") or ""),
|
||||
# Reported only when it is actually there: accountsservice keeps the
|
||||
# path in its database whether or not a file exists, so a deleted
|
||||
# avatar otherwise shows as a broken image.
|
||||
"iconFile": icon if icon and os.path.isfile(icon) else "",
|
||||
"administrator": int(values.get("AccountType") or 0) == ADMINISTRATOR,
|
||||
"locked": bool(values.get("Locked")),
|
||||
"automaticLogin": bool(values.get("AutomaticLogin")),
|
||||
"loginTime": int(values.get("LoginTime") or 0),
|
||||
"shell": str(values.get("Shell") or ""),
|
||||
"homeDirectory": str(values.get("HomeDirectory") or ""),
|
||||
"systemAccount": bool(values.get("SystemAccount")),
|
||||
"uid": int(values.get("Uid") or 0),
|
||||
}
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
paths = call(ACCOUNTS_PATH, ACCOUNTS, "ListCachedUsers", None, "(ao)")[0]
|
||||
users = [describe(path) for path in paths]
|
||||
users = [user for user in users if not user["systemAccount"]]
|
||||
users.sort(key=lambda user: user["uid"])
|
||||
me = os.environ.get("USER") or ""
|
||||
return {
|
||||
"users": users,
|
||||
"currentUser": me,
|
||||
# Removing the only administrator would leave a machine nobody can
|
||||
# administer, so the page needs to know rather than find out.
|
||||
"administratorCount": sum(1 for user in users if user["administrator"]),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def set_real_name(username: str, name: str) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
if len(name) > 128 or "\n" in name or ":" in name:
|
||||
raise BoundaryError("That name cannot be used.")
|
||||
call(user_path(username), USER_INTERFACE, "SetRealName",
|
||||
GLib.Variant("(s)", (name,)))
|
||||
|
||||
|
||||
# What an avatar is written out at. accountsservice stores whatever it is
|
||||
# handed, so a 4000px photograph would sit on disk forever to be drawn at 48.
|
||||
AVATAR_SIZE = 512
|
||||
|
||||
|
||||
def crop_square(path: str, x: int, y: int, size: int) -> str:
|
||||
"""Cut a square out of a picture and write it at avatar size.
|
||||
|
||||
Returns a path to a new file; the caller is responsible for removing it.
|
||||
GdkPixbuf rather than a new dependency -- gi is already required here for
|
||||
accountsservice itself.
|
||||
"""
|
||||
import gi
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
try:
|
||||
picture = GdkPixbuf.Pixbuf.new_from_file(path)
|
||||
except Exception as error:
|
||||
raise BoundaryError("That file is not a picture this can read.") from error
|
||||
|
||||
if size <= 0:
|
||||
raise BoundaryError("That is not a region of the picture.")
|
||||
|
||||
# Clamped rather than rejected: a drag that ends a pixel outside the image
|
||||
# is a normal thing to do with a pointer, not an error worth refusing.
|
||||
x = max(0, min(x, picture.get_width() - 1))
|
||||
y = max(0, min(y, picture.get_height() - 1))
|
||||
size = min(size, picture.get_width() - x, picture.get_height() - y)
|
||||
if size <= 0:
|
||||
raise BoundaryError("That region is outside the picture.")
|
||||
|
||||
square = picture.new_subpixbuf(x, y, size, size)
|
||||
scaled = square.scale_simple(AVATAR_SIZE, AVATAR_SIZE, GdkPixbuf.InterpType.BILINEAR)
|
||||
if scaled is None:
|
||||
raise BoundaryError("That picture could not be resized.")
|
||||
|
||||
handle, out = tempfile.mkstemp(prefix="panama-avatar-", suffix=".png")
|
||||
os.close(handle)
|
||||
# accountsservice reads this as root and copies it; it must not be private
|
||||
# to this user, and it is deleted as soon as that copy has happened.
|
||||
os.chmod(out, 0o644)
|
||||
scaled.savev(out, "png", [], [])
|
||||
return out
|
||||
|
||||
|
||||
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
if not os.path.isfile(path):
|
||||
raise BoundaryError("That picture no longer exists.")
|
||||
|
||||
source = path
|
||||
temporary = None
|
||||
if region is not None:
|
||||
temporary = source = crop_square(path, *region)
|
||||
|
||||
try:
|
||||
call(user_path(username), USER_INTERFACE, "SetIconFile",
|
||||
GLib.Variant("(s)", (source,)))
|
||||
finally:
|
||||
# SetIconFile copies the file before it returns, so this is safe here
|
||||
# and leaving it behind would litter /tmp with every picture ever set.
|
||||
if temporary is not None:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def set_account_type(username: str, kind: str) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
if kind not in ("standard", "administrator"):
|
||||
raise BoundaryError("That is not an account type.")
|
||||
call(user_path(username), USER_INTERFACE, "SetAccountType",
|
||||
GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,)))
|
||||
|
||||
|
||||
def set_automatic_login(username: str, enabled: bool) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
call(user_path(username), USER_INTERFACE, "SetAutomaticLogin",
|
||||
GLib.Variant("(b)", (enabled,)))
|
||||
|
||||
|
||||
def set_password(username: str) -> None:
|
||||
"""Set a new password, read from stdin and never named on a command line."""
|
||||
secret = sys.stdin.buffer.read()
|
||||
# A trailing newline from a pipe is not part of the password.
|
||||
if secret.endswith(b"\n"):
|
||||
secret = secret[:-1]
|
||||
if not secret:
|
||||
raise BoundaryError("No password was provided.")
|
||||
if len(secret) < 6:
|
||||
raise BoundaryError("That password is too short.")
|
||||
|
||||
# openssl reads the password on ITS stdin too, so the cleartext never
|
||||
# appears in a process listing at any point in the chain.
|
||||
hashed = subprocess.run(["openssl", "passwd", "-6", "-stdin"],
|
||||
input=secret, capture_output=True, timeout=30, check=False)
|
||||
if hashed.returncode != 0 or not hashed.stdout.strip():
|
||||
raise BoundaryError("The password could not be prepared.")
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
call(user_path(username), USER_INTERFACE, "SetPassword",
|
||||
GLib.Variant("(ss)", (hashed.stdout.decode().strip(), "")))
|
||||
|
||||
|
||||
def create_user(username: str, real_name: str, kind: str) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
if not USERNAME.fullmatch(username or ""):
|
||||
raise BoundaryError("A user name may use lowercase letters, digits, - and _.")
|
||||
if kind not in ("standard", "administrator"):
|
||||
raise BoundaryError("That is not an account type.")
|
||||
call(ACCOUNTS_PATH, ACCOUNTS, "CreateUser",
|
||||
GLib.Variant("(ssi)", (username, real_name,
|
||||
ADMINISTRATOR if kind == "administrator" else STANDARD)),
|
||||
"(o)")
|
||||
|
||||
|
||||
def delete_user(username: str, files: str) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
if files not in ("keep-files", "remove-files"):
|
||||
raise BoundaryError("Say whether to keep or remove the home directory.")
|
||||
if username == (os.environ.get("USER") or ""):
|
||||
raise BoundaryError("You cannot delete the account you are signed in to.")
|
||||
|
||||
state = snapshot()
|
||||
target = next((user for user in state["users"] if user["userName"] == username), None)
|
||||
if target is None:
|
||||
raise BoundaryError("That account no longer exists.")
|
||||
if target["administrator"] and state["administratorCount"] <= 1:
|
||||
raise BoundaryError("That is the only administrator; the machine would have none.")
|
||||
|
||||
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
|
||||
GLib.Variant("(xb)", (target["uid"], files == "remove-files")))
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "set-real-name":
|
||||
set_real_name(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-icon":
|
||||
set_icon(arguments[1], arguments[2])
|
||||
elif len(arguments) == 6 and arguments[0] == "set-icon":
|
||||
try:
|
||||
region = tuple(int(value) for value in arguments[3:6])
|
||||
except ValueError:
|
||||
raise BoundaryError("That is not a region of the picture.")
|
||||
set_icon(arguments[1], arguments[2], region)
|
||||
elif len(arguments) == 3 and arguments[0] == "set-account-type":
|
||||
set_account_type(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
|
||||
set_automatic_login(arguments[1], arguments[2] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-password":
|
||||
set_password(arguments[1])
|
||||
elif len(arguments) == 4 and arguments[0] == "create-user":
|
||||
create_user(arguments[1], arguments[2], arguments[3])
|
||||
elif len(arguments) == 3 and arguments[0] == "delete-user":
|
||||
delete_user(arguments[1], arguments[2])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-accounts snapshot | set-real-name USER NAME | "
|
||||
"set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | "
|
||||
"set-automatic-login USER true|false | set-password USER | "
|
||||
"create-user USERNAME REALNAME standard|administrator | "
|
||||
"delete-user USERNAME keep-files|remove-files")
|
||||
except BoundaryError as error:
|
||||
# Answers with the fresh state plus the message, so a page never has to
|
||||
# ask twice to find out what happened.
|
||||
try:
|
||||
state = snapshot()
|
||||
except BoundaryError:
|
||||
state = {"users": [], "currentUser": "", "administratorCount": 0}
|
||||
state["error"] = str(error)
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -170,13 +170,18 @@ Singleton {
|
||||
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
|
||||
|
||||
// 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. The accent is
|
||||
// passed through too: kitty's border and the hyprlock fallback
|
||||
// template are also the accent role, not just the scheme -- so
|
||||
// this still has to run on an accent-only change, even though the
|
||||
// scheme-relative work it also does is then redundant.
|
||||
// their own palettes -- terminals, chiefly. Portal readers (GTK4,
|
||||
// Qt6, Chromium, Electron) already have their SCHEME from the
|
||||
// gsettings write above.
|
||||
//
|
||||
// Their ACCENT does not come from here: GNOME's accent-color is a
|
||||
// fixed enum of nine names rather than a color, so the mapping from
|
||||
// our eight lives in panama-theme-apps beside the other per-
|
||||
// application translations. The accent is passed through for that,
|
||||
// and because kitty's border and the hyprlock fallback template are
|
||||
// the accent role too -- so this still has to run on an accent-only
|
||||
// change, even though the scheme-relative work it also does is then
|
||||
// redundant.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,40 @@ Singleton {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Whether the wired connection is on, as a person means it: carrying
|
||||
// traffic, and set to come back by itself.
|
||||
readonly property bool wiredOn: root.wiredDevice !== null
|
||||
&& root.wiredDevice.connected
|
||||
|
||||
// Deliberately NOT gated on hasLink. That property reads false while the
|
||||
// device is merely disconnected -- NetworkManager still reports the carrier
|
||||
// as on -- so using it to decide whether the switch works built a trap
|
||||
// door: turning Ethernet off made the switch disable itself, claim "no
|
||||
// cable", and leave no way to turn it back on. A wired device that exists
|
||||
// can always be asked to come up; if there is genuinely no cable, the
|
||||
// attempt fails and says so, which is the honest failure.
|
||||
readonly property bool wiredAvailable: root.wiredDevice !== null
|
||||
|
||||
// Turning wired networking off means both halves. Disconnecting alone lasts
|
||||
// about a second: NetworkManager sees a managed device with autoconnect set
|
||||
// and immediately brings it back, so the switch would flip itself on again
|
||||
// and read as broken.
|
||||
function setWired(enabled: bool): void {
|
||||
const device = root.wiredDevice;
|
||||
if (!device)
|
||||
return;
|
||||
device.autoconnect = enabled;
|
||||
if (enabled) {
|
||||
// With autoconnect restored NetworkManager will usually bring it up
|
||||
// on its own; asking directly makes it immediate rather than
|
||||
// whenever the daemon next looks.
|
||||
if (device.network)
|
||||
device.network.connect();
|
||||
} else {
|
||||
device.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
|
||||
readonly property bool wifiEnabled: Networking.wifiEnabled
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
pragma Singleton
|
||||
|
||||
// Rootless podman-compose stacks: what they are, what they expose, what they cost.
|
||||
//
|
||||
// Grouping is read from the compose labels rather than invented, and acting on a
|
||||
// group is done with plain podman over the labelled set -- never
|
||||
// `podman-compose down`, which would remove containers this shell did not
|
||||
// create. The compose file is the source of truth for what exists, and it
|
||||
// belongs to the repository.
|
||||
//
|
||||
// Rootless throughout, so nothing here prompts for a password.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-containers"
|
||||
|
||||
// How many log lines are kept in memory. A busy container can emit
|
||||
// thousands a minute, and the panel is for reading the recent past, not for
|
||||
// archiving it.
|
||||
readonly property int logLimit: 2000
|
||||
|
||||
property bool available: false
|
||||
property var projects: []
|
||||
property var loose: []
|
||||
property int running: 0
|
||||
property int total: 0
|
||||
property var exposed: []
|
||||
property var disk: ({})
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
// Guards read these Processes directly. A derived `busy` binding returns its
|
||||
// cached value inside the handler that changes its dependency, which is how
|
||||
// a write can be dropped without any error at all.
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
// Published on every interface AND currently running: reachable now, as
|
||||
// opposed to a stopped container that merely would be.
|
||||
readonly property var reachable: root.exposed.filter(entry => entry.running === true)
|
||||
readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true)
|
||||
|
||||
readonly property var unusedImages: root.disk.unusedImages ?? []
|
||||
readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
|
||||
readonly property int reclaimable:
|
||||
Number(root.disk.imagesReclaimable ?? 0) + Number(root.disk.volumesReclaimable ?? 0)
|
||||
|
||||
// The container whose logs are on screen, and whether podman is still
|
||||
// feeding them. Empty when the log view is closed.
|
||||
property string logTarget: ""
|
||||
readonly property bool logFollowing: logs.running
|
||||
property string logError: ""
|
||||
|
||||
readonly property alias logLines: logModel
|
||||
|
||||
// Decimal units, to match what podman itself prints.
|
||||
function formatBytes(bytes: real): string {
|
||||
if (!(bytes > 0))
|
||||
return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1000 && index < units.length - 1) {
|
||||
value /= 1000;
|
||||
index += 1;
|
||||
}
|
||||
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
|
||||
}
|
||||
|
||||
// Paths are shown relative to home: the interesting part of a compose path
|
||||
// is where it sits in the repository, not the eight characters before it.
|
||||
function shorten(path: string): string {
|
||||
const home = Quickshell.env("HOME") ?? "";
|
||||
return home !== "" && path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
|
||||
}
|
||||
|
||||
// Hand a compose file to whatever opens text files -- which on this machine
|
||||
// is Neovim in kitty.
|
||||
function openFile(path: string): void {
|
||||
if (path === "")
|
||||
return;
|
||||
opener.command = ["xdg-open", path];
|
||||
opener.running = true;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (query.running)
|
||||
return;
|
||||
query.command = [root.helperPath, "snapshot"];
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.available = parsed.available === true;
|
||||
root.projects = Array.isArray(parsed.projects) ? parsed.projects : [];
|
||||
root.loose = Array.isArray(parsed.loose) ? parsed.loose : [];
|
||||
root.running = Number(parsed.running ?? 0);
|
||||
root.total = Number(parsed.total ?? 0);
|
||||
root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
|
||||
root.disk = parsed.disk ?? ({});
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the state of the containers.";
|
||||
console.warn("Containers: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
function run(arguments: var): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
mutation.command = [root.helperPath].concat(arguments);
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function start(name: string): void { root.run(["start", name]); }
|
||||
function stop(name: string): void { root.run(["stop", name]); }
|
||||
function restart(name: string): void { root.run(["restart", name]); }
|
||||
|
||||
function startProject(name: string): void { root.run(["project-start", name]); }
|
||||
function stopProject(name: string): void { root.run(["project-stop", name]); }
|
||||
function restartProject(name: string): void { root.run(["project-restart", name]); }
|
||||
|
||||
function pruneImages(): void { root.run(["prune-images"]); }
|
||||
function pruneVolumes(): void { root.run(["prune-volumes"]); }
|
||||
|
||||
// Bind a service's published ports to loopback by editing its compose file
|
||||
// in place. The helper adds an address and leaves everything else alone, or
|
||||
// refuses; it never rewrites a port it cannot read unambiguously.
|
||||
function bindLocal(project: string, service: string): void {
|
||||
root.run(["bind-local", project, service]);
|
||||
}
|
||||
|
||||
// ── logs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openLogs(name: string): void {
|
||||
root.closeLogs();
|
||||
root.logTarget = name;
|
||||
root.logError = "";
|
||||
// --tail bounds the initial burst: a container running for weeks would
|
||||
// otherwise deliver its entire history before the first line appears.
|
||||
logs.command = ["podman", "logs", "--tail", "400", "--timestamps", "--follow", name];
|
||||
logs.running = true;
|
||||
}
|
||||
|
||||
function closeLogs(): void {
|
||||
if (logs.running)
|
||||
logs.running = false;
|
||||
logModel.clear();
|
||||
root.logTarget = "";
|
||||
root.logError = "";
|
||||
}
|
||||
|
||||
function appendLog(line: string): void {
|
||||
if (root.logTarget === "")
|
||||
return;
|
||||
logModel.append({ line });
|
||||
if (logModel.count > root.logLimit)
|
||||
logModel.remove(0, logModel.count - root.logLimit);
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
ListModel { id: logModel }
|
||||
|
||||
Process {
|
||||
id: query
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: opener }
|
||||
|
||||
Process {
|
||||
id: logs
|
||||
// podman writes container output to both streams; a log view that showed
|
||||
// only one would silently drop half of what the container said.
|
||||
stdout: SplitParser { onRead: line => root.appendLog(line) }
|
||||
stderr: SplitParser { onRead: line => root.appendLog(line) }
|
||||
onExited: (code, status) => {
|
||||
if (root.logTarget !== "" && code !== 0)
|
||||
root.logError = "The log stream ended unexpectedly.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,28 @@ Singleton {
|
||||
property var luaAutostartEntries: []
|
||||
property string lastError: ""
|
||||
|
||||
// For the UI, which wants one answer to "is anything happening".
|
||||
//
|
||||
// Guards inside this file do NOT use it. `busy` is a binding, and a binding
|
||||
// hands back its cached value until the change notification that feeds it
|
||||
// has been delivered. Inside a process's own onExited handler that has not
|
||||
// happened yet, so `busy` still reads true there -- which silently turned
|
||||
// the refresh after every successful write into a no-op. The write landed
|
||||
// and the settings page never noticed, which looks exactly like a settings
|
||||
// page that cannot change anything. Guards below read the Process objects
|
||||
// directly, where the value is current.
|
||||
readonly property bool busy: snapshotProcess.running || mutationProcess.running
|
||||
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
|
||||
readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"]
|
||||
// Derived from the snapshot, never restated. This was a hardcoded list of
|
||||
// seven, and when the helper and the page grew documents, text and archives
|
||||
// it stayed at seven -- so choosing a PDF viewer set lastError and did
|
||||
// nothing, which reads exactly like a settings page that does not work.
|
||||
//
|
||||
// The snapshot already reports one handler per role the helper supports, so
|
||||
// that IS the list. An empty one means no snapshot has landed yet; the
|
||||
// helper validates the role itself and reports a failure, so there is
|
||||
// nothing for this guard to add before then.
|
||||
readonly property var supportedRoles: Object.keys(root.handlers ?? ({}))
|
||||
|
||||
Process {
|
||||
id: snapshotProcess
|
||||
@@ -59,7 +78,9 @@ Singleton {
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (root.busy)
|
||||
// Only a snapshot already in flight is a reason not to start another.
|
||||
// A mutation finishing is the single best reason TO refresh.
|
||||
if (snapshotProcess.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
snapshotProcess.exec([root.helper, "snapshot"]);
|
||||
@@ -76,9 +97,11 @@ Singleton {
|
||||
}
|
||||
|
||||
function setDefault(role: string, desktopId: string): void {
|
||||
if (root.busy)
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) {
|
||||
const roleIsKnown = root.supportedRoles.length === 0
|
||||
|| root.supportedRoles.includes(role);
|
||||
if (!roleIsKnown || !root.knownDesktopId(desktopId)) {
|
||||
root.lastError = "Choose an application from the available list."
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +110,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function setAutostart(desktopId: string, enabled: bool): void {
|
||||
if (root.busy)
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
const known = root.autostartEntries.some(entry => entry.id === desktopId);
|
||||
if (!known) {
|
||||
@@ -98,8 +121,22 @@ Singleton {
|
||||
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
||||
}
|
||||
|
||||
// Deletes the entry rather than hiding it. Disabling writes Hidden=true and
|
||||
// is reversible; this is not, so the page confirms before calling it.
|
||||
function removeAutostart(desktopId: string): void {
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
const known = root.autostartEntries.some(entry => entry.id === desktopId);
|
||||
if (!known) {
|
||||
root.lastError = "That user autostart entry is no longer available."
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
mutationProcess.exec([root.helper, "remove-autostart", desktopId]);
|
||||
}
|
||||
|
||||
function addAutostart(desktopId: string): void {
|
||||
if (root.busy)
|
||||
if (mutationProcess.running)
|
||||
return;
|
||||
if (!root.knownDesktopId(desktopId)) {
|
||||
root.lastError = "Choose an installed application.";
|
||||
|
||||
@@ -208,7 +208,12 @@ Singleton {
|
||||
DesktopPreferences.get("middleClickPaste")),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||
DesktopPreferences.get("titlebarDoubleClick"))
|
||||
DesktopPreferences.get("titlebarDoubleClick")),
|
||||
// The portal republishes this as org.freedesktop.appearance
|
||||
// contrast, which is what libadwaita reads -- so this reaches GTK4
|
||||
// applications without any high-contrast theme being installed.
|
||||
root.setting("org.gnome.desktop.a11y.interface", "high-contrast",
|
||||
DesktopPreferences.get("highContrast"))
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
pragma Singleton
|
||||
|
||||
// Storage: what is in this machine, how full it is, and what is filling it.
|
||||
//
|
||||
// Two reads with very different costs, kept apart on purpose:
|
||||
//
|
||||
// refresh() layout, usage, and drive health. Around 90ms -- lsblk plus one
|
||||
// udisks call -- so the page opens with it and re-reads freely.
|
||||
// scan() what is using the space. Measuring a folder means walking it,
|
||||
// and a Steam library alone can be a terabyte, so this happens
|
||||
// only when asked and the answer is kept until asked again.
|
||||
//
|
||||
// Not a stored preference: every value here is the machine's, not the user's.
|
||||
//
|
||||
// Deliberately absent: partitioning and formatting. Those stay in GNOME Disks,
|
||||
// which the page can launch.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-disks"
|
||||
|
||||
property var drives: []
|
||||
property var filesystems: []
|
||||
property var swap: []
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
// The expensive half.
|
||||
property var folders: []
|
||||
property var containers: null
|
||||
property bool scanning: false
|
||||
property bool folderScanTruncated: false
|
||||
// Empty until a scan has completed, which is what the page shows a prompt
|
||||
// for rather than an empty list -- "nothing here" and "not measured yet"
|
||||
// are different answers.
|
||||
property bool foldersMeasured: false
|
||||
|
||||
readonly property var primaryDrive: root.drives.length > 0 ? root.drives[0] : null
|
||||
|
||||
// The filesystem the user means when they ask how full the machine is.
|
||||
readonly property var rootFilesystem: {
|
||||
for (const filesystem of root.filesystems) {
|
||||
if ((filesystem.mountpoints ?? []).includes("/"))
|
||||
return filesystem;
|
||||
}
|
||||
return root.filesystems.length > 0 ? root.filesystems[0] : null;
|
||||
}
|
||||
|
||||
// Bytes, in the units people actually read. Binary units with decimal-style
|
||||
// names would be a lie in the other direction; this matches what df -h and
|
||||
// the drive's own packaging say.
|
||||
function formatBytes(bytes: real): string {
|
||||
if (!(bytes > 0))
|
||||
return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1000 && index < units.length - 1) {
|
||||
value /= 1000;
|
||||
index += 1;
|
||||
}
|
||||
const decimals = value < 10 && index > 1 ? 1 : 0;
|
||||
return value.toFixed(decimals) + " " + units[index];
|
||||
}
|
||||
|
||||
function usedFraction(filesystem: var): real {
|
||||
const size = Number(filesystem?.sizeBytes ?? 0);
|
||||
if (!(size > 0))
|
||||
return 0;
|
||||
return Math.max(0, Math.min(1, Number(filesystem.usedBytes ?? 0) / size));
|
||||
}
|
||||
|
||||
// "/ and /home" rather than two rows: btrfs subvolumes share one pool of
|
||||
// free space, and showing them separately doubles it on screen.
|
||||
function mountLabel(filesystem: var): string {
|
||||
const points = filesystem?.mountpoints ?? [];
|
||||
if (points.length === 0)
|
||||
return String(filesystem?.device ?? "");
|
||||
if (points.length === 1)
|
||||
return points[0];
|
||||
return points.slice(0, -1).join(", ") + " and " + points[points.length - 1];
|
||||
}
|
||||
|
||||
function healthSummary(drive: var): string {
|
||||
if (!drive)
|
||||
return "";
|
||||
if (drive.healthy === false)
|
||||
return (drive.warnings ?? []).length > 0
|
||||
? "Reporting " + drive.warnings.join(", ")
|
||||
: "Reporting a failure";
|
||||
if (drive.healthy === true)
|
||||
return "No warnings";
|
||||
return "Health not reported";
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!query.running)
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function scan(): void {
|
||||
if (root.scanning)
|
||||
return;
|
||||
root.scanning = true;
|
||||
folderScan.running = true;
|
||||
}
|
||||
|
||||
function unmount(devicePath: string): void {
|
||||
root.runMedia(["unmount", devicePath]);
|
||||
}
|
||||
|
||||
function eject(devicePath: string): void {
|
||||
root.runMedia(["eject", devicePath]);
|
||||
}
|
||||
|
||||
function runMedia(arguments: var): void {
|
||||
if (media.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
media.command = [root.helperPath].concat(arguments);
|
||||
media.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: query
|
||||
command: [root.helperPath, "snapshot"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.drives = Array.isArray(parsed.drives) ? parsed.drives : [];
|
||||
root.filesystems = Array.isArray(parsed.filesystems) ? parsed.filesystems : [];
|
||||
root.swap = Array.isArray(parsed.swap) ? parsed.swap : [];
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.drives = [];
|
||||
root.filesystems = [];
|
||||
root.lastError = "Could not read the storage helper's output.";
|
||||
console.warn("Disks: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: folderScan
|
||||
command: [root.helperPath, "scan"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.folders = Array.isArray(parsed.folders) ? parsed.folders : [];
|
||||
root.containers = parsed.containers ?? null;
|
||||
root.folderScanTruncated = parsed.truncated === true;
|
||||
root.foldersMeasured = true;
|
||||
} catch (error) {
|
||||
root.lastError = "Could not measure what is using the drive.";
|
||||
console.warn("Disks: could not parse scan output:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: root.scanning = false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: media
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
// Re-read rather than assuming: a device may refuse to unmount because
|
||||
// something still has a file open on it.
|
||||
onExited: root.refresh()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
pragma Singleton
|
||||
|
||||
// The firewall, answered as "what can another machine reach?"
|
||||
//
|
||||
// Listing zones and services is what firewall-cmd already does. The question it
|
||||
// does not answer needs both halves at once: a port is reachable only when
|
||||
// something is listening on a network address AND the firewall permits it.
|
||||
// Either alone tells you nothing, which is how a tidy set of rules coexists
|
||||
// with an exposed database.
|
||||
//
|
||||
// Changes go through firewall-cmd, which is polkit-aware, so they prompt --
|
||||
// through Panama's own prompt now.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-firewall"
|
||||
|
||||
property bool running: false
|
||||
property bool enabledAtBoot: false
|
||||
property bool available: false
|
||||
property string defaultZone: ""
|
||||
property var allZones: []
|
||||
property var activeZones: ({})
|
||||
property var zones: []
|
||||
property var exposed: []
|
||||
property var exposedDataStores: []
|
||||
property int sshSessions: 0
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
readonly property var zone: root.zones.length > 0 ? root.zones[0] : null
|
||||
|
||||
// The range Fedora Workstation opens by default, if this zone has it. Named
|
||||
// separately because it is the single rule that explains almost everything
|
||||
// on the exposed list.
|
||||
readonly property var openRanges: (root.zone?.ports ?? []).filter(
|
||||
spec => String(spec).indexOf("-") > 0)
|
||||
|
||||
readonly property bool wideOpen: root.openRanges.length > 0
|
||||
|
||||
function serviceCount(): int { return (root.zone?.services ?? []).length; }
|
||||
|
||||
function allowedByRange(entry: var): bool {
|
||||
return String(entry?.allowedBy ?? "").indexOf("range") >= 0;
|
||||
}
|
||||
|
||||
// What closing the open range would cut off, by name, so the consequence is
|
||||
// stated before it happens rather than discovered afterwards.
|
||||
function rangeDependents(): var {
|
||||
return root.exposed.filter(entry => root.allowedByRange(entry));
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (query.running)
|
||||
return;
|
||||
query.command = [root.helperPath, "snapshot"];
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.running = parsed.running === true;
|
||||
root.enabledAtBoot = parsed.enabledAtBoot === true;
|
||||
root.available = parsed.available === true;
|
||||
root.defaultZone = String(parsed.defaultZone ?? "");
|
||||
root.allZones = Array.isArray(parsed.allZones) ? parsed.allZones : [];
|
||||
root.activeZones = parsed.activeZones ?? ({});
|
||||
root.zones = Array.isArray(parsed.zones) ? parsed.zones : [];
|
||||
root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
|
||||
root.exposedDataStores = Array.isArray(parsed.exposedDataStores)
|
||||
? parsed.exposedDataStores : [];
|
||||
root.sshSessions = Number(parsed.sshSessions ?? 0);
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the firewall's state.";
|
||||
console.warn("Firewall: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
function run(arguments: var): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
mutation.command = [root.helperPath].concat(arguments);
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function removeService(name: string): void { root.run(["remove-service", name]); }
|
||||
function addService(name: string): void { root.run(["add-service", name]); }
|
||||
function removePort(spec: string): void { root.run(["remove-port", spec]); }
|
||||
function addPort(spec: string): void { root.run(["add-port", spec]); }
|
||||
function setZone(interfaceName: string, zoneName: string): void {
|
||||
root.run(["set-zone", interfaceName, zoneName]);
|
||||
}
|
||||
function setDefaultZone(zoneName: string): void { root.run(["set-default-zone", zoneName]); }
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
Process {
|
||||
id: query
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
pragma Singleton
|
||||
|
||||
// Named focus modes, turned on by conditions rather than by alarms.
|
||||
//
|
||||
// A mode is active because something is true right now -- a game is running, a
|
||||
// window is fullscreen on a given display, a workspace is focused, the clock is
|
||||
// inside a window. That is re-evaluated continuously rather than fired once,
|
||||
// which is the whole reason schedules here do not have the usual failure modes:
|
||||
// there is no alarm to have missed. A machine asleep at 23:30, rebooted at
|
||||
// 02:00, or opened at 08:00 into a window that has already passed all arrive at
|
||||
// the correct answer by simply asking again.
|
||||
//
|
||||
// This deliberately does not own the manual timed session. FocusSession already
|
||||
// does that, with its own capsule, shortcut, Quick Settings entry and contracts,
|
||||
// and two things writing Do Not Disturb would fight. Modes whose only trigger is
|
||||
// "manual" are started through FocusSession; everything automatic lives here,
|
||||
// and defers entirely while a manual session is running.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var modes: {
|
||||
const stored = DesktopPreferences.get("focusModes");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
// Modes something could switch on without being asked.
|
||||
readonly property var automatic: root.modes.filter(mode =>
|
||||
mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))
|
||||
|
||||
// Re-read on a timer only because one trigger kind depends on the clock.
|
||||
// Everything else is driven by change signals; this is what makes a schedule
|
||||
// a condition rather than an alarm.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
// Set by the gamemode start/end hooks over IPC. The hook is the only thing
|
||||
// that reliably knows, and it is already talking to the shell.
|
||||
property bool gameRunning: false
|
||||
|
||||
// The mode in force, or null. First match wins, so the order in the list is
|
||||
// the priority: a person who wants Gaming to beat Sleep moves it up.
|
||||
readonly property var activeMode: {
|
||||
// A manual session owns Do Not Disturb while it runs. Evaluating on top
|
||||
// of it would mean two owners for one piece of state and a restore that
|
||||
// puts back whatever the loser happened to see.
|
||||
if (FocusSession.active)
|
||||
return null;
|
||||
for (const mode of root.automatic) {
|
||||
if (root.triggered(mode))
|
||||
return mode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly property bool active: root.activeMode !== null
|
||||
|
||||
// Applications the mode in force lets through anyway, by the same id the
|
||||
// per-application rules use. Empty whenever no mode is active, which is
|
||||
// what keeps a Do Not Disturb somebody set by hand absolute: an exception
|
||||
// belongs to a mode, so without a mode there are no exceptions.
|
||||
readonly property var allowedApps: {
|
||||
const mode = root.activeMode;
|
||||
if (!mode || mode.silence !== true)
|
||||
return [];
|
||||
return Array.isArray(mode.allow) ? mode.allow.map(String) : [];
|
||||
}
|
||||
|
||||
function allows(appId: string): bool {
|
||||
return root.allowedApps.indexOf(String(appId)) >= 0;
|
||||
}
|
||||
readonly property string activeName: String(root.activeMode?.name ?? "")
|
||||
|
||||
// Why it is on, in the words the page uses, so "something silenced my
|
||||
// notifications" is always answerable.
|
||||
readonly property string activeReason: {
|
||||
if (!root.activeMode)
|
||||
return "";
|
||||
for (const trigger of (root.activeMode.triggers ?? [])) {
|
||||
if (root.triggerActive(trigger))
|
||||
return root.describeTrigger(trigger);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function triggered(mode: var): bool {
|
||||
return (mode.triggers ?? []).some(trigger => root.triggerActive(trigger));
|
||||
}
|
||||
|
||||
function triggerActive(trigger: var): bool {
|
||||
switch (String(trigger?.kind ?? "")) {
|
||||
case "game":
|
||||
// Told to us by the gamemode hook rather than read from the Gaming
|
||||
// service, which only polls while its settings page is open and
|
||||
// would therefore be stale exactly when a game starts.
|
||||
return root.gameRunning;
|
||||
case "schedule":
|
||||
return root.withinWindow(trigger, new Date(root.nowMs));
|
||||
case "workspace":
|
||||
return Hyprland.focusedWorkspace?.id === Number(trigger.id ?? -1);
|
||||
case "fullscreen": {
|
||||
// Hyprland reports fullscreen on the workspace, not the window, and
|
||||
// Quickshell has no typed property for it -- so this reads the raw
|
||||
// IPC object, which does carry it and has a change notification, so
|
||||
// the binding still updates. Checked rather than assumed: the typed
|
||||
// property does not exist in this version.
|
||||
const workspace = Hyprland.focusedWorkspace;
|
||||
const fullscreen = workspace?.lastIpcObject?.hasfullscreen === true;
|
||||
if (!fullscreen)
|
||||
return false;
|
||||
const monitor = String(trigger.monitor ?? "");
|
||||
return monitor === "" || Hyprland.focusedMonitor?.name === monitor;
|
||||
}
|
||||
default:
|
||||
// "manual" included: FocusSession owns those.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Minutes since midnight, or -1 for anything unparseable. A malformed
|
||||
// schedule must never read as "on" -- silencing someone because a string
|
||||
// was wrong is the worst way for this to fail.
|
||||
function minutesOf(text: string): int {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? ""));
|
||||
if (!match)
|
||||
return -1;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59)
|
||||
return -1;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
// Is `when` inside this schedule?
|
||||
//
|
||||
// Windows that cross midnight are the case worth being careful about. A
|
||||
// window belongs to the day it STARTS on, so 23:30 Friday to 07:00 Saturday
|
||||
// is Friday's window, and Saturday morning is inside it only because Friday
|
||||
// is enabled. Treating it as Saturday's would silence a Saturday morning
|
||||
// nobody asked to be quiet.
|
||||
function withinWindow(trigger: var, when: var): bool {
|
||||
const start = root.minutesOf(trigger.start);
|
||||
const end = root.minutesOf(trigger.end);
|
||||
if (start < 0 || end < 0 || start === end)
|
||||
return false;
|
||||
|
||||
const days = Array.isArray(trigger.days) ? trigger.days.map(Number) : [];
|
||||
if (days.length === 0)
|
||||
return false;
|
||||
|
||||
const day = when.getDay();
|
||||
const minutes = when.getHours() * 60 + when.getMinutes();
|
||||
|
||||
if (start < end)
|
||||
return days.indexOf(day) >= 0 && minutes >= start && minutes < end;
|
||||
|
||||
// Crosses midnight: either late on an enabled day, or early on the day
|
||||
// after an enabled one.
|
||||
const yesterday = (day + 6) % 7;
|
||||
return (days.indexOf(day) >= 0 && minutes >= start)
|
||||
|| (days.indexOf(yesterday) >= 0 && minutes < end);
|
||||
}
|
||||
|
||||
function describeTrigger(trigger: var): string {
|
||||
switch (String(trigger?.kind ?? "")) {
|
||||
case "game":
|
||||
return "a game is running";
|
||||
case "schedule":
|
||||
return "scheduled " + String(trigger.start ?? "") + " to " + String(trigger.end ?? "");
|
||||
case "workspace":
|
||||
return "workspace " + String(trigger.id ?? "");
|
||||
case "fullscreen":
|
||||
return String(trigger.monitor ?? "") === ""
|
||||
? "a window is fullscreen"
|
||||
: "a window is fullscreen on " + String(trigger.monitor);
|
||||
case "manual":
|
||||
return "started by hand";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function summary(mode: var): string {
|
||||
const triggers = (mode.triggers ?? []).map(trigger => root.describeTrigger(trigger))
|
||||
.filter(text => text !== "");
|
||||
const effects = [];
|
||||
if (mode.silence === true)
|
||||
effects.push("silences notifications");
|
||||
if (mode.keepAwake === true)
|
||||
effects.push("keeps the screen awake");
|
||||
const allow = Array.isArray(mode.allow) ? mode.allow : [];
|
||||
if (allow.length > 0)
|
||||
effects.push(allow.length + (allow.length === 1 ? " app may interrupt" : " apps may interrupt"));
|
||||
const when = triggers.length > 0 ? "When " + triggers.join(" or ") : "Never turns on";
|
||||
return effects.length > 0 ? when + " · " + effects.join(", ") : when;
|
||||
}
|
||||
|
||||
function save(next: var): void {
|
||||
DesktopPreferences.set("focusModes", next);
|
||||
}
|
||||
|
||||
function setEnabled(id: string, enabled: bool): void {
|
||||
root.save(root.modes.map(mode =>
|
||||
mode.id === id ? Object.assign({}, mode, { enabled: enabled }) : mode));
|
||||
}
|
||||
|
||||
function update(id: string, changes: var): void {
|
||||
root.save(root.modes.map(mode =>
|
||||
mode.id === id ? Object.assign({}, mode, changes) : mode));
|
||||
}
|
||||
|
||||
// ── applying ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// What was true before a mode took over, so it can be put back. Recorded at
|
||||
// the moment of taking over rather than read at release, which would return
|
||||
// whatever the mode itself had set.
|
||||
|
||||
property bool holding: false
|
||||
property bool previousDnd: false
|
||||
property bool previousCaffeine: false
|
||||
|
||||
onActiveModeChanged: {
|
||||
const mode = root.activeMode;
|
||||
if (mode && !root.holding) {
|
||||
root.previousDnd = Notifs.doNotDisturb;
|
||||
root.previousCaffeine = Caffeine.enabled;
|
||||
root.holding = true;
|
||||
}
|
||||
if (mode) {
|
||||
if (mode.silence === true)
|
||||
Notifs.doNotDisturb = true;
|
||||
if (mode.keepAwake === true)
|
||||
Caffeine.enabled = true;
|
||||
return;
|
||||
}
|
||||
if (root.holding) {
|
||||
Notifs.doNotDisturb = root.previousDnd;
|
||||
Caffeine.enabled = root.previousCaffeine;
|
||||
root.holding = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only runs while a schedule could change the answer. Thirty seconds is
|
||||
// finer than any window boundary anyone sets and costs nothing.
|
||||
Timer {
|
||||
running: root.automatic.some(mode =>
|
||||
(mode.triggers ?? []).some(trigger => trigger.kind === "schedule"))
|
||||
interval: 30000
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
pragma Singleton
|
||||
|
||||
// Gaming: what the machine is doing, and what it should do while you play.
|
||||
//
|
||||
// The reporting half is cheap -- GPU sensors come from sysfs, gamemode from its
|
||||
// own daemon -- so this can poll while its page is open. It only polls then:
|
||||
// a settings page nobody is looking at has no business waking the CPU twice a
|
||||
// second.
|
||||
//
|
||||
// The acting half is not in this file at all. gamemode runs a script when a
|
||||
// game starts and another when it exits, and that script reads the preferences
|
||||
// directly, because the shell may have been restarted since the game launched.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gaming"
|
||||
|
||||
property var gameMode: ({})
|
||||
property var gpus: []
|
||||
property var overlay: ({})
|
||||
property var library: ({})
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
// Set by the page while it is visible. Nothing polls otherwise.
|
||||
property bool watching: false
|
||||
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
readonly property bool active: root.gameMode?.active === true
|
||||
readonly property var primaryGpu: {
|
||||
for (const gpu of root.gpus) {
|
||||
if (gpu.discrete)
|
||||
return gpu;
|
||||
}
|
||||
return root.gpus.length > 0 ? root.gpus[0] : null;
|
||||
}
|
||||
|
||||
// The honest version: gamemode's headline trick is switching the governor,
|
||||
// and it does nothing if the machine already runs that governor.
|
||||
readonly property bool governorAlreadyThere:
|
||||
String(root.gameMode?.governorNow ?? "") !== ""
|
||||
&& root.gameMode?.governorNow === root.gameMode?.governorWhileGaming
|
||||
|
||||
function formatBytes(bytes: real): string {
|
||||
if (!(bytes > 0))
|
||||
return "0 GB";
|
||||
return (bytes / 1073741824).toFixed(bytes < 10737418240 ? 1 : 0) + " GB";
|
||||
}
|
||||
|
||||
function gpuSummary(gpu: var): string {
|
||||
if (!gpu)
|
||||
return "";
|
||||
const parts = [];
|
||||
if (gpu.temperatureC !== null && gpu.temperatureC !== undefined)
|
||||
parts.push(gpu.temperatureC + " °C");
|
||||
if (gpu.watts !== null && gpu.watts !== undefined && gpu.watts > 0)
|
||||
parts.push(gpu.watts + " W");
|
||||
if (Number(gpu.vramTotalBytes ?? 0) > 0)
|
||||
parts.push(root.formatBytes(gpu.vramUsedBytes) + " / "
|
||||
+ root.formatBytes(gpu.vramTotalBytes));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (query.running)
|
||||
return;
|
||||
query.command = [root.helperPath, "snapshot"];
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.gameMode = parsed.gameMode ?? ({});
|
||||
root.gpus = Array.isArray(parsed.gpus) ? parsed.gpus : [];
|
||||
root.overlay = parsed.overlay ?? ({});
|
||||
root.library = parsed.library ?? ({});
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the gaming helper's answer.";
|
||||
console.warn("Gaming: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
function run(arguments: var): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
mutation.command = [root.helperPath].concat(arguments);
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function setOverlay(enabled: bool): void { root.run(["set-overlay", enabled ? "true" : "false"]); }
|
||||
function setOverlayPreset(preset: string): void { root.run(["set-overlay-preset", preset]); }
|
||||
function installHooks(): void { root.run(["install-hooks"]); }
|
||||
function removeHooks(): void { root.run(["remove-hooks"]); }
|
||||
|
||||
Process {
|
||||
id: query
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Three seconds: fast enough that a temperature reading feels live, slow
|
||||
// enough that it is not a background task of its own.
|
||||
Timer {
|
||||
running: root.watching
|
||||
interval: 3000
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
}
|
||||
@@ -277,30 +277,75 @@ Singleton {
|
||||
|
||||
// Grouping is by what the shortcut does, taken from its own description,
|
||||
// so adding a bind puts it in the right section without touching this file.
|
||||
// Which section a bind belongs to.
|
||||
//
|
||||
// "Windows" used to catch focus, movement, splitting, resizing and window
|
||||
// state alike, which put 43 of the 93 binds under one heading -- a section
|
||||
// that long is a list, not a grouping. The window verbs are separated here
|
||||
// by what you are actually trying to do.
|
||||
//
|
||||
// Order matters: "Next window splits down" is about splitting rather than
|
||||
// focus, and "Focus session" is a Panama feature rather than window focus,
|
||||
// so both are settled before the general checks below them.
|
||||
function groupFor(description: string, bind: var): string {
|
||||
const text = description.toLowerCase();
|
||||
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
||||
return "Media & hardware keys";
|
||||
|
||||
// Quiet mode and Caffeine bound to a workspace, not window focus.
|
||||
if (text.indexOf("focus session") >= 0)
|
||||
return "Applications & shell";
|
||||
|
||||
if (text.indexOf("workspace") >= 0)
|
||||
return "Workspaces";
|
||||
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|
||||
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|
||||
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
||||
|
||||
if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
||||
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
||||
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|
||||
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|
||||
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
|
||||
return "Windows";
|
||||
|| text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0
|
||||
|| text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0)
|
||||
return "Size";
|
||||
|
||||
if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0
|
||||
|| text.indexOf("move window") >= 0)
|
||||
return "Move & split";
|
||||
|
||||
if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0
|
||||
|| text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0
|
||||
|| text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0)
|
||||
return "Window state";
|
||||
|
||||
if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0
|
||||
|| text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0
|
||||
|| text.indexOf("window switch") >= 0)
|
||||
return "Focus";
|
||||
|
||||
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|
||||
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|
||||
|| text.indexOf("brightness") >= 0)
|
||||
return "Media & hardware keys";
|
||||
|
||||
return "Applications & shell";
|
||||
}
|
||||
|
||||
// Section order for the page. Anything a future bind invents lands at the
|
||||
// end rather than being dropped.
|
||||
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
|
||||
readonly property var groupOrder: ["Focus", "Move & split", "Size", "Window state",
|
||||
"Workspaces", "Applications & shell", "Media & hardware keys"]
|
||||
|
||||
// The action already bound to a chord, or "" if it is free. Compared on the
|
||||
// form keybinds.lua writes rather than the prettified display form, because
|
||||
// that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the
|
||||
// same binding and must not read as two.
|
||||
function boundTo(luaChord: string, exceptLuaChord: string): string {
|
||||
const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase();
|
||||
const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase();
|
||||
for (const bind of root.binds) {
|
||||
const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase();
|
||||
if (candidate === wanted && candidate !== skip)
|
||||
return String(bind.description);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function grouped(): var {
|
||||
const buckets = {};
|
||||
|
||||
@@ -39,6 +39,25 @@ Singleton {
|
||||
|
||||
readonly property bool replacementDaemon: root.daemon === "dbus"
|
||||
|
||||
// What is stored, never what is stored IN it. No property on this service
|
||||
// ever holds a password: the value is read by the helper, handed straight
|
||||
// to the clipboard, and forgotten. It does not cross into QML at all.
|
||||
property var collections: []
|
||||
property bool listed: false
|
||||
property bool listing: false
|
||||
property bool working: false
|
||||
|
||||
// Set for a moment after a successful copy, so the page can say what
|
||||
// happened without the page having to know how long a clipboard lasts.
|
||||
property string copiedPath: ""
|
||||
|
||||
readonly property int storedCount: {
|
||||
let total = 0;
|
||||
for (const collection of root.collections)
|
||||
total += (collection.items ?? []).length;
|
||||
return total;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!query.running)
|
||||
query.running = true;
|
||||
@@ -53,6 +72,54 @@ Singleton {
|
||||
unlockProcess.running = true;
|
||||
}
|
||||
|
||||
// The stored-secret list. Separate from status() because it is the only
|
||||
// read that needs the keyring UNLOCKED, and because a settings page should
|
||||
// not enumerate someone's passwords just because it was opened.
|
||||
function list(): void {
|
||||
if (root.listing)
|
||||
return;
|
||||
root.listing = true;
|
||||
items.command = [root.helperPath, "items"];
|
||||
items.running = true;
|
||||
}
|
||||
|
||||
// Puts one stored secret on the clipboard. The value never reaches this
|
||||
// process; the helper reads it and writes it to wl-copy's stdin, and clears
|
||||
// it again shortly afterwards if it is still there.
|
||||
function copy(path: string): void {
|
||||
if (root.working)
|
||||
return;
|
||||
root.working = true;
|
||||
root.copiedPath = path;
|
||||
items.command = [root.helperPath, "copy", path];
|
||||
items.running = true;
|
||||
}
|
||||
|
||||
// Irreversible. The page confirms before calling this.
|
||||
function forget(path: string): void {
|
||||
if (root.working)
|
||||
return;
|
||||
root.working = true;
|
||||
root.copiedPath = "";
|
||||
items.command = [root.helperPath, "forget", path];
|
||||
items.running = true;
|
||||
}
|
||||
|
||||
function absorbItems(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.collections = Array.isArray(parsed.collections) ? parsed.collections : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
if (root.lastError !== "")
|
||||
root.copiedPath = "";
|
||||
} catch (error) {
|
||||
root.collections = [];
|
||||
root.lastError = "Could not read the stored secrets.";
|
||||
console.warn("Keyring: could not parse item output:", error);
|
||||
}
|
||||
root.listed = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
@@ -74,6 +141,27 @@ Singleton {
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
}
|
||||
|
||||
// One process for all three item operations: each of them answers with the
|
||||
// same list, so a page never has to ask again to find out what changed.
|
||||
Process {
|
||||
id: items
|
||||
stdout: StdioCollector { onStreamFinished: root.absorbItems(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: {
|
||||
root.listing = false;
|
||||
root.working = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The clipboard notice is transient, and says so by disappearing.
|
||||
Timer {
|
||||
running: root.copiedPath !== ""
|
||||
interval: 12000
|
||||
onTriggered: root.copiedPath = ""
|
||||
}
|
||||
|
||||
Process {
|
||||
id: unlockProcess
|
||||
command: [root.helperPath, "unlock"]
|
||||
|
||||
@@ -234,7 +234,10 @@ Singleton {
|
||||
root.unreadCount += 1;
|
||||
}
|
||||
|
||||
if (!root.doNotDisturb) {
|
||||
// An exception belongs to the focus mode that is in force. A Do Not
|
||||
// Disturb switched on by hand has no exceptions and stays absolute,
|
||||
// because FocusModes.allows is false whenever no mode is active.
|
||||
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
|
||||
root.popups = [notification].concat(root.popups);
|
||||
} else if (notification.transient) {
|
||||
// Never shown, and (being transient) never filed in history
|
||||
|
||||
@@ -25,7 +25,8 @@ Singleton {
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
|
||||
|
||||
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }]
|
||||
// [{ path, provider, providerName, providerIcons, identity, needsAttention,
|
||||
// services: [{key,label,enabled}] }]
|
||||
property var accounts: []
|
||||
property bool scanned: false
|
||||
property bool busy: false
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
pragma Singleton
|
||||
|
||||
// Which applications may use the camera and microphone.
|
||||
//
|
||||
// Backed by xdg-desktop-portal's permission store, which records the answer an
|
||||
// application got when it asked through the portal. That is the whole of what
|
||||
// this controls, and the limit belongs on the page rather than in a comment: a
|
||||
// native binary opens /dev/video0 directly and no desktop setting stands in its
|
||||
// way. What this covers is Flatpaks and anything else going through the portal.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-permissions"
|
||||
|
||||
property bool available: false
|
||||
property var devices: []
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
// Guards read the Processes directly rather than a derived binding, which
|
||||
// returns its cached value inside the handler that changes its dependency.
|
||||
readonly property bool busy: query.running || mutation.running
|
||||
|
||||
// Devices something has actually asked for. A device nothing has asked for
|
||||
// is still reported, so the page can say so rather than omit it.
|
||||
readonly property var recorded: root.devices.filter(
|
||||
device => (device.applications ?? []).length > 0)
|
||||
|
||||
readonly property int grantedCount: root.devices.reduce(
|
||||
(total, device) => total + (device.applications ?? []).filter(app => app.allowed).length, 0)
|
||||
|
||||
function refresh(): void {
|
||||
if (query.running)
|
||||
return;
|
||||
query.command = [root.helperPath, "snapshot"];
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.available = parsed.available === true;
|
||||
root.devices = Array.isArray(parsed.devices) ? parsed.devices : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the portal's permissions.";
|
||||
console.warn("Permissions: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
function run(arguments: var): void {
|
||||
if (mutation.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
mutation.command = [root.helperPath].concat(arguments);
|
||||
mutation.running = true;
|
||||
}
|
||||
|
||||
function setAllowed(device: string, app: string, allowed: bool): void {
|
||||
root.run(["set", device, app, allowed ? "allow" : "deny"]);
|
||||
}
|
||||
|
||||
// Drops the recorded answer entirely, so the application is asked again the
|
||||
// next time it wants the device.
|
||||
function forget(device: string, app: string): void {
|
||||
root.run(["forget", device, app]);
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
Process {
|
||||
id: query
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user