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 |
|
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | 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
|
## System health and recovery
|
||||||
|
|
||||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||||
appears only for actionable warnings or errors and opens the same **System
|
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:
|
System Health**. The terminal summary is available with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ hl.on("hyprland.start", function()
|
|||||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||||
-- synchronously in the same shell invocation first so the Condition
|
-- synchronously in the same shell invocation first so the Condition
|
||||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
-- 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.
|
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
-- 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>).
|
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||||
--
|
--
|
||||||
-- Forge resized one named edge at a time. Hyprland resizes the active window
|
-- Forge resized one named EDGE at a time: its resize() grows the window for a
|
||||||
-- along an axis and lets the layout decide which edge actually moves, so the
|
-- positive amount in every direction, and the edge only decides which side
|
||||||
-- eight Forge keys collapse onto four behaviors. The pairing is kept
|
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
|
||||||
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
-- an axis and lets the layout choose the border, so those eight distinct
|
||||||
-- and "increase" always grows while "decrease" always shrinks.
|
-- 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
|
local step = 60
|
||||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
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 = "Wider" })
|
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 = "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 = "Narrower" })
|
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 = "Taller" })
|
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 = "Taller" })
|
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
|
-- 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
|
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||||
-- uses rather than landing on an arbitrary free key.
|
-- 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 + 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 = "Shorter" })
|
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
|
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||||
-- showing what you are choosing between.
|
-- showing what you are choosing between.
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ hl.window_rule({
|
|||||||
center = true,
|
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.
|
-- Portal dialogs (file chooser, screen share picker) should always float.
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
|
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",
|
label: "Automatically hide the Dock",
|
||||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
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,
|
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||||
unit: "ms",
|
unit: "ms",
|
||||||
@@ -107,6 +132,41 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Focus ───────────────────────────────────────────────────────────
|
// ── 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,
|
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||||
unit: "min",
|
unit: "min",
|
||||||
@@ -702,6 +762,14 @@ Singleton {
|
|||||||
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||||
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
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",
|
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||||
label: "Dim inactive windows",
|
label: "Dim inactive windows",
|
||||||
@@ -716,6 +784,22 @@ Singleton {
|
|||||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
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 ─────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ Singleton {
|
|||||||
|
|
||||||
// ── Dock ────────────────────────────────────────────────────────────────
|
// ── Dock ────────────────────────────────────────────────────────────────
|
||||||
// Pinned apps, in order, taken from the GNOME dash favorites.
|
// 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")
|
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||||
|
|
||||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
// 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
|
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
|
||||||
// the default.
|
// 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: ({
|
readonly property var accents: ({
|
||||||
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
|
"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" },
|
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" },
|
||||||
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
|
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" },
|
||||||
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
|
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" },
|
||||||
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
|
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" },
|
||||||
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
|
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" },
|
||||||
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
|
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" },
|
||||||
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" }
|
"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
|
// 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.
|
// light one, and text on it stops being legible.
|
||||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
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 overlayAlpha: root.dark ? 0.55 : 0.40
|
||||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
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 barGap: 6 // breathing room below the bar for popovers
|
||||||
readonly property int barSideMargin: 10 // inset for floating 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 dockPadding: 8
|
||||||
readonly property int dockGap: 8
|
readonly property int dockGap: 8
|
||||||
readonly property int dockRadius: 20
|
readonly property int dockRadius: 20
|
||||||
@@ -121,7 +135,6 @@ Singleton {
|
|||||||
readonly property int popoverPadding: 14
|
readonly property int popoverPadding: 14
|
||||||
readonly property int popoverWidth: 380
|
readonly property int popoverWidth: 380
|
||||||
readonly property int controlCenterWidth: 430
|
readonly property int controlCenterWidth: 430
|
||||||
readonly property int controlCenterTopGap: 2
|
|
||||||
|
|
||||||
readonly property int cardRadius: 12
|
readonly property int cardRadius: 12
|
||||||
readonly property int pillRadius: 999
|
readonly property int pillRadius: 999
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ PanelWindow {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
anchors.top: true
|
anchors.top: true
|
||||||
anchors.right: 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
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
implicitWidth: 350
|
implicitWidth: 350
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ PanelWindow {
|
|||||||
top: true
|
top: true
|
||||||
right: 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 {
|
margins {
|
||||||
top: Theme.barHeight + Theme.barGap * 2
|
top: Theme.barGap * 2
|
||||||
right: Theme.barSideMargin
|
right: Theme.barSideMargin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ PanelWindow {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
anchors.top: true
|
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
|
exclusiveZone: 0
|
||||||
|
|
||||||
implicitWidth: 760
|
implicitWidth: 760
|
||||||
|
|||||||
@@ -22,10 +22,34 @@ PanelWindow {
|
|||||||
property var modelData: null
|
property var modelData: null
|
||||||
screen: root.modelData
|
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 {
|
anchors {
|
||||||
|
top: root.vertical
|
||||||
bottom: true
|
bottom: true
|
||||||
left: true
|
left: root.position !== "right"
|
||||||
right: true
|
right: root.position !== "left"
|
||||||
}
|
}
|
||||||
|
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
@@ -42,10 +66,13 @@ PanelWindow {
|
|||||||
// ── Geometry ────────────────────────────────────────────────────────────
|
// ── Geometry ────────────────────────────────────────────────────────────
|
||||||
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
||||||
readonly property int revealStripHeight: 3
|
readonly property int revealStripHeight: 3
|
||||||
readonly property int bottomMargin: Theme.barGap
|
readonly property int edgeMargin: Theme.barGap
|
||||||
readonly property int tooltipSpace: 34
|
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 ─────────────────────────────────────────────────────────
|
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||||
@@ -140,22 +167,71 @@ PanelWindow {
|
|||||||
id: pointer
|
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 {
|
Item {
|
||||||
id: maskItem
|
id: maskItem
|
||||||
x: root.revealed ? body.x : 0
|
|
||||||
y: root.revealed ? body.y : surface.height - root.revealStripHeight
|
x: {
|
||||||
width: root.revealed ? body.width : surface.width
|
if (!root.revealed)
|
||||||
height: root.revealed ? surface.height - body.y : root.revealStripHeight
|
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 {
|
DockBody {
|
||||||
id: body
|
id: body
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
|
|
||||||
// Slides off the bottom edge when hidden.
|
vertical: root.vertical
|
||||||
y: root.revealed ? root.tooltipSpace : surface.height
|
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
|
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
|
// Asymmetric on purpose. Revealing is a response to something the
|
||||||
// user just did, so it has to feel immediate — any delay there
|
// 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
|
// reads as lag. Hiding is not a response to anything, so it can
|
||||||
|
|||||||
@@ -111,30 +111,51 @@ Rectangle {
|
|||||||
inset: parent.radius
|
inset: parent.radius
|
||||||
}
|
}
|
||||||
|
|
||||||
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
|
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
|
||||||
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
|
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
|
||||||
|
|
||||||
// The item the tooltip is currently describing, or null.
|
// The item the tooltip is currently describing, or null.
|
||||||
property Item hoveredItem: null
|
property Item hoveredItem: null
|
||||||
|
|
||||||
Row {
|
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||||
id: row
|
// 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
|
anchors.centerIn: parent
|
||||||
spacing: Theme.dockGap
|
spacing: Theme.dockGap
|
||||||
|
rows: root.vertical ? root.cellCount : 1
|
||||||
|
columns: root.vertical ? 1 : root.cellCount
|
||||||
|
horizontalItemAlignment: Grid.AlignHCenter
|
||||||
|
verticalItemAlignment: Grid.AlignVCenter
|
||||||
|
|
||||||
ShowAppsButton {
|
ShowAppsButton {
|
||||||
id: showApps
|
id: showApps
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
onEntered: root.hoveredItem = showApps
|
onEntered: root.hoveredItem = showApps
|
||||||
onExited: if (root.hoveredItem === showApps)
|
onExited: if (root.hoveredItem === showApps)
|
||||||
root.hoveredItem = null
|
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 {
|
Rectangle {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
|
||||||
width: 1
|
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
|
||||||
height: Theme.dockIconSize * 0.7
|
|
||||||
border.width: 0
|
border.width: 0
|
||||||
color: Theme.alpha(Theme.fg, 0.14)
|
color: Theme.alpha(Theme.fg, 0.14)
|
||||||
}
|
}
|
||||||
@@ -146,7 +167,6 @@ Rectangle {
|
|||||||
id: dockItem
|
id: dockItem
|
||||||
required property var modelData
|
required property var modelData
|
||||||
app: modelData
|
app: modelData
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
onEntered: root.hoveredItem = dockItem
|
onEntered: root.hoveredItem = dockItem
|
||||||
onExited: if (root.hoveredItem === dockItem)
|
onExited: if (root.hoveredItem === dockItem)
|
||||||
root.hoveredItem = null
|
root.hoveredItem = null
|
||||||
@@ -172,16 +192,26 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Centered on the hovered item, clamped inside the dock. Reads the item's
|
// Centred on the hovered item along the dock's own axis, and placed just
|
||||||
// x directly (rather than mapToItem) so the binding re-evaluates when
|
// outside the edge it lives on. Reads the item's position directly
|
||||||
// the row reflows.
|
// (rather than mapToItem) so the binding re-evaluates when the strip
|
||||||
|
// reflows.
|
||||||
x: {
|
x: {
|
||||||
if (!root.hoveredItem)
|
if (!root.hoveredItem)
|
||||||
return 0;
|
return 0;
|
||||||
const center = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
if (root.vertical)
|
||||||
return Math.max(4, Math.min(root.width - width - 4, center - width / 2));
|
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
|
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
||||||
height: tipLabel.implicitHeight + 8
|
height: tipLabel.implicitHeight + 8
|
||||||
|
|||||||
@@ -35,7 +35,17 @@ Rectangle {
|
|||||||
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
border.width: 0
|
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 {
|
Behavior on color {
|
||||||
ColorAnimation {
|
ColorAnimation {
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors.top: true
|
anchors.top: true
|
||||||
anchors.right: 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
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
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 {
|
Rectangle {
|
||||||
id: restingCard
|
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
|
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
|
radius: Theme.cardRadius + 2
|
||||||
color: Theme.alpha(Theme.warn, 0.028)
|
color: Theme.alpha(Theme.warn, 0.028)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
|
|||||||
@@ -20,9 +20,21 @@ PanelWindow {
|
|||||||
|
|
||||||
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
||||||
// preserve the Prism edge without making the surface feel detached.
|
// 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.top: true
|
||||||
anchors.right: 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
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,27 @@ Item {
|
|||||||
onExpanded: root.expand("wifi")
|
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 {
|
Toggle {
|
||||||
width: root.cellWidth
|
width: root.cellWidth
|
||||||
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
||||||
@@ -304,15 +325,45 @@ Item {
|
|||||||
width: content.width
|
width: content.width
|
||||||
height: 36
|
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
|
id: avatar
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 4
|
anchors.leftMargin: 4
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
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
|
size: 20
|
||||||
icon: "avatar-default-symbolic"
|
icon: "avatar-default-symbolic"
|
||||||
iconFallback: "user-info-symbolic"
|
iconFallback: "user-info-symbolic"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.left: avatar.right
|
anchors.left: avatar.right
|
||||||
@@ -320,7 +371,9 @@ Item {
|
|||||||
anchors.right: actions.left
|
anchors.right: actions.left
|
||||||
anchors.rightMargin: 8
|
anchors.rightMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: Quickshell.env("USER") || "user"
|
text: UserAccounts.me
|
||||||
|
? UserAccounts.displayName(UserAccounts.me)
|
||||||
|
: (Quickshell.env("USER") || "user")
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ SettingsPage {
|
|||||||
title: "Text"
|
title: "Text"
|
||||||
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
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 {
|
SettingsCard {
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ SettingsPage {
|
|||||||
|
|
||||||
property string expandedPicker: ""
|
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"
|
title: "Appearance"
|
||||||
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||||
|
|
||||||
@@ -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 {
|
SettingsCard {
|
||||||
|
visible: root.tab === "background"
|
||||||
title: "Background"
|
title: "Background"
|
||||||
subtitle: Wallpaper.lastError !== ""
|
subtitle: Wallpaper.lastError !== ""
|
||||||
? Wallpaper.lastError
|
? Wallpaper.lastError
|
||||||
@@ -83,6 +109,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "background"
|
||||||
title: "Lock screen"
|
title: "Lock screen"
|
||||||
subtitle: LockScreen.lastError !== ""
|
subtitle: LockScreen.lastError !== ""
|
||||||
? LockScreen.lastError
|
? LockScreen.lastError
|
||||||
@@ -101,6 +128,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "theme"
|
||||||
title: "Color scheme"
|
title: "Color scheme"
|
||||||
subtitle: ColorScheme.lastError !== ""
|
subtitle: ColorScheme.lastError !== ""
|
||||||
? ColorScheme.lastError
|
? ColorScheme.lastError
|
||||||
@@ -116,6 +144,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
title: "Shell typography"
|
title: "Shell typography"
|
||||||
subtitle: Fonts.lastError !== ""
|
subtitle: Fonts.lastError !== ""
|
||||||
? Fonts.lastError
|
? Fonts.lastError
|
||||||
@@ -157,6 +186,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
title: "Application typography"
|
title: "Application typography"
|
||||||
subtitle: DesktopStyle.lastError !== ""
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
? DesktopStyle.lastError
|
? DesktopStyle.lastError
|
||||||
@@ -229,6 +259,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
title: "Icons & pointer"
|
title: "Icons & pointer"
|
||||||
subtitle: DesktopStyle.lastError !== ""
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
? DesktopStyle.lastError
|
? DesktopStyle.lastError
|
||||||
@@ -279,6 +310,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "windows"
|
||||||
title: "Titlebars"
|
title: "Titlebars"
|
||||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||||
|
|
||||||
@@ -288,6 +320,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "windows"
|
||||||
title: "Windows"
|
title: "Windows"
|
||||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||||
|
|
||||||
@@ -302,6 +335,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "theme"
|
||||||
title: "Effects"
|
title: "Effects"
|
||||||
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
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 {
|
SettingsCard {
|
||||||
|
visible: root.tab === "shell"
|
||||||
title: "Clock"
|
title: "Clock"
|
||||||
|
|
||||||
ToggleRow { setting: "use24Hour" }
|
ToggleRow { setting: "use24Hour" }
|
||||||
@@ -326,6 +361,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "shell"
|
||||||
title: "System vitals"
|
title: "System vitals"
|
||||||
subtitle: "Choose what appears beside the workspace indicator."
|
subtitle: "Choose what appears beside the workspace indicator."
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,26 @@ SettingsPage {
|
|||||||
|
|
||||||
property string expandedRole: ""
|
property string expandedRole: ""
|
||||||
property bool addingAutostart: false
|
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
|
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: [
|
readonly property var roles: [
|
||||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||||
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
|
{ 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: "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: "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, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
||||||
{ key: "images", label: "Images", detail: "PNG images", 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 video", categorySets: [["video"]], terms: ["video player", "movie 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 {
|
function desktopId(entry: var): string {
|
||||||
@@ -43,17 +54,27 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesRole(entry: var, role: var): bool {
|
function matchesRole(entry: var, role: var): bool {
|
||||||
const rawCategories = Array.isArray(entry.categories)
|
// DesktopEntries hands back a QML list, not a JavaScript array, so
|
||||||
? entry.categories
|
// Array.isArray is false for it. The old code took that as "this is a
|
||||||
: [String(entry.categories ?? "")];
|
// 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 = [];
|
const categories = [];
|
||||||
for (const rawCategory of rawCategories) {
|
for (const value of joined.split(/[;,]/)) {
|
||||||
for (const value of String(rawCategory).split(";")) {
|
|
||||||
const category = value.trim().toLowerCase();
|
const category = value.trim().toLowerCase();
|
||||||
if (category !== "")
|
if (category !== "")
|
||||||
categories.push(category);
|
categories.push(category);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const metadata = [entry.name, entry.genericName]
|
const metadata = [entry.name, entry.genericName]
|
||||||
.map(value => String(value ?? "").toLowerCase())
|
.map(value => String(value ?? "").toLowerCase())
|
||||||
.join(" ");
|
.join(" ");
|
||||||
@@ -95,14 +116,46 @@ SettingsPage {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
|
id: roleRow
|
||||||
|
|
||||||
|
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
||||||
|
|
||||||
label: roleBlock.modelData.label
|
label: roleBlock.modelData.label
|
||||||
detail: roleBlock.modelData.detail
|
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
|
roleBlock.selectedEntry
|
||||||
? root.displayName(roleBlock.selectedEntry)
|
? root.displayName(roleBlock.selectedEntry)
|
||||||
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
: (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
|
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
|
||||||
onActivated: {
|
onActivated: {
|
||||||
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
||||||
@@ -219,12 +272,50 @@ SettingsPage {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool confirming:
|
||||||
|
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
|
||||||
|
|
||||||
label: autostartRow.modelData.name
|
label: autostartRow.modelData.name
|
||||||
detail: autostartRow.modelData.id
|
detail: autostartRow.confirming
|
||||||
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
|
? "Removing deletes this entry. Turning it off instead is reversible."
|
||||||
activatable: !DefaultApps.busy
|
: autostartRow.modelData.id
|
||||||
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
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"
|
title: "Wired"
|
||||||
visible: Connectivity.wiredDevice !== null
|
visible: Connectivity.wiredDevice !== null
|
||||||
|
|
||||||
TextRow {
|
SwitchRow {
|
||||||
label: "Ethernet"
|
label: "Ethernet"
|
||||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
// Three states worth telling apart: on, off but plugged in, and
|
||||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
// 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
|
divider: false
|
||||||
|
onToggled: value => Connectivity.setWired(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,24 +185,15 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Owned by Fedora"
|
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 {
|
ActionRow {
|
||||||
label: "Network connections"
|
label: "Network connections"
|
||||||
detail: "VPN, proxies, and per-connection settings"
|
detail: "VPN, proxies, and per-connection settings"
|
||||||
action: "Open"
|
action: "Open"
|
||||||
|
divider: false
|
||||||
onTriggered: SystemSettings.openGnomePanel("network")
|
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 {
|
SettingsCard {
|
||||||
title: "Timezone"
|
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 {
|
SearchField {
|
||||||
id: zoneSearch
|
id: zoneSearch
|
||||||
@@ -72,7 +80,10 @@ SettingsPage {
|
|||||||
controlWidth: 90
|
controlWidth: 90
|
||||||
divider: index < root.matchingZones.length - 1
|
divider: index < root.matchingZones.length - 1
|
||||||
activatable: true
|
activatable: true
|
||||||
onActivated: DateTime.setTimezone(modelData)
|
onActivated: {
|
||||||
|
DateTime.setTimezone(modelData);
|
||||||
|
zonePicker.collapse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +94,7 @@ SettingsPage {
|
|||||||
divider: false
|
divider: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: DateTime.lastError !== ""
|
visible: DateTime.lastError !== ""
|
||||||
|
|||||||
@@ -8,21 +8,100 @@
|
|||||||
// them.
|
// them.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
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"
|
title: "Desktop & Dock"
|
||||||
lede: "Keep the shell instant, spatial, and out of your way."
|
lede: "Keep the shell instant, spatial, and out of your way."
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Dock"
|
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" }
|
ToggleRow { setting: "dockAutohide" }
|
||||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
|
||||||
|
SliderRow { setting: "dockIconSize"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -62,7 +141,7 @@ SettingsPage {
|
|||||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||||
action: "Open Appearance"
|
action: "Open Appearance"
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: ShellState.openSettings("appearance")
|
onTriggered: ShellState.openSettingsSection("appearance", "windows")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +201,71 @@ SettingsPage {
|
|||||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Snapshots"
|
title: "Snapshots"
|
||||||
subtitle: SettingsBackup.lastError !== ""
|
subtitle: SettingsBackup.lastError !== ""
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Column {
|
|||||||
property var monitor: null
|
property var monitor: null
|
||||||
property bool enabled: true
|
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
|
spacing: 0
|
||||||
|
|
||||||
readonly property var grouped: {
|
readonly property var grouped: {
|
||||||
@@ -106,11 +109,14 @@ Column {
|
|||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
enabled: root.enabled && !rate.selected
|
enabled: root.enabled && !rate.selected
|
||||||
onTapped: Displays.apply(
|
onTapped: {
|
||||||
|
Displays.apply(
|
||||||
root.monitor.name,
|
root.monitor.name,
|
||||||
rate.modelData.mode,
|
rate.modelData.mode,
|
||||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||||
root.monitor.transform)
|
root.monitor.transform);
|
||||||
|
root.picked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ SettingsPage {
|
|||||||
? root.monitor.mode
|
? 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 {
|
function syncSelectedOutput(): void {
|
||||||
if (!Displays.monitorNamed(root.selectedOutput))
|
if (!Displays.monitorNamed(root.selectedOutput))
|
||||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||||
@@ -175,10 +184,61 @@ SettingsPage {
|
|||||||
title: "Resolution"
|
title: "Resolution"
|
||||||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
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 {
|
DisplayModePicker {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
monitor: root.monitor
|
monitor: root.monitor
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
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);
|
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 {
|
function move(from: int, to: int): void {
|
||||||
if (to < 0 || to >= root.pinned.length)
|
if (to < 0 || to >= root.pinned.length)
|
||||||
return;
|
return;
|
||||||
@@ -67,7 +111,7 @@ Column {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.pinned
|
model: root.displayed
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
id: pin
|
id: pin
|
||||||
@@ -76,15 +120,71 @@ Column {
|
|||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
label: root.nameFor(pin.modelData)
|
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
|
divider: pin.index < root.pinned.length - 1
|
||||||
controlWidth: 132
|
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 {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 4
|
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 {
|
SettingsButton {
|
||||||
text: "↑"
|
text: "↑"
|
||||||
enabled: pin.index > 0
|
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 {
|
ActionRow {
|
||||||
objectName: "health-fedora-handoff:color"
|
objectName: "health-fedora-handoff:color"
|
||||||
label: "Color profiles"
|
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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
@@ -6,119 +22,101 @@ SettingsPage {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property int openedHour: new Date().getHours()
|
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`
|
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 {
|
Component.onCompleted: {
|
||||||
title: SystemSettings.monitorDescription || "Active display"
|
if (!Firewall.scanned)
|
||||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
Firewall.refresh();
|
||||||
|
if (!Containers.scanned)
|
||||||
|
Containers.refresh();
|
||||||
|
if (!Snapshots.scanned)
|
||||||
|
Snapshots.refresh();
|
||||||
|
if (!Disks.scanned)
|
||||||
|
Disks.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What you came here to do ────────────────────────────────────────────
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
id: monitorLayout
|
id: doing
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
columns: width >= 720 ? 2 : 1
|
columns: width >= 720 ? 2 : 1
|
||||||
@@ -126,20 +124,46 @@ SettingsPage {
|
|||||||
rowSpacing: 16
|
rowSpacing: 16
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
width: summaryCards.columns === 2
|
width: doing.columns === 2
|
||||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
? (doing.width - doing.columnSpacing) / 2
|
||||||
: summaryCards.width
|
: doing.width
|
||||||
title: "Quiet focus"
|
title: "Focus"
|
||||||
subtitle: "Notifications and focused work"
|
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 {
|
SettingRow {
|
||||||
label: "Do Not Disturb"
|
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
|
divider: false
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
enabled: !FocusModes.active && !FocusSession.active
|
||||||
checked: Notifs.doNotDisturb
|
checked: Notifs.doNotDisturb
|
||||||
onToggled: value => Notifs.doNotDisturb = value
|
onToggled: value => Notifs.doNotDisturb = value
|
||||||
}
|
}
|
||||||
@@ -147,18 +171,119 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
width: summaryCards.columns === 2
|
width: doing.columns === 2
|
||||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
? (doing.width - doing.columnSpacing) / 2
|
||||||
: summaryCards.width
|
: doing.width
|
||||||
title: "Desktop services"
|
title: "Right now"
|
||||||
subtitle: "The essentials are running"
|
subtitle: Containers.running > 0
|
||||||
|
? Containers.running + " of " + Containers.total + " containers running"
|
||||||
|
: "No containers are running"
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Sync & remote access"
|
label: Containers.running > 0
|
||||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
? 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
|
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";
|
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 {
|
function saveHomeAssistantConfig(): void {
|
||||||
HomeAssistantConfig.save(
|
HomeAssistantConfig.save(
|
||||||
homeUrlInput.text,
|
homeUrlInput.text,
|
||||||
homeEntitiesInput.text,
|
HomeAssistantConfig.entities.join(", "),
|
||||||
homeTokenInput.text
|
homeTokenInput.text
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,7 +61,6 @@ SettingsPage {
|
|||||||
function onConfigurationSaved(): void {
|
function onConfigurationSaved(): void {
|
||||||
homeTokenInput.clear();
|
homeTokenInput.clear();
|
||||||
homeUrlInput.text = HomeAssistantConfig.url;
|
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 {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: HomeAssistantConfig.lastError !== ""
|
visible: HomeAssistantConfig.lastError !== ""
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import qs.modules.clipboard
|
|||||||
Column {
|
Column {
|
||||||
id: root
|
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
|
spacing: 0
|
||||||
|
|
||||||
SearchField {
|
SearchField {
|
||||||
@@ -37,8 +41,10 @@ Column {
|
|||||||
divider: place.index < Geocoding.results.length - 1
|
divider: place.index < Geocoding.results.length - 1
|
||||||
activatable: true
|
activatable: true
|
||||||
onActivated: {
|
onActivated: {
|
||||||
if (Geocoding.choose(place.modelData))
|
if (Geocoding.choose(place.modelData)) {
|
||||||
query.text = "";
|
query.text = "";
|
||||||
|
root.picked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,52 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
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"
|
title: "Notifications & Focus"
|
||||||
lede: "Control interruptions without losing useful history."
|
lede: "Control interruptions without losing useful history."
|
||||||
|
|
||||||
@@ -25,7 +71,7 @@ SettingsPage {
|
|||||||
TextRow {
|
TextRow {
|
||||||
label: "Notification history"
|
label: "Notification history"
|
||||||
detail: "Live notifications retained by the shell"
|
detail: "Live notifications retained by the shell"
|
||||||
value: `${Notifs.history.length} items`
|
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
@@ -65,26 +111,65 @@ SettingsPage {
|
|||||||
model: Notifs.applications
|
model: Notifs.applications
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
id: appEntry
|
||||||
|
|
||||||
required property var modelData
|
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
|
width: parent.width
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: app.name
|
width: parent.width
|
||||||
detail: app.id
|
label: appEntry.app.name
|
||||||
controlWidth: 48
|
// 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.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: Notifs.appRule(app.id).enabled
|
spacing: 11
|
||||||
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
|
|
||||||
|
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 {
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: appEntry.open
|
||||||
label: "Show on lock screen"
|
label: "Show on lock screen"
|
||||||
detail: "Allow this app's notifications on the lock screen"
|
detail: "Allow this app's notifications on the lock screen"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
@@ -92,22 +177,264 @@ SettingsPage {
|
|||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: Notifs.appRule(app.id).showOnLockScreen
|
checked: appEntry.rule.showOnLockScreen
|
||||||
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
|
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: appEntry.open
|
||||||
label: "Show content on lock screen"
|
label: "Show content on lock screen"
|
||||||
detail: "Show message details when this app is visible there"
|
detail: "Show message details when this app is visible there"
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 48
|
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 {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: Notifs.appRule(app.id).showContentOnLockScreen
|
checked: modeEntry.mode.silence === true
|
||||||
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
|
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.
|
// quietly stops syncing for weeks.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
@@ -57,6 +58,22 @@ SettingsPage {
|
|||||||
id: accountCard
|
id: accountCard
|
||||||
required property var modelData
|
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
|
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||||
subtitle: accountCard.modelData.needsAttention
|
subtitle: accountCard.modelData.needsAttention
|
||||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
? 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
|
id: root
|
||||||
|
|
||||||
title: "Privacy & Security"
|
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
|
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||||
? "Screen lock, device access, and a machine whose security settings all check out."
|
? "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."
|
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||||
@@ -28,6 +47,8 @@ SettingsPage {
|
|||||||
DeviceSecurity.refresh();
|
DeviceSecurity.refresh();
|
||||||
if (!Keyring.scanned)
|
if (!Keyring.scanned)
|
||||||
Keyring.refresh();
|
Keyring.refresh();
|
||||||
|
if (!Permissions.scanned)
|
||||||
|
Permissions.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
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 {
|
SettingsCard {
|
||||||
title: "Camera & microphone"
|
title: "Camera & microphone"
|
||||||
subtitle: PrivacyState.anyActive
|
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 {
|
SettingsCard {
|
||||||
title: "Device security"
|
title: "Device security"
|
||||||
subtitle: DeviceSecurity.attentionCount === 0
|
subtitle: DeviceSecurity.attentionCount === 0
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ such rather than half-reimplemented.
|
|||||||
|
|
||||||
The stable internal `services` route renders **System Health**. It is reachable
|
The stable internal `services` route renders **System Health**. It is reachable
|
||||||
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
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.
|
reserve no bar space and produce no notification.
|
||||||
|
|
||||||
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ SettingsPage {
|
|||||||
title: "Language"
|
title: "Language"
|
||||||
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||||
|
|
||||||
TextRow {
|
PickerRow {
|
||||||
|
id: languagePicker
|
||||||
|
|
||||||
label: "Current language"
|
label: "Current language"
|
||||||
detail: SystemLocale.pendingRestart
|
detail: SystemLocale.pendingRestart
|
||||||
? "Chosen, but not in use until you sign out and back in"
|
? "Chosen, but not in use until you sign out and back in"
|
||||||
: "Used by programs that ask the system what language to speak"
|
: "Used by programs that ask the system what language to speak"
|
||||||
value: SystemLocale.currentLabel || "Reading…"
|
value: SystemLocale.currentLabel || "Reading…"
|
||||||
}
|
divider: false
|
||||||
|
|
||||||
SearchPicker {
|
SearchPicker {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -43,7 +45,11 @@ SettingsPage {
|
|||||||
current: SystemLocale.current
|
current: SystemLocale.current
|
||||||
placeholder: "Search languages and regions"
|
placeholder: "Search languages and regions"
|
||||||
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
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
|
id: root
|
||||||
|
|
||||||
property string text: ""
|
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 string tone: "normal"
|
||||||
property bool enabled: true
|
property bool enabled: true
|
||||||
signal clicked
|
signal clicked
|
||||||
@@ -16,16 +20,20 @@ Rectangle {
|
|||||||
color: {
|
color: {
|
||||||
if (tone === "accent")
|
if (tone === "accent")
|
||||||
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.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);
|
return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075);
|
||||||
}
|
}
|
||||||
border.width: tone === "accent" ? 0 : 1
|
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 {
|
Text {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: root.text
|
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.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ Rectangle {
|
|||||||
property string title: ""
|
property string title: ""
|
||||||
property string subtitle: ""
|
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
|
width: parent ? parent.width : 680
|
||||||
implicitHeight: body.implicitHeight + 30
|
implicitHeight: body.implicitHeight + 30
|
||||||
radius: Theme.cardRadius + 2
|
radius: Theme.cardRadius + 2
|
||||||
@@ -32,15 +39,38 @@ Rectangle {
|
|||||||
anchors.margins: 15
|
anchors.margins: 15
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
Text {
|
Row {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.title !== ""
|
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
|
text: root.title
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
font.weight: Font.DemiBold
|
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 {
|
Text {
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ Rectangle {
|
|||||||
case "home-phone": return homePhonePage;
|
case "home-phone": return homePhonePage;
|
||||||
case "desktop": return desktopPage;
|
case "desktop": return desktopPage;
|
||||||
case "sound": return soundPage;
|
case "sound": return soundPage;
|
||||||
|
case "gaming": return gamingPage;
|
||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
@@ -120,6 +121,15 @@ Rectangle {
|
|||||||
case "power": return powerPage;
|
case "power": return powerPage;
|
||||||
case "datetime": return dateTimePage;
|
case "datetime": return dateTimePage;
|
||||||
case "applications": return applicationsPage;
|
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 "services": return healthPage;
|
||||||
case "about": return aboutPage;
|
case "about": return aboutPage;
|
||||||
default: return homePage;
|
default: return homePage;
|
||||||
@@ -158,6 +168,15 @@ Rectangle {
|
|||||||
|
|
||||||
Component { id: homePage; HomePage {} }
|
Component { id: homePage; HomePage {} }
|
||||||
Component { id: applicationsPage; ApplicationsPage {} }
|
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: accessibilityPage; AccessibilityPage {} }
|
||||||
Component { id: powerPage; PowerPage {} }
|
Component { id: powerPage; PowerPage {} }
|
||||||
Component { id: dateTimePage; DateTimePage {} }
|
Component { id: dateTimePage; DateTimePage {} }
|
||||||
@@ -167,6 +186,7 @@ Rectangle {
|
|||||||
Component { id: homePhonePage; HomePhonePage {} }
|
Component { id: homePhonePage; HomePhonePage {} }
|
||||||
Component { id: desktopPage; DesktopPage {} }
|
Component { id: desktopPage; DesktopPage {} }
|
||||||
Component { id: soundPage; SoundPage {} }
|
Component { id: soundPage; SoundPage {} }
|
||||||
|
Component { id: gamingPage; GamingPage {} }
|
||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ Rectangle {
|
|||||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
{ 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: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
|
{ page: "gaming", label: "Gaming", icon: "\u{F0297}" },
|
||||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||||
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||||
@@ -40,6 +46,10 @@ Rectangle {
|
|||||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
{ 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: "services", label: "System Health", icon: "\u{F0493}" },
|
||||||
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
{ 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
|
// when nothing is being captured. Held here rather than per row so that
|
||||||
// starting a new capture cancels any other.
|
// starting a new capture cancels any other.
|
||||||
property string capturingChord: ""
|
property string capturingChord: ""
|
||||||
|
|
||||||
|
// 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") ?? "")
|
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
||||||
|
|
||||||
function xkbOptions(): var {
|
function xkbOptions(): var {
|
||||||
@@ -170,11 +175,28 @@ SettingsPage {
|
|||||||
height: 30
|
height: 30
|
||||||
visible: bindRow.capturing
|
visible: bindRow.capturing
|
||||||
focus: 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 => {
|
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);
|
Keybinds.rebind(bindRow.modelData.luaChord, chord);
|
||||||
root.capturingChord = "";
|
root.capturingChord = "";
|
||||||
}
|
}
|
||||||
onCanceled: root.capturingChord = ""
|
onCanceled: {
|
||||||
|
root.conflict = "";
|
||||||
|
root.capturingChord = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
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 {
|
SettingsButton {
|
||||||
id: useButton
|
id: useButton
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -98,6 +111,7 @@ Rectangle {
|
|||||||
onClicked: AudioDevices.select(root.output, root.node)
|
onClicked: AudioDevices.select(root.output, root.node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconButton {
|
IconButton {
|
||||||
id: muteButton
|
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
|
source: "file://" + tile.modelData
|
||||||
fillMode: Image.PreserveAspectCrop
|
fillMode: Image.PreserveAspectCrop
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
cache: false
|
|
||||||
// Decode to roughly the size actually drawn. Without this a
|
// Decode to roughly the size actually drawn. Without this a
|
||||||
// grid of 12MB photographs decodes at full resolution.
|
// grid of 12MB photographs decodes at full resolution.
|
||||||
sourceSize.width: 400
|
sourceSize.width: 400
|
||||||
sourceSize.height: 240
|
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
|
// Several of these are 8-12MB originals, so a tile can sit
|
||||||
// empty for a second or two. Fading in on ready makes that
|
// empty for a second or two. Fading in on ready makes that
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
module qs.modules.settings
|
module qs.modules.settings
|
||||||
AboutPage 1.0 AboutPage.qml
|
AboutPage 1.0 AboutPage.qml
|
||||||
AppearancePage 1.0 AppearancePage.qml
|
AppearancePage 1.0 AppearancePage.qml
|
||||||
|
AvatarPicker 1.0 AvatarPicker.qml
|
||||||
ConnectivityPage 1.0 ConnectivityPage.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
|
HomePhonePage 1.0 HomePhonePage.qml
|
||||||
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||||
AvailableLightRow 1.0 AvailableLightRow.qml
|
AvailableLightRow 1.0 AvailableLightRow.qml
|
||||||
@@ -9,10 +17,13 @@ DesktopPage 1.0 DesktopPage.qml
|
|||||||
DisplaysPage 1.0 DisplaysPage.qml
|
DisplaysPage 1.0 DisplaysPage.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
NotificationsPage 1.0 NotificationsPage.qml
|
NotificationsPage 1.0 NotificationsPage.qml
|
||||||
|
PasswordRow 1.0 PasswordRow.qml
|
||||||
|
PrintersPage 1.0 PrintersPage.qml
|
||||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||||
HealthPage 1.0 HealthPage.qml
|
HealthPage 1.0 HealthPage.qml
|
||||||
HealthSummary 1.0 HealthSummary.qml
|
HealthSummary 1.0 HealthSummary.qml
|
||||||
HealthCheckRow 1.0 HealthCheckRow.qml
|
HealthCheckRow 1.0 HealthCheckRow.qml
|
||||||
|
SegmentRow 1.0 SegmentRow.qml
|
||||||
SettingRow 1.0 SettingRow.qml
|
SettingRow 1.0 SettingRow.qml
|
||||||
SettingsCard 1.0 SettingsCard.qml
|
SettingsCard 1.0 SettingsCard.qml
|
||||||
SettingsButton 1.0 SettingsButton.qml
|
SettingsButton 1.0 SettingsButton.qml
|
||||||
@@ -20,9 +31,13 @@ SettingsShell 1.0 SettingsShell.qml
|
|||||||
SettingsSidebar 1.0 SettingsSidebar.qml
|
SettingsSidebar 1.0 SettingsSidebar.qml
|
||||||
SettingsToggle 1.0 SettingsToggle.qml
|
SettingsToggle 1.0 SettingsToggle.qml
|
||||||
SettingsWindow 1.0 SettingsWindow.qml
|
SettingsWindow 1.0 SettingsWindow.qml
|
||||||
|
SharingPage 1.0 SharingPage.qml
|
||||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||||
|
SnapshotsPage 1.0 SnapshotsPage.qml
|
||||||
SoundPage 1.0 SoundPage.qml
|
SoundPage 1.0 SoundPage.qml
|
||||||
SettingsPage 1.0 SettingsPage.qml
|
SettingsPage 1.0 SettingsPage.qml
|
||||||
|
StoragePage 1.0 StoragePage.qml
|
||||||
|
SwitchRow 1.0 SwitchRow.qml
|
||||||
ToggleRow 1.0 ToggleRow.qml
|
ToggleRow 1.0 ToggleRow.qml
|
||||||
SliderRow 1.0 SliderRow.qml
|
SliderRow 1.0 SliderRow.qml
|
||||||
ChoiceRow 1.0 ChoiceRow.qml
|
ChoiceRow 1.0 ChoiceRow.qml
|
||||||
@@ -33,6 +48,8 @@ LockScreenPreview 1.0 LockScreenPreview.qml
|
|||||||
PowerPage 1.0 PowerPage.qml
|
PowerPage 1.0 PowerPage.qml
|
||||||
DateTimePage 1.0 DateTimePage.qml
|
DateTimePage 1.0 DateTimePage.qml
|
||||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||||
|
UpdatesPage 1.0 UpdatesPage.qml
|
||||||
|
UsersPage 1.0 UsersPage.qml
|
||||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||||
WallpaperControls 1.0 WallpaperControls.qml
|
WallpaperControls 1.0 WallpaperControls.qml
|
||||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||||
@@ -62,3 +79,4 @@ RegionPage 1.0 RegionPage.qml
|
|||||||
SearchPicker 1.0 SearchPicker.qml
|
SearchPicker 1.0 SearchPicker.qml
|
||||||
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||||
AccentPicker 1.0 AccentPicker.qml
|
AccentPicker 1.0 AccentPicker.qml
|
||||||
|
TextFieldRow 1.0 TextFieldRow.qml
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ PanelWindow {
|
|||||||
|
|
||||||
screen: root.modelData
|
screen: root.modelData
|
||||||
anchors.top: true
|
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
|
exclusiveZone: 0
|
||||||
implicitWidth: root.showingEvent ? 388 : 438
|
implicitWidth: root.showingEvent ? 388 : 438
|
||||||
implicitHeight: root.showingEvent ? 76 : 132
|
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)"
|
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||||
emit "Swap" "$swap"
|
emit "Swap" "$swap"
|
||||||
|
|
||||||
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
|
# Decimal units with explicit GB/TB labels, matching the Storage page and the
|
||||||
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
|
# 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[*]}")"
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ def describe(obj):
|
|||||||
"path": obj.get_object_path(),
|
"path": obj.get_object_path(),
|
||||||
"provider": account.props.provider_type,
|
"provider": account.props.provider_type,
|
||||||
"providerName": account.props.provider_name,
|
"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
|
# PresentationIdentity is the human one (an email address); Identity is
|
||||||
# the internal handle and is not always readable.
|
# the internal handle and is not always readable.
|
||||||
"identity": account.props.presentation_identity or account.props.identity,
|
"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):
|
def find(client, path):
|
||||||
for obj in client.get_accounts():
|
for obj in client.get_accounts():
|
||||||
if obj.get_object_path() == path:
|
if obj.get_object_path() == path:
|
||||||
|
|||||||
@@ -109,6 +109,19 @@ case "$action" in
|
|||||||
clipboard) qs ipc call clipboard open ;;
|
clipboard) qs ipc call clipboard open ;;
|
||||||
overview) qs ipc call overview open ;;
|
overview) qs ipc call overview open ;;
|
||||||
settings) qs ipc call settings 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 ;;
|
health) qs ipc call health open ;;
|
||||||
|
|
||||||
dnd)
|
dnd)
|
||||||
@@ -149,7 +162,7 @@ case "$action" in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
printf 'Usage: panama-action {%s}\n' \
|
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
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
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
|
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 = {
|
ROLE_TARGETS = {
|
||||||
"browser": ("settings", "default-web-browser"),
|
"browser": ("settings", ["default-web-browser"]),
|
||||||
"mail": ("mime", "x-scheme-handler/mailto"),
|
"mail": ("mime", ["x-scheme-handler/mailto"]),
|
||||||
"files": ("mime", "inode/directory"),
|
"files": ("mime", ["inode/directory"]),
|
||||||
"terminal": ("mime", "x-scheme-handler/terminal"),
|
"terminal": ("mime", ["x-scheme-handler/terminal"]),
|
||||||
"music": ("mime", "audio/mpeg"),
|
"music": ("mime", [
|
||||||
"images": ("mime", "image/png"),
|
"audio/mpeg", "audio/flac", "audio/x-vorbis+ogg", "audio/ogg",
|
||||||
"video": ("mime", "video/mp4"),
|
"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$")
|
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
|
||||||
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
|
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]:
|
def query_handlers() -> dict[str, str]:
|
||||||
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 = (
|
command = (
|
||||||
["xdg-settings", "get", target]
|
["xdg-settings", "get", target]
|
||||||
if kind == "settings"
|
if kind == "settings"
|
||||||
@@ -179,13 +213,85 @@ def set_default(role: str, desktop_id: str) -> None:
|
|||||||
if target is None:
|
if target is None:
|
||||||
raise BoundaryError("That default application role is not supported.")
|
raise BoundaryError("That default application role is not supported.")
|
||||||
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
|
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
|
||||||
kind, setting = target
|
kind, settings = target
|
||||||
command = (
|
if kind == "settings":
|
||||||
["xdg-settings", "set", setting, desktop_id]
|
run(["xdg-settings", "set", settings[0], desktop_id])
|
||||||
if kind == "settings"
|
return
|
||||||
else ["xdg-mime", "default", desktop_id, setting]
|
# 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
|
||||||
run(command)
|
# 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:
|
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)
|
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:
|
def add_autostart(desktop_id: str) -> None:
|
||||||
desktop_files = discovered_desktop_files()
|
desktop_files = discovered_desktop_files()
|
||||||
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
require_desktop_id(desktop_id, discovered=set(desktop_files))
|
||||||
@@ -294,15 +432,19 @@ def main(arguments: list[str]) -> int:
|
|||||||
try:
|
try:
|
||||||
if arguments == ["snapshot"]:
|
if arguments == ["snapshot"]:
|
||||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||||
|
elif arguments == ["seed"]:
|
||||||
|
seed()
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-default":
|
elif len(arguments) == 3 and arguments[0] == "set-default":
|
||||||
set_default(arguments[1], arguments[2])
|
set_default(arguments[1], arguments[2])
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
elif len(arguments) == 3 and arguments[0] == "set-autostart":
|
||||||
set_autostart(arguments[1], arguments[2])
|
set_autostart(arguments[1], arguments[2])
|
||||||
|
elif len(arguments) == 2 and arguments[0] == "remove-autostart":
|
||||||
|
remove_autostart(arguments[1])
|
||||||
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
elif len(arguments) == 2 and arguments[0] == "add-autostart":
|
||||||
add_autostart(arguments[1])
|
add_autostart(arguments[1])
|
||||||
else:
|
else:
|
||||||
raise BoundaryError(
|
raise BoundaryError(
|
||||||
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
"Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
|
||||||
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
|
||||||
)
|
)
|
||||||
except BoundaryError as error:
|
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 secrets
|
||||||
import signal
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -115,7 +116,7 @@ CHECK_ORDER = (
|
|||||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
||||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
"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",
|
"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.")
|
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 check_runtime_links(config: DoctorConfig) -> Check:
|
||||||
def valid_link(name: str, relative_source: Path) -> bool:
|
def valid_link(name: str, relative_source: Path) -> bool:
|
||||||
destination = config.config_home / name
|
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),
|
"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),
|
"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),
|
"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:
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
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 json
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable
|
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]:
|
def parse_loaded_plugins(output: str) -> list[str]:
|
||||||
try:
|
data = parse_property(output, "as")
|
||||||
parts = shlex.split(output)
|
if not isinstance(data, list):
|
||||||
except ValueError:
|
|
||||||
return []
|
return []
|
||||||
if len(parts) < 2 or parts[0] != "as":
|
return [str(item) for item in data]
|
||||||
return []
|
|
||||||
try:
|
|
||||||
count = int(parts[1])
|
|
||||||
except ValueError:
|
|
||||||
return []
|
|
||||||
return parts[2 : 2 + max(0, count)]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_string_property(output: str) -> str:
|
def parse_string_property(output: str) -> str:
|
||||||
try:
|
data = parse_property(output, "s")
|
||||||
parts = shlex.split(output)
|
return data if isinstance(data, str) else ""
|
||||||
except ValueError:
|
|
||||||
return ""
|
|
||||||
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bool_property(output: str) -> bool | None:
|
def parse_bool_property(output: str) -> bool | None:
|
||||||
parts = output.split()
|
data = parse_property(output, "b")
|
||||||
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
|
return data if isinstance(data, bool) else None
|
||||||
return None
|
|
||||||
return parts[1] == "true"
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(
|
def run_command(
|
||||||
@@ -141,6 +151,7 @@ def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]
|
|||||||
[
|
[
|
||||||
"busctl",
|
"busctl",
|
||||||
"--user",
|
"--user",
|
||||||
|
"--json=short",
|
||||||
"call",
|
"call",
|
||||||
"org.kde.kdeconnect",
|
"org.kde.kdeconnect",
|
||||||
device_object(device_id),
|
device_object(device_id),
|
||||||
@@ -157,6 +168,7 @@ def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[s
|
|||||||
[
|
[
|
||||||
"busctl",
|
"busctl",
|
||||||
"--user",
|
"--user",
|
||||||
|
"--json=short",
|
||||||
"get-property",
|
"get-property",
|
||||||
"org.kde.kdeconnect",
|
"org.kde.kdeconnect",
|
||||||
device_object(device_id),
|
device_object(device_id),
|
||||||
@@ -178,6 +190,7 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
|
|||||||
[
|
[
|
||||||
"busctl",
|
"busctl",
|
||||||
"--user",
|
"--user",
|
||||||
|
"--json=short",
|
||||||
"get-property",
|
"get-property",
|
||||||
"org.kde.kdeconnect",
|
"org.kde.kdeconnect",
|
||||||
device_object(device_id),
|
device_object(device_id),
|
||||||
@@ -198,6 +211,7 @@ def device_property(
|
|||||||
[
|
[
|
||||||
"busctl",
|
"busctl",
|
||||||
"--user",
|
"--user",
|
||||||
|
"--json=short",
|
||||||
"get-property",
|
"get-property",
|
||||||
"org.kde.kdeconnect",
|
"org.kde.kdeconnect",
|
||||||
device_object(device_id),
|
device_object(device_id),
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ Usage:
|
|||||||
panama-keyring unlock -> raises the password prompt; prints the new state
|
panama-keyring unlock -> raises the password prompt; prints the new state
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
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():
|
def main():
|
||||||
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||||
if action not in ("status", "unlock"):
|
target = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
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
|
return 2
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -165,6 +284,27 @@ def main():
|
|||||||
}))
|
}))
|
||||||
return 0
|
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":
|
if action == "unlock":
|
||||||
login = next(
|
login = next(
|
||||||
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
(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
|
fi
|
||||||
done
|
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"
|
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
||||||
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||||
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||||
@@ -250,4 +290,5 @@ jq -cn \
|
|||||||
--arg btop "$status_btop" \
|
--arg btop "$status_btop" \
|
||||||
--arg tmux "$status_tmux" \
|
--arg tmux "$status_tmux" \
|
||||||
--arg hyprlock "$status_hyprlock" \
|
--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} } } })`]);
|
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
|
||||||
|
|
||||||
// Applications that predate org.freedesktop.appearance and carry
|
// Applications that predate org.freedesktop.appearance and carry
|
||||||
// their own palettes -- terminals, chiefly. Everything that reads
|
// their own palettes -- terminals, chiefly. Portal readers (GTK4,
|
||||||
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
|
// Qt6, Chromium, Electron) already have their SCHEME from the
|
||||||
// the gsettings write above and needs nothing here. The accent is
|
// gsettings write above.
|
||||||
// passed through too: kitty's border and the hyprlock fallback
|
//
|
||||||
// template are also the accent role, not just the scheme -- so
|
// Their ACCENT does not come from here: GNOME's accent-color is a
|
||||||
// this still has to run on an accent-only change, even though the
|
// fixed enum of nine names rather than a color, so the mapping from
|
||||||
// scheme-relative work it also does is then redundant.
|
// 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]);
|
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,40 @@ Singleton {
|
|||||||
return fallback;
|
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 var adapter: Bluetooth.defaultAdapter
|
||||||
|
|
||||||
readonly property bool wifiEnabled: Networking.wifiEnabled
|
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 var luaAutostartEntries: []
|
||||||
property string lastError: ""
|
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 bool busy: snapshotProcess.running || mutationProcess.running
|
||||||
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
|
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 {
|
Process {
|
||||||
id: snapshotProcess
|
id: snapshotProcess
|
||||||
@@ -59,7 +78,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refresh(): void {
|
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;
|
return;
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
snapshotProcess.exec([root.helper, "snapshot"]);
|
snapshotProcess.exec([root.helper, "snapshot"]);
|
||||||
@@ -76,9 +97,11 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDefault(role: string, desktopId: string): void {
|
function setDefault(role: string, desktopId: string): void {
|
||||||
if (root.busy)
|
if (mutationProcess.running)
|
||||||
return;
|
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."
|
root.lastError = "Choose an application from the available list."
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,7 +110,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setAutostart(desktopId: string, enabled: bool): void {
|
function setAutostart(desktopId: string, enabled: bool): void {
|
||||||
if (root.busy)
|
if (mutationProcess.running)
|
||||||
return;
|
return;
|
||||||
const known = root.autostartEntries.some(entry => entry.id === desktopId);
|
const known = root.autostartEntries.some(entry => entry.id === desktopId);
|
||||||
if (!known) {
|
if (!known) {
|
||||||
@@ -98,8 +121,22 @@ Singleton {
|
|||||||
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
|
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 {
|
function addAutostart(desktopId: string): void {
|
||||||
if (root.busy)
|
if (mutationProcess.running)
|
||||||
return;
|
return;
|
||||||
if (!root.knownDesktopId(desktopId)) {
|
if (!root.knownDesktopId(desktopId)) {
|
||||||
root.lastError = "Choose an installed application.";
|
root.lastError = "Choose an installed application.";
|
||||||
|
|||||||
@@ -208,7 +208,12 @@ Singleton {
|
|||||||
DesktopPreferences.get("middleClickPaste")),
|
DesktopPreferences.get("middleClickPaste")),
|
||||||
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||||
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
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,
|
// 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.
|
// 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 {
|
function groupFor(description: string, bind: var): string {
|
||||||
const text = description.toLowerCase();
|
const text = description.toLowerCase();
|
||||||
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
||||||
return "Media & hardware keys";
|
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)
|
if (text.indexOf("workspace") >= 0)
|
||||||
return "Workspaces";
|
return "Workspaces";
|
||||||
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|
|
||||||
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|
if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
||||||
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
|
||||||
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
||||||
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|
|| text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0
|
||||||
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|
|| text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0)
|
||||||
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
|
return "Size";
|
||||||
return "Windows";
|
|
||||||
|
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
|
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|
||||||
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|
||||||
|| text.indexOf("brightness") >= 0)
|
|| text.indexOf("brightness") >= 0)
|
||||||
return "Media & hardware keys";
|
return "Media & hardware keys";
|
||||||
|
|
||||||
return "Applications & shell";
|
return "Applications & shell";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section order for the page. Anything a future bind invents lands at the
|
// Section order for the page. Anything a future bind invents lands at the
|
||||||
// end rather than being dropped.
|
// 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 {
|
function grouped(): var {
|
||||||
const buckets = {};
|
const buckets = {};
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ Singleton {
|
|||||||
|
|
||||||
readonly property bool replacementDaemon: root.daemon === "dbus"
|
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 {
|
function refresh(): void {
|
||||||
if (!query.running)
|
if (!query.running)
|
||||||
query.running = true;
|
query.running = true;
|
||||||
@@ -53,6 +72,54 @@ Singleton {
|
|||||||
unlockProcess.running = true;
|
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 {
|
function absorb(text: string): void {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
@@ -74,6 +141,27 @@ Singleton {
|
|||||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
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 {
|
Process {
|
||||||
id: unlockProcess
|
id: unlockProcess
|
||||||
command: [root.helperPath, "unlock"]
|
command: [root.helperPath, "unlock"]
|
||||||
|
|||||||
@@ -234,7 +234,10 @@ Singleton {
|
|||||||
root.unreadCount += 1;
|
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);
|
root.popups = [notification].concat(root.popups);
|
||||||
} else if (notification.transient) {
|
} else if (notification.transient) {
|
||||||
// Never shown, and (being transient) never filed in history
|
// Never shown, and (being transient) never filed in history
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ Singleton {
|
|||||||
|
|
||||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
|
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 var accounts: []
|
||||||
property bool scanned: false
|
property bool scanned: false
|
||||||
property bool busy: 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