Merge remote-tracking branch 'origin/main' into feat/panama-health

# Conflicts:
#	config/dot/quickshell/modules/settings/ServicesPage.qml
#	config/dot/quickshell/modules/settings/SettingsShell.qml
#	config/dot/quickshell/modules/settings/SettingsSidebar.qml
This commit is contained in:
Gabriel Brown
2026-08-18 11:23:07 -04:00
87 changed files with 4557 additions and 184 deletions
+5
View File
@@ -16,3 +16,8 @@ __pycache__/
# Generated from the colour scheme; machine state, not configuration. # Generated from the colour scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf /config/dot/kitty/current-theme.conf
# Generated from the colour scheme; machine state, not configuration.
/config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
+1 -1
View File
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
| Piece | What it is | | Piece | What it is |
|---|---| |---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI | | `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed | | `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
@@ -0,0 +1,57 @@
# Tokyo Night Day for btop, the light counterpart to tokyonight-moon.
#
# btop ships no Tokyo Night light variant at all, and the nearest stock light
# theme (flat-remix-light) is a different palette that happens to have a similar
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree.
#
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
# Day's darker, more saturated accents: the light versions of these hues are too
# faint to read as a filled meter.
theme[main_bg]="#e1e2e7"
theme[main_fg]="#3760bf"
theme[title]="#3760bf"
theme[hi_fg]="#2e7de9"
theme[selected_bg]="#c4c8da"
theme[selected_fg]="#2e7de9"
theme[inactive_fg]="#7079a8"
theme[proc_misc]="#587539"
theme[cpu_box]="#a8aecb"
theme[mem_box]="#a8aecb"
theme[net_box]="#a8aecb"
theme[proc_box]="#a8aecb"
theme[div_line]="#c4c8da"
theme[temp_start]="#2e7de9"
theme[temp_mid]="#8c6c3e"
theme[temp_end]="#f52a65"
theme[cpu_start]="#2e7de9"
theme[cpu_mid]="#9854f1"
theme[cpu_end]="#f52a65"
theme[free_start]="#c4c8da"
theme[free_mid]="#007197"
theme[free_end]="#2e7de9"
theme[cached_start]="#007197"
theme[cached_mid]="#2e7de9"
theme[cached_end]="#9854f1"
theme[available_start]="#8c6c3e"
theme[available_mid]="#b15c00"
theme[available_end]="#f52a65"
theme[used_start]="#587539"
theme[used_mid]="#8c6c3e"
theme[used_end]="#f52a65"
theme[download_start]="#c4c8da"
theme[download_mid]="#2e7de9"
theme[download_end]="#007197"
theme[upload_start]="#c4c8da"
theme[upload_mid]="#9854f1"
theme[upload_end]="#7847bd"
@@ -0,0 +1,56 @@
# Tokyo Night Moon for btop, matched to Panama's palette.
#
# btop ships a "tokyo-night" theme, but it is the Night variant (#1a1b26). The
# rest of this desktop is Moon (#222436), and two Tokyo Nights side by side read
# as a mistake rather than as a choice.
#
# The *_start/_mid/_end triples are gradients btop draws meters with: low,
# middle, and high. They run blue -> yellow -> red so a saturated resource is
# obvious at a glance without reading the number.
theme[main_bg]="#222436"
theme[main_fg]="#c8d3f5"
theme[title]="#c8d3f5"
theme[hi_fg]="#82aaff"
theme[selected_bg]="#3b4261"
theme[selected_fg]="#82aaff"
theme[inactive_fg]="#636da6"
theme[proc_misc]="#a5e8b5"
theme[cpu_box]="#4d5685"
theme[mem_box]="#4d5685"
theme[net_box]="#4d5685"
theme[proc_box]="#4d5685"
theme[div_line]="#3b4261"
theme[temp_start]="#82aaff"
theme[temp_mid]="#ffc777"
theme[temp_end]="#ff757f"
theme[cpu_start]="#82aaff"
theme[cpu_mid]="#c099ff"
theme[cpu_end]="#ff757f"
theme[free_start]="#3b4261"
theme[free_mid]="#589ed7"
theme[free_end]="#86e1fc"
theme[cached_start]="#86e1fc"
theme[cached_mid]="#82aaff"
theme[cached_end]="#c099ff"
theme[available_start]="#ffc777"
theme[available_mid]="#ff966c"
theme[available_end]="#ff757f"
theme[used_start]="#a5e8b5"
theme[used_mid]="#ffc777"
theme[used_end]="#ff757f"
theme[download_start]="#3b4261"
theme[download_mid]="#82aaff"
theme[download_end]="#86e1fc"
theme[upload_start]="#3b4261"
theme[upload_mid]="#c099ff"
theme[upload_end]="#fca7ea"
@@ -1,3 +1,12 @@
# GENERATED FILE -- edit settings.ini.template instead.
#
# The theme name and dark preference below follow Panama's colour
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# settings daemon, so for GTK3 it is authoritative -- which is why it
# has to change with the scheme rather than being pinned to dark.
#
[Settings] [Settings]
# These values must match `gsettings get org.gnome.desktop.interface ...`. # These values must match `gsettings get org.gnome.desktop.interface ...`.
# #
@@ -6,12 +15,12 @@
# this file becomes authoritative for GTK3 — which is why it previously named # this file becomes authoritative for GTK3 — which is why it previously named
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything # themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
# appearing broken. # appearing broken.
gtk-theme-name=adw-gtk3-dark gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11 gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1 gtk-application-prefer-dark-theme=@PREFER_DARK@
gtk-toolbar-style=GTK_TOOLBAR_ICONS gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
-11
View File
@@ -1,11 +0,0 @@
[Settings]
# libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=1
gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+20
View File
@@ -0,0 +1,20 @@
# GENERATED FILE -- edit settings.ini.template instead.
#
# The theme name and dark preference below follow Panama's colour
# scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no
# settings daemon, so for GTK3 it is authoritative -- which is why it
# has to change with the scheme rather than being pinned to dark.
#
[Settings]
# libadwaita apps normally take their dark preference from the
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
# window before the portal answers.
gtk-application-prefer-dark-theme=@PREFER_DARK@
gtk-theme-name=@GTK_THEME@
gtk-icon-theme-name=Adwaita
gtk-font-name=Adwaita Sans 11
gtk-cursor-theme-name=oreo_blue_cursors
gtk-cursor-theme-size=24
+1 -1
View File
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live | | Removable media | udiskie plus udisks notifications | Live |
| 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 | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | | System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
## GNOME extension migration ## GNOME extension migration
+8 -8
View File
@@ -27,7 +27,7 @@ Don't "fix" them.
| File | Contents | | File | Contents |
|---|---| |---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope | | `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below | | `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below | | `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | | `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
@@ -59,7 +59,7 @@ generated elsewhere:
`quickshell/scripts/panama-idle` regenerates the hypridle config from the `quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed; remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting The Settings app shows which of the two states you are in rather than presenting
controls that quietly do nothing. controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with: Remove the drop-in and go back to the shipped config with:
@@ -77,7 +77,7 @@ relationship is:
`prefs.get("key", <shipped value>)`, so the config still works standalone with `prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all. no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it. - **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change - **The Settings app is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart. live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to To add an adjustable setting: add an entry to
@@ -95,7 +95,7 @@ accepts the config in each of those states.
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file. the Settings app without editing this file.
```lua ```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row. Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so `tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently. this cannot regress silently.
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
| `SUPER + V` | Clipboard history | | `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker | | `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings | | `SUPER + S` | Quick settings |
| `SUPER + I` | Panama Settings | | `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session | | `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre | | `SUPER + B` | Notification centre |
| `SUPER + grave` | Workspace overview | | `SUPER + grave` | Workspace overview |
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
### Apps ### Apps
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator · `SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME `E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels. Settings remains searchable in Vicinae for hardware and account panels.
## Calendar and notifications ## Calendar and notifications
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
dashboard windows. dashboard windows.
Home shows the first four selected favourites at rest and every selected light Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites, when expanded. Use **Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous power toggle leaves Home Assistant responsible for restoring its previous
+10 -2
View File
@@ -21,10 +21,18 @@ hl.config({
repeat_delay = prefs.get("keyRepeatDelay", 500), repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33), repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus. -- 1 = FOLLOW. The window under the pointer takes focus. This comment
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
-- so click-to-focus is 0. Changing the shipped value is a behaviour
-- decision rather than a correction, so the value is left alone and
-- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1), follow_mouse = prefs.getInt("followMouse", 1),
-- Don't refocus on mouse move alone -- only on click. -- Softens follow_mouse: with this off, focus changes only when the
-- pointer crosses a window boundary, not on every movement inside one.
-- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false, mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming. -- Flat pointer response, no acceleration. Matters for gaming.
+1 -1
View File
@@ -80,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" }) bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ──────────────────────────────────────────────────────────────── -- ── Launcher ────────────────────────────────────────────────────────────────
@@ -98,7 +98,26 @@ Singleton {
// back to shipped defaults and let the next write replace it. // back to shipped defaults and let the next write replace it.
parsed = {}; parsed = {};
} }
root.values = (parsed && typeof parsed === "object") ? parsed : {}; const raw = (parsed && typeof parsed === "object") ? parsed : {};
// Upgrade before anything reads a value. A stored key the current
// schema no longer recognises is carried through untouched and silently
// stops taking effect, so the conversion has to happen here rather than
// being noticed later by whoever owns that setting.
const result = Migrations.apply(raw);
root.values = result.values;
if (result.migrated)
console.info("Settings migrated from version", result.from, "to", result.to + ":",
result.applied.join("; "));
// Write whenever the version moved, which includes stamping a file
// written before versioning existed. Left unwritten, the stamp lives
// only in memory and is redone on every launch, and a migration that is
// not idempotent would compound.
if (result.changed)
persistTimer.restart();
root.revision++; root.revision++;
root.loaded = true; root.loaded = true;
} }
+121
View File
@@ -0,0 +1,121 @@
pragma Singleton
// Versioned upgrades for the settings file.
//
// The schema is the single source of truth for what a setting IS, but it cannot
// describe what a setting USED to be. Renaming a key, changing its units, or
// splitting one setting into two all leave a stored value that the new schema
// does not recognise -- and an unrecognised key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent.
//
// HOW IT WORKS
//
// settings.json carries a schemaVersion. On load, every migration with a
// version ABOVE the stored one runs in order, then the file is stamped with
// `current`. A file with no schemaVersion at all is a file written before this
// existed; it is stamped at `baseline` and NOT migrated, because those
// migrations were never written for it.
//
// WRITING ONE
//
// { version: 2, describe: "rename dockDelay to dockHideDelayMs",
// migrate: values => { ... return values; } }
//
// Rules that make this safe to run against a real user's file:
//
// * migrate() receives the whole values object and returns it. Mutating and
// returning the same object is fine.
// * NEVER delete a key you are not replacing. Unknown keys are deliberately
// preserved so that rolling back to an older Panama does not discard a
// newer version's settings, and a migration is the one place that promise
// could quietly be broken.
// * A migration must tolerate its input being absent or the wrong type. It
// runs against files written by every previous version, including ones
// that were hand-edited.
// * Migrations never run twice: the stored version only moves forward.
import QtQuick
QtObject {
id: root
// What a file written today is stamped with. Bump this when adding a
// migration, to the version of the migration you added.
readonly property int current: 1
// Files predating versioning are stamped here without being migrated.
readonly property int baseline: 1
readonly property string versionKey: "schemaVersion"
// Ordered by version. Empty is the correct state until the first breaking
// schema change -- this exists so that change is a routine edit rather than
// an emergency.
readonly property var steps: []
function storedVersion(values: var): int {
const raw = values ? values[root.versionKey] : undefined;
return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0;
}
// Returns { values, migrated, changed, from, to, applied }.
//
// `applied` names each step that ran, so the caller can log something
// meaningful rather than "settings changed somehow". `changed` is the one
// the caller should write on: stamping a pre-versioning file changes it
// without running any step, and left unwritten the stamp would live only in
// memory and be redone on every launch.
function apply(values: var): var {
return root.applyWith(values, root.steps, root.current, root.baseline);
}
// The same logic with the step list injected, so the machinery can be
// tested against fixture migrations. The real list is empty until the first
// breaking schema change, and a mechanism that has never run against a
// failing step is not one to find out about during an upgrade.
function applyWith(values: var, steps: var, current: int, baseline: int): var {
const safe = (values && typeof values === "object") ? values : {};
const from = root.storedVersion(safe);
// No version: written before versioning existed. Stamp it and stop.
// Running the migration list against it would apply upgrades designed
// for schemas this file never had.
if (from === 0) {
safe[root.versionKey] = baseline;
return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] };
}
// A file from a NEWER Panama. Left completely alone: downgrading its
// keys is not something this can do correctly, and unknown keys are
// already preserved, so the older build simply ignores what it does not
// understand.
if (from > current)
return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] };
const applied = [];
let working = safe;
for (const step of steps) {
if (step.version <= from || step.version > current)
continue;
try {
const result = step.migrate(working);
if (result && typeof result === "object")
working = result;
applied.push(step.version + ": " + step.describe);
} catch (error) {
// One bad migration must not cost the user every setting. Stop
// at the last good version so the next launch retries from
// here rather than skipping the failed step forever.
console.warn("Migrations: step", step.version, "failed:", error);
working[root.versionKey] = step.version - 1;
return { values: working, migrated: applied.length > 0, changed: applied.length > 0,
from: from, to: step.version - 1, applied: applied };
}
}
working[root.versionKey] = current;
return { values: working, migrated: applied.length > 0, changed: from !== current,
from: from, to: current, applied: applied };
}
}
+256 -10
View File
@@ -128,10 +128,21 @@ Singleton {
{ {
key: "vrrPolicy", type: "enum", def: 3, group: "display", key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate", label: "Variable refresh rate",
detail: "Content-aware matches the display to what is on screen", detail: "Matches the display's refresh rate to what is on screen",
// All four the compositor publishes, rather than the two that were
// here. Always-on VRR is a legitimate choice on a panel that
// handles it well, and it was simply unreachable -- as was
// fullscreen-only, which is what someone wanting VRR for video
// rather than games wants.
options: [ options: [
{ value: 0, label: "Off" }, { value: 0, label: "Off",
{ value: 3, label: "Content-aware" } detail: "The display runs at a fixed refresh rate" },
{ value: 1, label: "Always on",
detail: "Best on panels that handle low refresh rates without flicker" },
{ value: 2, label: "Fullscreen only",
detail: "Any fullscreen window, including video" },
{ value: 3, label: "Fullscreen games",
detail: "Only fullscreen games, which is the safest default" }
], ],
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" } hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
}, },
@@ -140,8 +151,12 @@ Singleton {
label: "Direct scanout", label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing", detail: "Lets fullscreen content bypass compositing",
options: [ options: [
{ value: 0, label: "Off" }, { value: 0, label: "Off",
{ value: 2, label: "Automatic" } detail: "Everything goes through the compositor" },
{ value: 1, label: "Always on",
detail: "Forced rather than decided per surface; can drop frames on some drivers" },
{ value: 2, label: "Automatic",
detail: "The compositor decides per surface, which is the safe default" }
], ],
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" } hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
}, },
@@ -257,6 +272,24 @@ Singleton {
detail: "XKB layout name, or a comma-separated list to switch between", detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" } hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
}, },
{
key: "keyboardVariant", type: "string", def: "", group: "input",
// Same shape as the layout list and for the same reason: this is
// serialised into an hl.config string.
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
label: "Layout variant",
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
},
{
key: "keyboardOptions", type: "string", def: "", group: "input",
// XKB option names are colon-separated pairs in a comma-separated
// list, e.g. "compose:ralt,caps:escape".
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
label: "Keyboard options",
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
},
{ {
key: "numlockByDefault", type: "bool", def: true, group: "input", key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login", label: "Num Lock on login",
@@ -281,12 +314,28 @@ Singleton {
}, },
{ {
key: "followMouse", type: "enum", def: 1, group: "input", key: "followMouse", type: "enum", def: 1, group: "input",
label: "Focus follows pointer", label: "Pointer focus",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer", detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was
// shown as "Click to focus" while Hyprland's 1 means the opposite.
// The compositor publishes the authoritative mapping itself --
// `hyprctl descriptions` gives
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
// -- so a desktop labelled "Click to focus" was in fact following
// the pointer, and the way to actually get click-to-focus was to
// choose "Never". Value 3 was missing entirely.
//
// enum-hypr-map-contract now pins every mapped enum against that
// published map, so this cannot drift again.
options: [ options: [
{ value: 0, label: "Never" }, { value: 0, label: "Click to focus",
{ value: 1, label: "Click to focus" }, detail: "Moving the pointer never changes focus" },
{ value: 2, label: "Sloppy focus" } { value: 1, label: "Focus follows pointer",
detail: "The window under the pointer takes focus as you move" },
{ value: 2, label: "Pointer detached",
detail: "The pointer highlights windows on its own; clicking moves keyboard focus" },
{ value: 3, label: "Pointer fully separate",
detail: "Clicking does not move keyboard focus at all" }
], ],
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" } hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
}, },
@@ -310,6 +359,203 @@ Singleton {
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" } hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
}, },
// ── Pointer ─────────────────────────────────────────────────────────
//
// Every `readAs` below was read back off the running compositor rather
// than guessed. Two are not what they look like: touchpad drag lock is
// an int with three states, not a switch, and scroll factors are floats
// even at their default of exactly 1.
{
key: "naturalScroll", type: "bool", def: false, group: "pointer",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers, as on a phone",
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
},
{
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
label: "Acceleration",
detail: "Flat moves the pointer the same distance however fast you move",
options: [
{ value: "flat", label: "Flat" },
{ value: "adaptive", label: "Adaptive" }
],
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
},
{
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "pointer",
label: "Scroll speed",
detail: "Multiplies how far one notch of the wheel scrolls",
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
},
{
key: "leftHanded", type: "bool", def: false, group: "pointer",
label: "Left-handed",
detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
},
// ── Touchpad ────────────────────────────────────────────────────────
//
// Shown only on machines that have one. These are separate from the
// pointer settings above because libinput keeps them separate: a mouse
// and a touchpad on the same machine can scroll in opposite directions,
// and usually should.
{
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
label: "Tap to click",
detail: "A tap counts as a click without pressing down",
// The Lua config key and the hyprctl option name genuinely differ
// here: hl.config wants input.touchpad.tap_to_click, getoption
// answers to input:touchpad:tap-to-click. Using either spelling for
// both fails -- a hyphen is not a Lua identifier, and the
// underscored name is not a known option to getoption. This is what
// the two separate fields are for.
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
},
{
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
label: "Natural scrolling",
detail: "Content follows the direction of your fingers",
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
},
{
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
label: "Disable while typing",
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
},
{
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
group: "touchpad",
label: "Scroll speed",
detail: "Multiplies how far a two-finger scroll travels",
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
},
{
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
label: "Drag lock",
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
// An int with three states rather than a switch, which is why this
// is an enum: reported as `int` by getoption, not `bool`.
options: [
{ value: 0, label: "Off" },
{ value: 1, label: "On" },
{ value: 2, label: "On, until you tap again" }
],
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
},
{
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
label: "Middle-click by pressing both buttons",
detail: "Pressing left and right together acts as a middle click",
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
// described the first two of these as read-only facts ("Layout:
// Tiling"), which was never true -- they are ordinary settings that
// happened not to have controls.
//
// Defaults here are Panama's shipped values from hypr/looks.lua, not
// Hyprland's own, so restoring defaults returns the desktop to how it
// ships rather than to how Hyprland would behave with no config.
{
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
label: "Tiling layout",
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
options: [
{ value: "dwindle", label: "Dwindle" },
{ value: "master", label: "Master and stack" }
],
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
},
{
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
label: "Keep split direction",
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
},
{
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
label: "New windows open",
detail: "Where a new window lands relative to the one that was focused",
options: [
{ value: 0, label: "Where the pointer is" },
{ value: 1, label: "Always left or above" },
{ value: 2, label: "Always right or below" }
],
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
},
{
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
label: "Snap floating windows",
detail: "Floating windows stick to screen edges and to each other as you drag them",
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
},
{
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
label: "Switch back and forth",
detail: "Selecting the workspace you are already on returns you to the previous one",
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
},
{
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
label: "Wrap around at the ends",
detail: "Moving past the last workspace continues from the first",
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
},
{
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
label: "Let applications take focus",
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
detail: "Moving the pointer to another display makes it the active one",
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
},
// ── Accessibility ───────────────────────────────────────────────────
//
// Only what Hyprland can actually deliver. GNOME's sticky keys, slow
// keys, bounce keys and mouse keys are AccessX, an X11 server feature:
// XKB under Wayland has no accessx option group at all (verified
// against evdev.lst), and Hyprland does not implement it. The
// compositor will happily STORE "accessx:enable" as a keyboard option
// and nothing will ever act on it, which is exactly the kind of switch
// this app refuses to ship.
{
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
group: "accessibility",
label: "Magnifier",
detail: "Magnifies the screen around the pointer. 1.0 is off",
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
},
{
key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
label: "Magnifier follows in steps",
detail: "Moves the magnified view in increments rather than gliding with the pointer",
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
},
{
key: "dimInactive", type: "bool", def: false, group: "accessibility",
label: "Dim inactive windows",
detail: "Darkens every window except the focused one, so the active window is unmistakable",
hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
},
{
key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
group: "accessibility",
label: "Dim amount",
detail: "How much darker unfocused windows are",
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
},
// ── Night light ───────────────────────────────────────────────────── // ── Night light ─────────────────────────────────────────────────────
{ {
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
+1
View File
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml singleton Theme 1.0 Theme.qml
singleton Migrations 1.0 Migrations.qml
@@ -0,0 +1,56 @@
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
// case. Run by tests/quickshell/migrations-contract.sh.
//
// Fixture steps rather than the real list: the real one is empty until the
// first breaking schema change, and a mechanism that has never been run against
// a failing step is not one to discover the behaviour of during an upgrade.
import Quickshell
import QtQuick
import qs.config
ShellRoot {
Component.onCompleted: {
const steps = [
{ version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } },
{ version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } },
{ version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } }
];
const results = {};
// No version at all: a file written before versioning. Stamped, never
// migrated.
let r = Migrations.applyWith({ a: 1 }, steps, 3, 1);
results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated,
changed: r.changed, untouched: r.values.b === undefined };
// Older file: every step above its version runs, in order.
r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1);
results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
count: r.applied.length, migrated: r.migrated };
// Already current: nothing runs.
r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1);
results.current = { v: r.values.schemaVersion, migrated: r.migrated,
kept: r.values.keep === "me", b: r.values.b === undefined };
// From the future: left completely alone, including its unknown keys.
r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1);
results.future = { v: r.values.schemaVersion, migrated: r.migrated,
changed: r.changed, kept: r.values.futureKey === "x" };
// A failing step stops at the last good version rather than losing the
// file or skipping past the failure forever.
r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1);
results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c,
kept: r.values.a === 5, count: r.applied.length };
// Unknown keys survive a migration: rolling back to an older Panama
// must not discard a newer version's settings.
r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1);
results.preserved = { kept: r.values.unknownFromFuture === true };
console.info("PANAMA-MIGRATIONS " + JSON.stringify(results));
Qt.callLater(() => Qt.quit());
}
}
@@ -4,11 +4,20 @@ import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
title: "About Panama" id: root
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste." lede: "A curated Hyprland desktop built around focus, speed, and good taste."
Component.onCompleted: {
if (!MachineInfo.scanned)
MachineInfo.refresh();
if (GraphicsDevices.devices.length === 0)
GraphicsDevices.refresh();
}
SettingsCard { SettingsCard {
title: "Panama Desktop" title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling" subtitle: "Tokyo Night Moon · Prism glass · native tiling"
TextRow { TextRow {
@@ -31,6 +40,59 @@ SettingsPage {
} }
} }
// What GNOME's About panel answers and this page did not: what am I running
// on. Rows come from MachineInfo, except graphics, which is joined from
// GraphicsDevices rather than read a second time -- two readouts of the same
// hardware are two things that can disagree.
//
// The join splices the GPU rows in directly after Processor rather than
// appending them, so the card reads the way fastfetch does: what the system
// is, then what is installed on it, then the hardware underneath. A GPU
// listed after "Disk" reads as an afterthought.
readonly property var machineRows: {
const rows = (MachineInfo.facts ?? []).slice();
const gpus = GraphicsDevices.devices ?? [];
if (gpus.length === 0)
return rows;
const graphics = gpus.map((device, index) => ({
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
value: device.name
}));
const after = rows.findIndex(row => row.label === "Processor");
if (after < 0)
return rows.concat(graphics);
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
}
SettingsCard {
title: "This machine"
subtitle: "Hardware and system, as the kernel reports it."
Repeater {
model: root.machineRows
TextRow {
id: machineRow
required property var modelData
required property int index
label: machineRow.modelData.label
value: machineRow.modelData.value
divider: machineRow.index < root.machineRows.length - 1
}
}
TextRow {
visible: MachineInfo.scanned && root.machineRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
divider: false
}
}
SettingsCard { SettingsCard {
title: "Design principles" title: "Design principles"
@@ -25,32 +25,61 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Text" title: "Text"
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is 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"; divider: false }
} }
SettingsCard { SettingsCard {
title: "Motion" title: "Motion"
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing." subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false } ToggleRow { setting: "animationsEnabled"; divider: false }
} }
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
// no reason to send someone to another application for it.
SettingsCard { SettingsCard {
title: "Contrast" title: "Magnifier"
subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read." subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
SliderRow { setting: "inactiveOpacity"; divider: false } SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
ToggleRow { setting: "magnifierRigid"; divider: false }
} }
SettingsCard { SettingsCard {
title: "System accessibility" title: "Contrast"
subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack." subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
SliderRow { setting: "inactiveOpacity" }
ToggleRow { setting: "dimInactive" }
SliderRow { setting: "dimStrength"; divider: false }
}
// What this session genuinely cannot do, said plainly.
//
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
// all, and Hyprland does not implement one. The compositor will accept
// "accessx:enable" as a keyboard option and store it, and nothing will ever
// act on it -- so there is no switch here, and pointing at GNOME's panel
// would be no better, since the daemon that applies those keys is not
// running either.
SettingsCard {
title: "Keyboard accessibility"
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
ActionRow {
label: "Screen reader"
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
action: "Start Orca"
onTriggered: SystemSettings.openApplication("orca")
}
ActionRow { ActionRow {
label: "GNOME accessibility settings" label: "GNOME accessibility settings"
detail: "Opens in GNOME Settings" detail: "For the parts GNOME's own stack still owns"
action: "Open" action: "Open"
divider: false divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access") onTriggered: SystemSettings.openGnomePanel("universal-access")
@@ -46,7 +46,7 @@ SettingsPage {
title: "Background" title: "Background"
subtitle: Wallpaper.lastError !== "" subtitle: Wallpaper.lastError !== ""
? Wallpaper.lastError ? Wallpaper.lastError
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." : "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
WallpaperPicker { WallpaperPicker {
id: wallpapers id: wallpapers
@@ -184,7 +184,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Theme" title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it." subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" } TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false } TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
@@ -142,6 +142,40 @@ SettingsPage {
} }
} }
// GNOME's Search panel, answered honestly.
//
// It configures which applications provide results in gnome-shell's
// overview and which folders are indexed. gnome-shell does not run here,
// so those settings would do nothing. Under Panama searching is the
// launcher's job, and Vicinae carries its own preferences -- reimplementing
// them here would give two places to change one thing.
SettingsCard {
title: "Search"
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
TextRow {
label: "Launcher"
detail: SystemSettings.vicinaeActive
? "Running as a user service"
: "Not running — Super+Shift+R opens the fallback launcher"
value: "Vicinae"
}
TextRow {
label: "Open search"
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
value: "Super+Space"
}
ActionRow {
label: "Change these shortcuts"
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
action: "Open keyboard"
divider: false
onTriggered: ShellState.openSettings("shortcuts")
}
}
SettingsCard { SettingsCard {
title: "User autostart" title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
@@ -175,7 +209,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Compositor autostart" title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here." subtitle: "These are started from the Hyprland configuration. They are read-only here."
TextRow { TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0 visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
@@ -19,13 +19,18 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
title: "Network & Devices" title: "Network & Devices"
lede: Connectivity.activeNetwork lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name ? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns." : "Wi-Fi, Bluetooth, and the things Fedora owns."
// Drive the scanners only while this page is the one being shown. // Drive the scanners only while this page is the one being shown.
Component.onCompleted: Connectivity.active = true Component.onCompleted: {
Connectivity.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
}
Component.onDestruction: Connectivity.active = false Component.onDestruction: Connectivity.active = false
SettingsCard { SettingsCard {
@@ -68,6 +73,69 @@ SettingsPage {
} }
} }
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
// alternative is reading a passphrase out loud.
//
// The image holds the password in machine-readable form, so it is generated
// on demand rather than up front, and the helper writes it to tmpfs under
// XDG_RUNTIME_DIR instead of anywhere persistent.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
Repeater {
model: WifiShare.shareable
ActionRow {
id: shareRow
required property var modelData
required property int index
label: shareRow.modelData.ssid
detail: WifiShare.sharing === shareRow.modelData.name
? "Showing a code below — anyone who can see the screen can join"
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
}
}
// Drawn at its natural size on a white plate: a QR code inverted or
// tinted to match a dark theme is unreliable to scan, and this one has
// exactly one job.
Item {
width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
}
}
}
}
SettingsCard { SettingsCard {
title: "Bluetooth" title: "Bluetooth"
visible: Connectivity.adapter !== null visible: Connectivity.adapter !== null
@@ -47,18 +47,16 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Window layout" title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling." subtitle: "Follows the Forge mental model, with native Hyprland tiling."
TextRow { // These were two read-only rows reporting "Tiling" and "Dynamic", which
label: "Layout" // described settings rather than facts -- both are ordinary Hyprland
detail: "Dwindle with preserved split direction" // options that simply had no controls. TextRow's own documentation says
value: "Tiling" // a setting the user could reasonably change does not belong in it.
} ChoiceRow { setting: "windowLayout" }
TextRow { ToggleRow { setting: "preserveSplit" }
label: "Workspace movement" ChoiceRow { setting: "forceSplit" }
detail: "Alt+H / Alt+L, add Shift to move a window" ToggleRow { setting: "windowSnapping" }
value: "Dynamic"
}
ActionRow { ActionRow {
label: "Gaps, corners, and effects" label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview" detail: "Adjusted on the Appearance page, beside a live preview"
@@ -68,6 +66,18 @@ SettingsPage {
} }
} }
// GNOME's Multitasking panel, in Hyprland's terms.
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ChoiceRow { setting: "followMouse"; divider: false }
}
SettingsCard { SettingsCard {
title: "Focus" title: "Focus"
@@ -111,10 +121,10 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Reset" title: "Reset"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed." subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow { ActionRow {
label: "Restore Panama defaults" label: "Restore defaults"
detail: "Applies immediately, including to the compositor" detail: "Applies immediately, including to the compositor"
action: "Restore defaults" action: "Restore defaults"
divider: false divider: false
@@ -89,9 +89,12 @@ Rectangle {
} }
} }
// The real bar carries a clock here, not a name. Standing in with the
// actual time keeps the preview a picture of this desktop rather than
// a picture of a product.
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: "Panama" text: Qt.formatTime(new Date(), "h:mm")
color: Theme.alpha(Theme.fg, 0.45) color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: 9 font.pixelSize: 9
@@ -149,7 +149,7 @@ SettingsPage {
ActionRow { ActionRow {
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
label: "Using a custom display setting" label: "Using a custom display setting"
detail: "Forget it to go back to the resolution and scale Panama ships" detail: "Forget it to go back to the shipped resolution and scale"
action: "Forget" action: "Forget"
divider: false divider: false
onTriggered: Displays.forget(root.monitor.name) onTriggered: Displays.forget(root.monitor.name)
@@ -248,7 +248,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Gaming display policy" title: "Gaming display policy"
subtitle: "Applied immediately and restored when Panama starts." subtitle: "Applied immediately and restored when the session starts."
ToggleRow { setting: "autoHdr" } ToggleRow { setting: "autoHdr" }
ChoiceRow { setting: "vrrPolicy" } ChoiceRow { setting: "vrrPolicy" }
@@ -143,6 +143,7 @@ SettingsPage {
const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible); const checkingLabels = root.descendants(root, "health-checking-label").filter(label => label.visible);
const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible); const confirmationSheets = root.descendants(root, "health-confirmation-sheet:").filter(sheet => sheet.visible);
const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible); const emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible);
const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible);
return { return {
renderedRows: rows.map(row => { renderedRows: rows.map(row => {
const objectName = String(row.objectName); const objectName = String(row.objectName);
@@ -162,6 +163,11 @@ SettingsPage {
focusChain: root.renderedFocusChain(), focusChain: root.renderedFocusChain(),
activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)), activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)),
emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)), emptyQuietGroups: emptyGroups.map(label => String(label.objectName).slice("health-empty-group:".length)),
fedoraHandoffs: fedoraHandoffs.map(row => ({
id: String(row.objectName).slice("health-fedora-handoff:".length),
label: row.label,
action: row.action
})),
confirmationVisible: confirmationSheets.length === 1, confirmationVisible: confirmationSheets.length === 1,
confirmationId: confirmationSheets.length === 1 confirmationId: confirmationSheets.length === 1
? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length) ? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length)
@@ -384,7 +390,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Fedora system settings" title: "Fedora system settings"
subtitle: "Network accounts, printers, users, and Fedora updates remain owned by system tools." subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
Item { Item {
width: parent.width width: parent.width
@@ -415,5 +421,38 @@ SettingsPage {
Keys.onSpacePressed: SystemSettings.openGnomePanel("network") Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
} }
} }
ActionRow {
objectName: "health-fedora-handoff:users"
label: "Users"
detail: "Accounts, passwords, and automatic login"
action: "Open users"
onTriggered: SystemSettings.openGnomePanel("system", "users")
}
ActionRow {
objectName: "health-fedora-handoff:sharing"
label: "Sharing"
detail: "Remote desktop, media sharing, and remote login"
action: "Open sharing"
onTriggered: SystemSettings.openGnomePanel("sharing")
}
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Colour profiles"
detail: "ICC profiles for displays, printers, and scanners"
action: "Open colour"
onTriggered: SystemSettings.openGnomePanel("color")
}
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
detail: "Screen time and break reminders"
action: "Open wellbeing"
divider: false
onTriggered: SystemSettings.openGnomePanel("wellbeing")
}
} }
} }
@@ -9,7 +9,7 @@ SettingsPage {
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")
title: `${root.greeting}, Gabriel` title: `${root.greeting}, Gabriel`
lede: "Your Panama desktop is configured and ready." lede: "Your desktop is configured and ready."
SettingsCard { SettingsCard {
title: SystemSettings.monitorDescription || "Active display" title: SystemSettings.monitorDescription || "Active display"
@@ -202,7 +202,7 @@ SettingsPage {
SettingRow { SettingRow {
label: "Private configuration" label: "Private configuration"
detail: "Saved with owner-only permissions in Panama's private environment file" detail: "Saved with owner-only permissions in a private environment file"
controlWidth: 216 controlWidth: 216
Row { Row {
@@ -239,7 +239,7 @@ SettingsPage {
SettingRow { SettingRow {
label: "Light catalog" label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper." detail: "Light state is read through the Home Assistant helper."
divider: false divider: false
controlWidth: 176 controlWidth: 176
@@ -0,0 +1,57 @@
// Mouse & Touchpad.
//
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
// where it was before. These all reach Hyprland's input section, which until now
// could only be changed by editing hypr/input.lua by hand.
//
// The touchpad card renders only when a touchpad is attached. On a desktop it
// would be worse than useless -- every switch on it appears to work, because the
// preference is stored and the compositor accepts an option for a device class
// with no members, so the user would be toggling settings that can never affect
// anything with nothing to say so.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Mouse & Touchpad"
lede: InputDevices.hasTouchpad
? "Pointer behaviour for your mouse and touchpad."
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
SettingsCard {
title: "Mouse"
subtitle: "Applied to every pointing device that is not a touchpad."
SliderRow { setting: "pointerSensitivity" }
ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Touchpad"
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
ToggleRow { setting: "touchpadTapToClick" }
ToggleRow { setting: "touchpadNaturalScroll" }
ToggleRow { setting: "touchpadDisableWhileTyping" }
SliderRow { setting: "touchpadScrollFactor" }
ChoiceRow { setting: "touchpadDragLock" }
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
SliderRow { setting: "cursorSize"; divider: false }
}
}
@@ -24,7 +24,7 @@ SettingsPage {
TextRow { TextRow {
label: "Notification history" label: "Notification history"
detail: "Live notifications retained by Panama" detail: "Live notifications retained by the shell"
value: `${Notifs.history.length} items` value: `${Notifs.history.length} items`
} }
@@ -0,0 +1,122 @@
// Online Accounts.
//
// GNOME's panel of the same name, except for one step. The accounts are GNOME
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
// per-service toggles and removal are all done natively on this page.
//
// Signing in to an OAuth provider is handed to GNOME's panel, because the
// credentials are OAuth tokens and the code that obtains them ships without a
// scriptable binding. That is stated on the page rather than hidden behind a
// button that looks native, because a hand-off the user does not expect reads
// as a bug.
//
// Accounts needing attention are surfaced first. GOA knows when a token has
// expired and nothing outside its own panel says so, which is how an account
// quietly stops syncing for weeks.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Online Accounts"
lede: OnlineAccounts.attentionCount > 0
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
? " account needs you to sign in again."
: " accounts need you to sign in again.")
: "Accounts your mail, calendar, contacts, and files come from."
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
SettingsCard {
visible: !OnlineAccounts.available
title: "Accounts unavailable"
subtitle: OnlineAccounts.lastError
}
SettingsCard {
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
title: "No accounts yet"
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
ActionRow {
label: "Add an account"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
Repeater {
model: OnlineAccounts.accounts
SettingsCard {
id: accountCard
required property var modelData
title: accountCard.modelData.identity || accountCard.modelData.providerName
subtitle: accountCard.modelData.needsAttention
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
: accountCard.modelData.providerName
// Only when it is true, because it is the one thing on this page
// that needs acting on.
ActionRow {
visible: accountCard.modelData.needsAttention
label: "Sign in again"
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
action: "Sign in"
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
Repeater {
model: accountCard.modelData.services
SettingRow {
id: serviceRow
required property var modelData
required property int index
label: serviceRow.modelData.label
detail: serviceRow.modelData.enabled
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
: "Hidden from applications"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: serviceRow.modelData.enabled
onToggled: value => OnlineAccounts.setService(
accountCard.modelData.path, serviceRow.modelData.key, value)
}
}
}
ActionRow {
label: "Remove this account"
detail: "Signs out and removes it from every application that was using it"
action: "Remove"
divider: false
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
}
}
}
SettingsCard {
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
title: "Add another account"
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
ActionRow {
label: "Add an account"
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
action: "Add account"
divider: false
onTriggered: SystemSettings.openGnomePanel("online-accounts")
}
}
}
@@ -14,14 +14,48 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
// Probing the power daemon is a D-Bus round trip, so it happens when the
// page opens rather than at shell startup.
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
title: "Power & Lock" title: "Power & Lock"
lede: "When the screen turns off, when the session locks, and whether it ever sleeps." lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
// The same profiles GNOME's Power panel offers. Not a stored preference --
// the daemon owns it, it survives Panama restarts, and anything else on the
// system can change it, so a copy here would drift.
SettingsCard {
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
title: "Power profile"
subtitle: PowerProfiles.degraded !== ""
? "Performance is limited right now: " + PowerProfiles.degraded
: (PowerProfiles.available
? "Applies to the whole system and persists across sessions."
: PowerProfiles.lastError)
Repeater {
model: PowerProfiles.profiles
SettingRow {
id: profileRow
required property var modelData
required property int index
label: PowerProfiles.label(profileRow.modelData)
detail: PowerProfiles.detail(profileRow.modelData)
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
divider: profileRow.index < PowerProfiles.profiles.length - 1
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
onActivated: PowerProfiles.set(profileRow.modelData)
}
}
}
SettingsCard { SettingsCard {
title: "Idle behaviour" title: "Idle behaviour"
subtitle: IdleLock.managed subtitle: IdleLock.managed
? "Panama is managing hypridle. Changes take effect immediately." ? "Idle timings are managed here. Changes take effect immediately."
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable." : "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" } SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
@@ -41,10 +75,10 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Management" title: "Management"
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place." subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
SettingRow { SettingRow {
label: "Let Panama manage idle timings" label: "Manage idle timings here"
detail: IdleLock.serviceState === "active" detail: IdleLock.serviceState === "active"
? "hypridle is running" ? "hypridle is running"
: "hypridle is " + IdleLock.serviceState : "hypridle is " + IdleLock.serviceState
@@ -0,0 +1,166 @@
// Privacy & Security.
//
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
// history, trash, and device security. Panama covers the parts it genuinely
// owns and is explicit about the parts it does not.
//
// The file-history and trash settings are the notable omission, and the reason
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
// which is not running in a Hyprland session. Offering switches for them would
// store a preference, change nothing, and give no sign of it -- the exact
// failure this codebase keeps designing against. So they are delegated by name
// rather than reimplemented as controls that lie.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Privacy & Security"
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
Component.onCompleted: {
if (!DeviceSecurity.scanned)
DeviceSecurity.refresh();
if (!Keyring.scanned)
Keyring.refresh();
}
SettingsCard {
title: "Screen lock"
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
// The login keyring, which nothing else surfaces.
//
// It is unlocked at sign-in by PAM, so this card normally just confirms
// that. It earns its place on the rare occasion it is not: a locked keyring
// breaks saved passwords everywhere at once, and does it without ever
// saying the word "keyring" -- you get a mail account that will not
// authenticate and a git push that cannot find its key.
SettingsCard {
visible: Keyring.scanned
title: "Saved passwords"
subtitle: !Keyring.available
? "No secret service is answering, so saved passwords are unavailable."
: Keyring.locked
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
: "The login keyring is unlocked, as it is after every normal sign-in."
// Two rows rather than one with a conditional button: a locked keyring
// needs an action, an unlocked one is a statement of fact, and ActionRow
// and TextRow already say exactly those two things.
ActionRow {
visible: Keyring.available && Keyring.locked
label: "Login keyring"
detail: "Unlock to restore access to stored passwords and keys"
action: Keyring.unlocking ? "Waiting…" : "Unlock"
enabled: !Keyring.unlocking
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
onTriggered: Keyring.unlock()
}
TextRow {
visible: !(Keyring.available && Keyring.locked)
label: "Login keyring"
detail: Keyring.available
? "Unlocked at sign-in by PAM, the same way GNOME does it"
: "No secret service is answering on this session"
value: Keyring.available ? "Unlocked" : "Unavailable"
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
}
// Only shown when it is true, because it is a diagnostic rather than a
// setting: it means the daemon holding your secrets is not the one PAM
// started, so whatever unlocked it will not survive a restart.
SettingRow {
visible: Keyring.replacementDaemon
label: "Keyring service"
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
value: "Replaced"
divider: Keyring.lastError !== ""
}
SettingRow {
visible: Keyring.lastError !== ""
label: "Keyring problem"
detail: Keyring.lastError
divider: false
}
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive
? "In use right now — the bar shows an indicator whenever this is true."
: "Nothing is using your camera or microphone."
TextRow {
label: "Camera"
detail: PrivacyState.cameraActive
? "In use by " + (PrivacyState.cameraApp || "an application")
: "Not in use"
value: PrivacyState.cameraActive ? "Active" : "Idle"
}
TextRow {
label: "Microphone"
detail: PrivacyState.microphoneActive
? "In use by " + (PrivacyState.microphoneApp || "an application")
: "Not in use"
value: PrivacyState.microphoneActive ? "Active" : "Idle"
}
TextRow {
label: "Screen sharing"
detail: PrivacyState.screenSharingActive
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
: "Not being shared"
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
divider: false
}
}
SettingsCard {
title: "Device security"
subtitle: DeviceSecurity.attentionCount === 0
? "Everything below is in its recommended state."
: DeviceSecurity.attentionCount + " item"
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
Repeater {
model: DeviceSecurity.facts
TextRow {
id: factRow
required property var modelData
required property int index
label: factRow.modelData.label
detail: factRow.modelData.detail
value: factRow.modelData.value
divider: factRow.index < DeviceSecurity.facts.length - 1
}
}
}
SettingsCard {
title: "Owned by Fedora"
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
ActionRow {
label: "File history & trash"
detail: "Opens GNOME Settings, which owns these"
action: "Open privacy"
divider: false
onTriggered: SystemSettings.openGnomePanel("privacy")
}
}
}
@@ -1,4 +1,4 @@
# Panama Settings # Settings
The control centre for everything Panama owns. Anything the system owns — The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as hardware, accounts, printers — is delegated to GNOME Settings and labelled as
@@ -0,0 +1,75 @@
// Region & Language.
//
// GNOME keeps language and formats under System; the setting itself is the
// machine's locale, which localectl owns. Panama does not store a copy of it --
// there is exactly one system locale and localectl is where it lives, so a
// preference here would be a second source of truth that drifts the moment
// anything else changes it.
//
// Keyboard layout is deliberately not repeated here even though GNOME groups it
// with region. It is a compositor setting that applies instantly, it lives on
// the Keyboard page with the rest of the typing settings, and showing it twice
// invites the two views to disagree.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Region & Language"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: "The language and regional formats this machine uses."
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
SettingsCard {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
TextRow {
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
}
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => SystemLocale.set(value)
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "Language problem"
subtitle: SystemLocale.lastError
}
SettingsCard {
title: "Formats"
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
ActionRow {
label: "Clock and date display"
detail: "How the desktop itself shows the time"
action: "Open appearance"
onTriggered: ShellState.openSettings("appearance")
}
ActionRow {
label: "Regional formats"
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
action: "Open system"
divider: false
onTriggered: SystemSettings.openGnomePanel("system", "region")
}
}
}
@@ -18,7 +18,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Read anything on screen" title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions." subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
SettingRow { SettingRow {
icon: "󰗊" icon: "󰗊"
@@ -52,7 +52,7 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Local recognition" title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result." subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
TextRow { TextRow {
label: "Text recognition" label: "Text recognition"
@@ -0,0 +1,94 @@
// A searchable list of choices, for settings with far too many values to put in
// a dropdown and no useful preview to show.
//
// SearchPicker {
// items: Locale.locales // [{ value, label, detail }]
// current: Locale.current
// placeholder: "Search languages"
// onPicked: value => Locale.set(value)
// }
//
// FontPicker is the same idea specialised: it draws each candidate in its own
// family, which is the whole reason it is a list rather than a text field. This
// one is for choices whose only useful preview is their name.
import QtQuick
import qs.config
// SearchField lives with the clipboard module, which is where it was first
// needed; FontPicker imports it from the same place.
import qs.modules.clipboard
Column {
id: root
// [{ value, label, detail }]
property var items: []
property string current: ""
property string emptyText: "Nothing to choose from"
property string placeholder: "Search"
signal picked(string value)
spacing: 0
// Capped and filtered rather than listing everything: 327 locales inside a
// page that is already scrolling is worse than a search box.
readonly property var matches: {
const needle = filter.text.trim().toLowerCase();
const list = root.items.filter(item =>
needle === ""
? true
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
// The current choice stays visible while browsing, so it is always
// clear what would be replaced.
const selected = list.find(item => item.value === root.current);
if (needle === "" && selected !== undefined)
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: root.placeholder
}
Repeater {
model: root.matches
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData.value === root.current
label: candidate.modelData.label
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
controlWidth: 120
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData.value)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: candidate.selected
text: "✓"
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
color: Theme.accent
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
@@ -53,7 +53,7 @@ Rectangle {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 18 anchors.leftMargin: 18
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Panama Settings" text: "Settings"
color: Theme.fg color: Theme.fg
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
@@ -112,6 +112,10 @@ Rectangle {
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;
case "mouse": return mousePage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage;
case "accessibility": return accessibilityPage; case "accessibility": return accessibilityPage;
case "power": return powerPage; case "power": return powerPage;
case "datetime": return dateTimePage; case "datetime": return dateTimePage;
@@ -166,6 +170,10 @@ Rectangle {
Component { id: notificationsPage; NotificationsPage {} } Component { id: notificationsPage; NotificationsPage {} }
Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
Component { id: shortcutsPage; ShortcutsPage {} } Component { id: shortcutsPage; ShortcutsPage {} }
Component { id: mousePage; MousePage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: healthPage; HealthPage {} } Component { id: healthPage; HealthPage {} }
Component { id: aboutPage; AboutPage {} } Component { id: aboutPage; AboutPage {} }
@@ -31,13 +31,17 @@ Rectangle {
{ page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" },
{ 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: "Input & Shortcuts", icon: "\u{F030C}" }, { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
{ 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: "services", label: "System Health", icon: "\u{F0493}" }, { page: "services", label: "System Health", icon: "\u{F0493}" },
{ page: "about", label: "About Panama", icon: "\u{F02FD}" } { page: "about", label: "About", icon: "\u{F02FD}" }
] ]
width: 272 width: 272
@@ -56,15 +60,6 @@ Rectangle {
height: implicitHeight height: implicitHeight
spacing: 12 spacing: 12
Text {
text: "PANAMA"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
font.letterSpacing: 2.2
}
Rectangle { Rectangle {
width: parent.width width: parent.width
height: 36 height: 36
@@ -8,7 +8,7 @@ FloatingWindow {
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings" title: "Settings"
visible: ShellState.settingsOpen visible: ShellState.settingsOpen
implicitWidth: 1120 implicitWidth: 1120
implicitHeight: 760 implicitHeight: 760
@@ -28,24 +28,19 @@ SettingsPage {
SettingsCard { SettingsCard {
title: "Keyboard" title: "Keyboard"
TextRow { // These were read-only text, on the grounds that a layout change needed
label: "Keyboard layout" // a compositor reload. It does not: setting input:kb_variant through
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly." // hl.config re-keymaps attached keyboards immediately -- verified by
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us" // watching active_keymap on a real keyboard change and change back. So
} // they are real controls.
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" } SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false } ToggleRow { setting: "numlockByDefault"; divider: false }
} }
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard { SettingsCard {
title: "Hardware input" title: "Hardware input"
@@ -155,7 +150,7 @@ SettingsPage {
SettingsCard { SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0 visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts" title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration." subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
ActionRow { ActionRow {
label: "Restore every shipped shortcut" label: "Restore every shipped shortcut"
@@ -0,0 +1,109 @@
// A free-text setting, bound to a schema key by name.
//
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
//
// For the settings whose value is a short string with too many legal values to
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
// validation all come from PreferenceSchema, so a row cannot drift from the
// setting it edits.
//
// Rejected input is shown as rejected rather than silently dropped or quietly
// sanitised. Several of these strings are serialised into an hl.config payload,
// where stripping an unexpected character would turn a typo into a different
// working setting -- so the schema's pattern decides, the field turns red when
// it fails, and nothing is committed until it passes.
//
// Committed on Enter or when focus leaves, not per keystroke: half a layout
// name is a valid string that means something else, and each commit is a round
// trip to the compositor.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
property string placeholder: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
// Empty is always allowed to be typed through, even where the pattern
// forbids it, so a field can be cleared on the way to a new value.
readonly property bool valid: input.text === ""
|| !root.spec?.pattern
|| new RegExp(root.spec.pattern).test(input.text)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 210
function commit(): void {
if (!root.valid || input.text === root.stored)
return;
SystemSettings.commitPreference(root.setting, input.text);
}
function revert(): void {
input.text = root.stored;
}
// Follows the store when the value changes elsewhere -- a reset, a restored
// backup -- but never while the field has focus, which would overwrite what
// is being typed.
onStoredChanged: if (!input.activeFocus) input.text = root.stored
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.controlWidth
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: !root.valid
? Theme.danger
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
verticalAlignment: TextInput.AlignVCenter
clip: true
text: root.stored
color: root.valid ? Theme.fg : Theme.danger
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
selectByMouse: true
selectionColor: Theme.alpha(Theme.accent, 0.35)
onAccepted: root.commit()
// Commit, then show what is actually stored. If the compositor
// refused the value, the field snaps back to the value in effect
// rather than displaying a setting that was never applied; if it
// accepted, onStoredChanged brings the field back up to date the
// moment the write lands.
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
Keys.onEscapePressed: {
root.revert();
input.focus = false;
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: input.text === "" && !input.activeFocus
text: root.placeholder
color: Theme.fgMuted
font: input.font
}
}
}
}
@@ -156,7 +156,7 @@ Item {
width: parent.width - 40 width: parent.width - 40
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds." text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
color: Theme.fgMuted color: Theme.fgMuted
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -48,3 +48,9 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml
TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# What this machine is, as JSON: {label, value} pairs in display order.
#
# GNOME's About panel answers "what am I running on" in one screen, and
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
# locale. Rows are ordered roughly the way fastfetch presents them: what the
# system is, then what is installed on it, then the hardware underneath.
#
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of
# truth that could disagree with the first. The page joins the two.
#
# Anything unreadable is omitted rather than reported as "Unknown". These are
# facts about hardware, and a row saying "Processor: Unknown" is noise where
# simply not having the row is not.
set -uo pipefail
facts=()
emit() {
[[ -n "${2:-}" ]] || return 0
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
}
# ── System ───────────────────────────────────────────────────────────────────
if [[ -r /etc/os-release ]]; then
# Sourced in a subshell so the variables cannot leak into this script.
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
emit "Operating system" "$os"
fi
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
# string"). Those are worse than nothing, so they are filtered out.
dmi() {
local value
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
case "$value" in
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
esac
printf '%s' "$value"
}
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
emit "Model" "$vendor $product"
else
emit "Model" "${product:-$vendor}"
fi
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
# leading "up " needs removing.
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
# ── Installed ────────────────────────────────────────────────────────────────
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
# lines and takes a moment, which is part of why this whole helper runs on
# demand rather than at startup.
packages=""
if command -v rpm >/dev/null 2>&1; then
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
fi
if command -v flatpak >/dev/null 2>&1; then
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
if [[ "$flatpak_count" -gt 0 ]]; then
[[ -n "$packages" ]] && packages="$packages, "
packages="$packages$flatpak_count flatpak"
fi
fi
emit "Packages" "$packages"
# $SHELL is the login shell, which is the one worth reporting -- the shell this
# script happens to run under is an implementation detail of the caller.
if [[ -n "${SHELL:-}" ]]; then
shell_name="$(basename "$SHELL")"
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
fi
emit "Locale" "${LANG:-}"
case "${XDG_SESSION_TYPE:-}" in
wayland) emit "Windowing system" "Wayland" ;;
x11) emit "Windowing system" "X11" ;;
esac
# ── Hardware ─────────────────────────────────────────────────────────────────
# The focused monitor's actual mode, including the fractional scale, since a
# 4500x3000 panel at 1.5 presents very differently from one at 1.
if command -v hyprctl >/dev/null 2>&1; then
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
| jq -r 'map(select(.focused)) + . | .[0]
| select(. != null)
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
fi
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
threads="$(nproc 2>/dev/null)"
if [[ -n "$cpu" ]]; then
# The marketing name usually already says "6-Core", so the thread count is
# the part that adds information.
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
emit "Processor" "$cpu"
fi
# Reported as the kernel sees it, which is a little under the sticker figure
# because firmware and integrated graphics reserve some before Linux starts.
#
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
# is stale before it finishes drawing. The Home page's vitals readout is where
# live numbers belong.
emit "Memory" "$(awk '/^MemTotal:/ { 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"
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Online accounts, through GNOME Online Accounts.
GOA is a daemon plus a D-Bus API, and it is already running in this session --
gvfs activates it, and the four accounts on this machine work without
gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts
themselves are ordinary D-Bus objects that anything may read and modify.
So everything except the initial sign-in is available to us: listing accounts,
turning individual services on and off, and removing an account. That is the
whole Online Accounts panel apart from one OAuth handshake.
Adding an account is the part that splits. The daemon's AddAccount takes
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
password, which a settings app can reasonably collect. For Google it is an OAuth
token, and the code that runs that exchange lives in libgoa-backend, which
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
would mean our own Google client credentials. So Google sign-in is handed to
GNOME's panel, and only the sign-in.
Usage:
panama-accounts list
panama-accounts set <object-path> <service> <true|false>
panama-accounts remove <object-path>
"""
import json
import sys
# Every service GOA models. The account object carries one interface per service
# it supports, so presence of the interface is what "this account can do mail"
# means -- there is no capability list to read.
SERVICES = [
("mail", "Mail", "get_mail", "mail_disabled"),
("calendar", "Calendar", "get_calendar", "calendar_disabled"),
("contacts", "Contacts", "get_contacts", "contacts_disabled"),
("files", "Files", "get_files", "files_disabled"),
("photos", "Photos", "get_photos", "photos_disabled"),
("music", "Music", "get_music", "music_disabled"),
("chat", "Chat", "get_chat", "chat_disabled"),
]
def load_client():
import gi
gi.require_version("Goa", "1.0")
from gi.repository import Goa
return Goa.Client.new_sync(None)
def describe(obj):
account = obj.get_account()
services = []
for key, label, getter, disabled_prop in SERVICES:
if getattr(obj, getter)() is None:
continue
services.append({
"key": key,
"label": label,
"enabled": not getattr(account.props, disabled_prop),
})
return {
"path": obj.get_object_path(),
"provider": account.props.provider_type,
"providerName": account.props.provider_name,
# PresentationIdentity is the human one (an email address); Identity is
# the internal handle and is not always readable.
"identity": account.props.presentation_identity or account.props.identity,
# GOA raises this when stored credentials stop working -- an expired
# token, a changed password. It is the one piece of state a user must
# act on, and nothing else surfaces it.
"needsAttention": bool(account.props.attention_needed),
"services": services,
}
def find(client, path):
for obj in client.get_accounts():
if obj.get_object_path() == path:
return obj
return None
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "list"
try:
client = load_client()
except Exception as error: # noqa: BLE001 - any failure here means "no GOA"
# A machine without GOA is a legitimate state, not a crash.
print(json.dumps({
"accounts": [],
"error": f"GNOME Online Accounts is not available: {error}",
}))
return 0
if action == "list":
print(json.dumps({
"accounts": [describe(obj) for obj in client.get_accounts()],
"error": "",
}))
return 0
if action == "set":
if len(sys.argv) != 5:
print("usage: panama-accounts set <path> <service> <true|false>", file=sys.stderr)
return 2
path, service, value = sys.argv[2], sys.argv[3], sys.argv[4]
obj = find(client, path)
if obj is None:
print(f"panama-accounts: no account at {path}", file=sys.stderr)
return 1
match = next((s for s in SERVICES if s[0] == service), None)
if match is None:
print(f"panama-accounts: unknown service {service!r}", file=sys.stderr)
return 2
if getattr(obj, match[2])() is None:
print(f"panama-accounts: this account does not support {service}", file=sys.stderr)
return 1
# The property is "disabled", so enabling a service clears it.
setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes"))
return 0
if action == "remove":
if len(sys.argv) != 3:
print("usage: panama-accounts remove <path>", file=sys.stderr)
return 2
obj = find(client, sys.argv[2])
if obj is None:
print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr)
return 1
obj.get_account().call_remove_sync(None)
return 0
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -57,6 +57,39 @@ has_accessible_bus() {
return 1 return 1
} }
# The I2C bus that carries DDC/CI for one connector.
#
# There are two, and picking the wrong one finds no monitor at all:
#
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
# is the one ddcutil talks to.
#
# The connector's `ddc` symlink points at the classic I2C line used for EDID
# on HDMI and DVI. On a DisplayPort connector it still exists and still
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
# immediately.
#
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
# it is given, so searching the unresolved path silently finds nothing.
bus_for_connector() {
local path="$1" real aux ddc
real="$(readlink -f "$path")"
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
if [[ -n "$aux" ]]; then
printf '%s' "${aux#i2c-}"
return 0
fi
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
[[ -n "$ddc" ]] || return 1
ddc="$(basename "$ddc")"
printf '%s' "${ddc#i2c-}"
}
cmd_list() { cmd_list() {
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm' has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
@@ -69,8 +102,7 @@ cmd_list() {
connector="$(basename "$path")" connector="$(basename "$path")"
connector="${connector#card*-}" connector="${connector#card*-}"
bus="$(basename "$(readlink -f "$path/ddc")")" bus="$(bus_for_connector "$path")"
bus="${bus#i2c-}"
[[ "$bus" =~ ^[0-9]+$ ]] || continue [[ "$bus" =~ ^[0-9]+$ ]] || continue
# A monitor that does not implement 0x10 is not an error; it simply # A monitor that does not implement 0x10 is not an error; it simply
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""The login keyring's lock state, and a way to unlock it.
Why this exists
---------------
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
and a bare Hyprland session does not, is anywhere to SEE that it failed.
It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
service_method_open_session, seen once here), and when it does, D-Bus activates
a replacement. That replacement never received the login password, so the login
keyring comes back LOCKED in the middle of a session that unlocked it correctly
at login. Everything that stores a secret then starts failing in ways that do
not mention keyrings at all: a mail client that will not authenticate, a git
push that cannot find its key, an integration that reports "not configured".
So this reports the state plainly and offers the one action that fixes it.
Unlocking prompts
-----------------
`unlock` asks the Secret Service to unlock, which raises the gcr password
dialog. That is deliberate: the password is not ours to store or handle, and it
never passes through this script. The dialog is the same one GNOME shows.
Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
is not hung, it is waiting for a dialog nobody is looking at. That is worth
knowing before debugging one for an hour.
Usage:
panama-keyring status -> {"available", "locked", "collections", "daemon"}
panama-keyring unlock -> raises the password prompt; prints the new state
"""
import json
import os
import re
import sys
def daemon_origin():
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
A D-Bus-activated daemon is the signature of the crash-and-replace case
above: it is the one that cannot have the login password. PAM's daemon lives
outside the app slice, so the cgroup tells the two apart.
"""
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as handle:
cmdline = handle.read().decode("utf-8", "replace")
except OSError:
continue
if "gnome-keyring-daemon" not in cmdline:
continue
try:
with open(f"/proc/{pid}/cgroup", "r") as handle:
cgroup = handle.read()
except OSError:
return "unknown"
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
return "dbus"
return "pam"
except OSError:
pass
return "none"
def load_service():
import gi
gi.require_version("Secret", "1")
from gi.repository import Secret
return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)
def report(service, Secret):
collections = [
{"label": c.get_label(), "locked": c.get_locked()}
for c in service.get_collections()
]
# The login keyring is the one that matters; the others are per-application
# stores that manage their own unlocking.
login = next((c for c in collections if c["label"] == "Login"), None)
return {
"available": True,
"locked": bool(login["locked"]) if login else False,
"hasLogin": login is not None,
"collections": collections,
"daemon": daemon_origin(),
"error": "",
}
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "status"
if action not in ("status", "unlock"):
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
return 2
try:
Secret, service = load_service()
except Exception as error: # noqa: BLE001 - any failure here is "no keyring"
# No Secret Service at all is a legitimate state, not a crash: report it
# so the UI can say so instead of showing an empty card.
print(json.dumps({
"available": False, "locked": False, "hasLogin": False,
"collections": [], "daemon": daemon_origin(),
"error": f"The secret service is not answering: {error}",
}))
return 0
if action == "unlock":
login = next(
(c for c in service.get_collections() if c.get_label() == "Login"), None)
if login is not None and login.get_locked():
try:
# Blocks until the dialog is answered or dismissed.
service.unlock_sync([login], None)
except Exception as error: # noqa: BLE001
state = report(service, Secret)
state["error"] = f"The keyring was not unlocked: {error}"
print(json.dumps(state))
return 0
# The collection object caches its state; re-read it.
Secret, service = load_service()
print(json.dumps(report(service, Secret)))
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# System locale, via localectl.
#
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale>
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
# The code stays visible as the row's detail, because it is what actually gets
# written and someone choosing between two Spanish variants needs to see it.
#
# The join happens in a single jq pass. Doing it per locale meant 327 jq
# invocations, which took long enough to be visible when opening the page.
#
# Setting the locale is a privileged operation: localectl goes through polkit,
# which prompts. It also only takes effect for programs started afterwards, so
# the caller is responsible for saying a sign-out is needed -- this script does
# not pretend the running session changed.
set -uo pipefail
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
cmd_get() {
localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]'
}
cmd_list() {
local locales
locales="$(localectl list-locales 2>/dev/null)" || locales=""
if [[ -z "$locales" ]]; then
printf '[]\n'
return 0
fi
# Without iso-codes installed the codes are still perfectly usable; they
# just do not get friendly names. That is a degraded list, not a failure.
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
jq -Rn --rawfile raw /dev/stdin \
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
<<<"$locales"
return 0
fi
jq -Rn \
--slurpfile languages "$ISO_LANG" \
--slurpfile countries "$ISO_COUNTRY" \
--rawfile raw /dev/stdin '
# alpha_2 -> name, for both databases. Languages without a two-letter
# code cannot appear in a locale name, so they are simply absent.
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
| [ $raw
| split("\n")[]
| select(length > 0)
| . as $value
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
# not part of the human name.
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
| ($lang[$parts[0]] // $parts[0]) as $language
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
| {
value: $value,
label: (if $region then "\($language) (\($region))" else $language end),
detail: $value
}
]
| sort_by(.label)
' <<<"$locales"
}
cmd_set() {
local locale="${1:-}"
# Constrained rather than passed through: this reaches a privileged
# command, and the set of legal locale names is narrow and well known.
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
}
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
localectl set-locale "LANG=$locale"
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
esac
+141 -6
View File
@@ -2,6 +2,8 @@
set -u set -u
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
strict_delivery() { strict_delivery() {
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]] [[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
} }
@@ -67,18 +69,151 @@ adjust_microphone() {
show_volume "$target" microphone show_volume "$target" microphone
} }
brightness_percent() {
local output="$1" percent
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$percent"
}
brightness_error() {
local detail="$1" label="External brightness unavailable"
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
label="Brightness needs permission"
fi
show_message dialog-warning-symbolic "$label" || true
if command -v notify-send >/dev/null 2>&1; then
notify-send --app-name=Panama --icon=display-brightness-symbolic \
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
fi
}
discover_ddc_bus() {
local helper="$1" cache_file="$2" list_json focused selected error bus connector
command -v jq >/dev/null 2>&1 || {
brightness_error "jq is required to discover DDC/CI displays."
return 1
}
list_json="$("$helper" list 2>/dev/null)" || {
brightness_error "The external brightness helper could not inspect connected displays."
return 1
}
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
brightness_error "The external brightness helper returned invalid display information."
return 1
fi
error="$(jq -r '.error // empty' <<<"$list_json")"
if [[ -n $error ]]; then
brightness_error "$error"
return 1
fi
focused="$(hyprctl -j monitors 2>/dev/null \
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
| head -n1)"
selected="$(jq -r --arg connector "$focused" '
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
| [.bus, .connector]
| @tsv
' <<<"$list_json")"
IFS=$'\t' read -r bus connector <<<"$selected"
if [[ ! $bus =~ ^[0-9]+$ ]]; then
brightness_error "No connected monitor exposes DDC/CI brightness control."
return 1
fi
umask 077
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
printf '%s\n' "$bus"
}
adjust_ddc_brightness() {
local action="$1" step="$2"
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
local bus="" connector="" current target lock_fd
[[ -x $helper ]] || {
brightness_error "The external brightness helper is not installed."
return 0
}
mkdir -p "$runtime_dir" || return 0
chmod 700 "$runtime_dir" 2>/dev/null || true
exec {lock_fd}>"$lock_file" || return 0
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
flock -w 2 "$lock_fd" || return 0
if [[ -r $cache_file ]]; then
IFS=$'\t' read -r bus connector <"$cache_file" || true
[[ $bus =~ ^[0-9]+$ ]] || bus=""
fi
if [[ -n $bus ]]; then
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
if [[ ! $current =~ ^[0-9]+$ ]]; then
: >"$cache_file"
bus=""
fi
fi
if [[ -z $bus ]]; then
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
fi
if [[ ! $current =~ ^[0-9]+$ ]]; then
brightness_error "The selected monitor stopped responding over DDC/CI."
return 0
fi
if [[ $action == up ]]; then
target=$(( current + step ))
else
target=$(( current - step ))
fi
(( target > 100 )) && target=100
(( target < 0 )) && target=0
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
brightness_error "The selected monitor did not accept the brightness change."
return 0
fi
show_progress brightness "$target" "${target}%"
}
adjust_brightness() { adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent local action="${1:-}" step="${2:-5}" output percent
[[ $step =~ ^[0-9]+$ ]] || {
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
return 2
}
case "$action" in case "$action" in
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;; up|down) ;;
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;; *) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
esac esac
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 # Laptop panels expose a kernel backlight class and remain the fastest,
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" # most reliable path. Desktops fall through to DDC/CI monitor control.
[[ $percent =~ ^[0-9]+$ ]] || return 0 output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
show_progress brightness "$percent" "${percent}%" if percent="$(brightness_percent "$output")"; then
if [[ $action == up ]]; then
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
else
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
fi
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(brightness_percent "$output")" || return 0
show_progress brightness "$percent" "${percent}%"
return
fi
adjust_ddc_brightness "$action" "$step"
} }
media_action() { media_action() {
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# System power profile, via the PowerProfiles D-Bus API.
#
# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the
# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather
# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles
# interface, which is why this talks to the interface rather than to either
# binary -- powerprofilesctl is not even installed here.
#
# Setting a profile needs no privileges: the daemon accepts a property write
# from the active session user.
#
# Usage:
# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."}
# panama-power-profile set <name>
#
# PerformanceDegraded is reported because it is the one thing that makes the
# choice a lie: a thermally throttled laptop reports "performance" while
# behaving otherwise, and GNOME surfaces exactly this. It is an empty string
# when nothing is wrong.
set -uo pipefail
readonly BUS_NAME=net.hadess.PowerProfiles
readonly OBJECT=/net/hadess/PowerProfiles
emit_error() {
printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available'
property() {
busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null
}
cmd_list() {
# A machine with no power-profiles daemon is a normal state -- plenty of
# desktops have none -- so it is reported rather than treated as a failure.
busctl status "$BUS_NAME" >/dev/null 2>&1 \
|| emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.'
local active degraded profiles
active="$(property ActiveProfile | sed 's/^s //; s/"//g')"
degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')"
# Profiles is an array of dicts, which busctl renders flat:
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
# so each profile is the string following its own "Profile" marker. Matching
# the marker matters: "Driver" values sit in the same stream, and on this
# machine the driver is called "tuned", which a looser pattern happily
# reports as a fourth profile that does not exist.
profiles="$(property Profiles \
| grep -oE '"Profile" s "[a-z-]+"' \
| sed 's/.*s "//; s/"$//' \
| awk '!seen[$0]++')"
[[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.'
jq -cn \
--arg active "$active" \
--arg degraded "$degraded" \
--argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \
'{profiles: $profiles, active: $active, degraded: $degraded, error: ""}'
}
cmd_set() {
local profile="${1:-}"
# Constrained rather than passed through: this reaches a system service.
[[ "$profile" =~ ^[a-z-]+$ ]] || {
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
return 2
}
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
| head -2 >&2
return 0
}
case "${1:-list}" in
list) cmd_list ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-power-profile [list|set <profile>]\n' >&2; exit 2 ;;
esac
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Device security facts, as JSON.
#
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
# disk encryption, SELinux mode and the firewall are set in firmware, at install
# time, or by system policy -- none of them is a desktop preference, and a
# settings app that offered to toggle them would either fail or do something
# far-reaching from a switch that looks like any other.
#
# What it is for is answering "is this machine set up the way I think it is",
# which is the question GNOME's Device Security panel exists to answer and which
# otherwise needs five commands and root.
#
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
# the UI can highlight what deserves attention without hard-coding the meaning
# of each string. Anything that cannot be determined reports "Unknown" with
# ok:false rather than guessing, because a security readout that quietly reports
# "fine" when it failed to look is worse than no readout.
set -uo pipefail
fact() {
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
'{label: $label, value: $value, ok: $ok, detail: $detail}'
}
facts=()
# ── Secure Boot ──────────────────────────────────────────────────────────────
if command -v mokutil >/dev/null 2>&1; then
case "$(mokutil --sb-state 2>/dev/null)" in
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
esac
elif [[ -d /sys/firmware/efi ]]; then
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
else
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
fi
# ── TPM ──────────────────────────────────────────────────────────────────────
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
if [[ -n "$tpm_major" ]]; then
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
elif [[ -e /sys/class/tpm/tpm0 ]]; then
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
else
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
fi
# ── Disk encryption ──────────────────────────────────────────────────────────
# Counts LUKS mappings rather than naming them: which volume is encrypted is
# more detail than this readout needs, and device names are not meaningful here.
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
if (( crypt_count > 0 )); then
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
else
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
fi
# ── SELinux ──────────────────────────────────────────────────────────────────
if command -v getenforce >/dev/null 2>&1; then
case "$(getenforce 2>/dev/null)" in
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
esac
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
else
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
fi
fi
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
@@ -11,6 +11,11 @@
# Terminals are the notable exception -- they predate the standard and carry # Terminals are the notable exception -- they predate the standard and carry
# their own palettes. kitty is handled here. # their own palettes. kitty is handled here.
# #
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead.
#
# panama-theme-apps dark|light # panama-theme-apps dark|light
# #
# kitty gets it twice: the generated include file so terminals opened later # kitty gets it twice: the generated include file so terminals opened later
@@ -26,6 +31,84 @@ case "$scheme" in
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;; *) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac esac
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
# open session changes now instead of at next launch -- tmux applies a
# source-file to every attached client immediately.
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
status_tmux="skipped"
if [[ -r "$tmux_theme" ]]; then
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
status_tmux="written"
# Only if a server is actually running; `tmux source-file` would
# otherwise start one just to theme it.
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
&& status_tmux="applied to running sessions"
fi
else
rm -f "$tmux_dir/current-theme.conf.tmp"
status_tmux="failed"
fi
fi
# ── btop ─────────────────────────────────────────────────────────────────────
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
# rewrites the whole file on exit -- so the config is not symlinked into Panama
# and not replaced wholesale here; just this one value is edited, and btop keeps
# it on the next write.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colours until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
btop_theme="tokyonight-moon"
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
status_btop="skipped"
if [[ -w "$btop_conf" ]]; then
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
status_btop="written"
else
status_btop="failed"
fi
fi
# ── GTK ──────────────────────────────────────────────────────────────────────
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
# naming a theme that does not exist makes GTK fall back to its light default --
# which made dark mode silently produce light windows.
if [[ "$scheme" == "light" ]]; then
gtk_theme="adw-gtk3"
prefer_dark=0
else
gtk_theme="adw-gtk3-dark"
prefer_dark=1
fi
status_gtk="skipped"
for gtk_version in 3.0 4.0; do
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
template="$gtk_dir/settings.ini.template"
[[ -r "$template" ]] || continue
# Written atomically: a GTK application starting mid-write would otherwise
# read a truncated file and fall back to defaults.
if sed -e "s/@GTK_THEME@/$gtk_theme/" -e "s/@PREFER_DARK@/$prefer_dark/" "$template" \
>"$gtk_dir/settings.ini.tmp" 2>/dev/null \
&& mv "$gtk_dir/settings.ini.tmp" "$gtk_dir/settings.ini" 2>/dev/null; then
status_gtk="written"
else
rm -f "$gtk_dir/settings.ini.tmp"
status_gtk="failed"
fi
done
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"
@@ -54,4 +137,13 @@ if [[ -r "$theme_file" ]]; then
fi fi
fi fi
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty" # Every target reports what actually happened. A helper that says only
# "kitty: applied" while silently skipping three other applications is how a
# half-applied theme goes unnoticed.
jq -cn \
--arg scheme "$scheme" \
--arg kitty "$status_kitty" \
--arg gtk "$status_gtk" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera.
#
# GNOME's Wi-Fi panel has this and it is the single most-used thing in it.
# The payload is the de-facto WIFI: URI that Android and iOS both scan:
#
# WIFI:T:WPA;S:<ssid>;P:<passphrase>;H:<hidden>;;
#
# HANDLING THE PASSPHRASE
#
# This image contains the network password in machine-readable form. Anyone who
# can read the file can read the password, so:
#
# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it
# never reaches disk and disappears at logout -- not /tmp, which is shared;
# * it is created with umask 077;
# * the passphrase is never printed, never passed as an argument (argv is
# world-readable via /proc), and never appears in an error message.
#
# It is piped to qrencode on stdin for that last reason.
#
# Usage:
# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]}
# panama-wifi-qr qr <name> -> {"path":"/run/user/…/….png"}
set -uo pipefail
emit_error() {
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
cmd_list() {
local rows=() name ssid psk
while IFS= read -r name; do
[[ -n "$name" ]] || continue
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || ssid="$name"
# Only networks whose passphrase this user can actually read are
# shareable. An enterprise network has no passphrase to share at all,
# and a QR code for one would simply not work.
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
'{name: $name, ssid: $ssid, shareable: $shareable}')")
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
| awk -F: '$2 == "802-11-wireless" { print $1 }')
if [[ ${#rows[@]} -eq 0 ]]; then
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
return 0
fi
printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
}
# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID
# containing a semicolon would otherwise terminate the field early and produce a
# QR code for a different network entirely.
#
# Trailing newlines are stripped as well. nmcli terminates every value with one,
# and left in place it lands INSIDE the payload -- the code still decodes here,
# but a newline in the middle of a WIFI: URI is not something every phone's
# scanner tolerates, and the failure would look like "the QR code just does not
# work on my phone".
escape_field() {
sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \
| tr -d '\n'
}
cmd_qr() {
local name="${1:-}"
[[ -n "$name" ]] || emit_error 'no network named'
local ssid hidden psk_file payload_file out_dir out_file
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
umask 077
mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory'
chmod 700 "$out_dir" 2>/dev/null || true
# Named after the connection, hashed, so repeated shares reuse one file
# instead of accumulating images of the password.
out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png"
# Built in a file rather than a variable that could be echoed, and piped to
# qrencode on stdin so the passphrase never appears in argv.
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
trap 'rm -f "$payload_file"' RETURN
{
printf 'WIFI:T:WPA;S:'
printf '%s' "$ssid" | escape_field
printf ';P:'
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
printf ';H:%s;;' "$hidden"
} >"$payload_file"
if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then
emit_error "Could not generate a QR code for \"$name\"."
fi
chmod 600 "$out_file" 2>/dev/null || true
jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}'
}
case "${1:-list}" in
list) cmd_list ;;
qr) shift; cmd_qr "${1:-}" ;;
*) printf 'usage: panama-wifi-qr [list|qr <name>]\n' >&2; exit 2 ;;
esac
+15 -3
View File
@@ -78,9 +78,21 @@ Singleton {
root.lastError = ""; root.lastError = "";
const scheme = root.dark ? "prefer-dark" : "prefer-light"; const scheme = root.dark ? "prefer-dark" : "prefer-light";
// Adwaita's light and dark are the same theme; only the preference and
// the -dark suffix differ, so applications that honour either agree. // adw-gtk3, not Adwaita. This is the bug that made dark mode look
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita"; // broken while light mode looked fine:
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
const commands = [ const commands = [
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme], ["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
@@ -0,0 +1,54 @@
pragma Singleton
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
// firewall.
//
// Nothing here is a preference. These are set in firmware, at install time, or
// by system policy, and a settings app that offered to change them from a
// switch would either fail or do something far-reaching from a control that
// looks like every other control. What this answers is "is this machine set up
// the way I think it is", which otherwise takes five commands and root.
//
// Read on demand. None of these can change while the desktop is running,
// short of a reboot.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
// [{ label, value, ok, detail }]
property var facts: []
property bool scanned: false
// The facts that are not in their reassuring state. The page leads with the
// count so a machine that is entirely fine says so in one line instead of
// making the user read five rows to find out.
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("DeviceSecurity: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -0,0 +1,66 @@
pragma Singleton
// What input hardware this machine actually has.
//
// Exists so pages can hide controls for hardware that is not present. A
// touchpad card on a desktop is not merely useless -- it is misleading, because
// every switch on it appears to work: the preference is stored and Hyprland
// accepts the option for a device class it has no member of. The user is left
// toggling settings that will never affect anything, with nothing to say so.
//
// Touchpads are identified by name. libinput exposes them through the same
// "mice" list as everything else that reports pointer motion, and Hyprland
// passes the device name through, so an Elan or Synaptics touchpad arrives as
// something like "elan-touchpad". There is no device-class field to consult.
//
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
Singleton {
id: root
property var mice: []
property var keyboards: []
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
running: true
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}
}
}
}
Connections {
target: Hyprland
function onRawEvent(event: var): void {
if (event.name === "device" || event.name === "configreloaded")
root.refresh();
}
}
}
@@ -0,0 +1,83 @@
pragma Singleton
// The login keyring's lock state.
//
// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is
// under GNOME. What a bare Hyprland session lacks is anywhere to see when that
// has stopped being true.
//
// It stops being true rarely but expensively: gnome-keyring-daemon can crash,
// D-Bus activates a replacement, and the replacement never received the login
// password -- so the keyring is locked in the middle of a session that unlocked
// it correctly. Nothing announces this. What the user sees instead is a mail
// account that will not authenticate, a git push that cannot find its key, or
// an integration reporting "not configured", none of which mention keyrings.
//
// Checked on demand and after an unlock, not polled: the state changes only
// when a daemon dies or a password is entered.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring"
property bool available: false
property bool locked: false
property bool scanned: false
property bool unlocking: false
property string lastError: ""
// "pam" when the daemon that holds the keyring is the one PAM started at
// login, "dbus" when it is a D-Bus-activated replacement -- which is the
// signature of the crash case, and worth showing, because a dbus daemon
// that is currently unlocked was unlocked by hand and will not survive.
property string daemon: ""
readonly property bool replacementDaemon: root.daemon === "dbus"
function refresh(): void {
if (!query.running)
query.running = true;
}
// Raises the standard password dialog. The password never passes through
// Panama -- the Secret Service prompts, the same way it does under GNOME.
function unlock(): void {
if (root.unlocking)
return;
root.unlocking = true;
unlockProcess.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.locked = parsed.locked === true;
root.daemon = String(parsed.daemon ?? "");
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.available = false;
root.lastError = "Could not read the keyring helper's output.";
console.warn("Keyring: could not parse helper output:", error);
}
root.scanned = true;
}
Process {
id: query
command: [root.helperPath, "status"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
id: unlockProcess
command: [root.helperPath, "unlock"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
onExited: root.unlocking = false
}
}
@@ -0,0 +1,47 @@
pragma Singleton
// What this machine is: model, processor, memory, disk, OS, kernel.
//
// Read once, on demand. None of it changes while the desktop is running except
// free disk space, and About is not a monitor -- the vitals readout on the Home
// page is where live figures belong.
//
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
// readout, and naming them again would be a second source of truth that could
// disagree with the first; the About page joins the two instead.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
// [{ label, value }] in display order.
property var facts: []
property bool scanned: false
function refresh(): void {
if (!query.running)
query.running = true;
}
Process {
id: query
command: [root.helperPath]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.facts = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.facts = [];
console.warn("MachineInfo: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
}
@@ -0,0 +1,95 @@
pragma Singleton
// Online accounts, via GNOME Online Accounts.
//
// The daemon already runs in this session -- gvfs activates it, and accounts
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
// are D-Bus objects anything may read and modify. So listing, per-service
// toggles, and removal all happen here, natively.
//
// Signing in is the exception, and only for OAuth providers. The daemon's
// AddAccount takes credentials as an argument rather than obtaining them, and
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
// panel and the user comes straight back here.
//
// Read on demand and after every change: accounts are added and removed by
// people, not by the system, so there is nothing to poll for.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }]
property var accounts: []
property bool scanned: false
property bool busy: false
property string lastError: ""
// Accounts whose stored credentials have stopped working -- an expired
// token, a changed password. GOA knows, and nothing outside its own panel
// ever says so, which is how an account quietly stops syncing for weeks.
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
readonly property bool available: root.lastError === ""
function refresh(): void {
if (!list.running)
list.running = true;
}
// Enabling a service clears GOA's "disabled" flag; the helper owns that
// inversion so the UI can speak in terms of what is on.
function setService(path: string, service: string, enabled: bool): void {
if (root.busy)
return;
root.busy = true;
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
write.running = true;
}
function remove(path: string): void {
if (root.busy)
return;
root.busy = true;
write.command = [root.helperPath, "remove", path];
write.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.accounts = [];
root.lastError = "Could not read the accounts helper's output.";
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: write
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
// Re-read rather than assuming the write landed: GOA may refuse, and a
// toggle that sprang back is the honest outcome.
onExited: {
root.busy = false;
root.refresh();
}
}
}
@@ -0,0 +1,107 @@
pragma Singleton
// The system power profile: power-saver, balanced, or performance.
//
// The same daemon GNOME's Power panel drives. On Fedora 44 the implementation
// is tuned-ppd rather than power-profiles-daemon, but it serves the same
// net.hadess.PowerProfiles interface -- so this talks to the interface, not to
// either binary. powerprofilesctl is not installed here at all.
//
// Not a stored preference. The profile lives in the daemon, survives Panama
// restarts, and can be changed by anything else on the system; keeping a copy
// in settings.json would mean restoring a value the daemon had moved past.
// Same reasoning as monitor brightness.
//
// Read on demand and after each change. The daemon does emit PropertiesChanged,
// but subscribing to it would mean holding a bus connection open for a value
// that changes only when someone chooses it.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-power-profile"
property var profiles: []
property string active: ""
property bool scanned: false
property bool busy: false
property string lastError: ""
// Non-empty when the machine cannot actually deliver the profile it is set
// to -- thermal throttling, or a laptop running on battery. Worth showing,
// because otherwise "performance" is a claim the hardware is not honouring.
property string degraded: ""
readonly property bool available: root.profiles.length > 0
// Presentation lives here rather than in the page so the Control Center and
// Settings cannot disagree about what a profile is called.
function label(profile: string): string {
switch (profile) {
case "power-saver": return "Power Saver";
case "balanced": return "Balanced";
case "performance": return "Performance";
default: return profile;
}
}
function detail(profile: string): string {
switch (profile) {
case "power-saver": return "Reduces performance to save energy and run quieter";
case "balanced": return "Standard behaviour, scaling up only when needed";
case "performance": return "Holds higher clocks, using more power and making more noise";
default: return "";
}
}
function refresh(): void {
if (!query.running)
query.running = true;
}
function set(profile: string): void {
if (root.busy || profile === root.active)
return;
root.busy = true;
apply.command = [root.helperPath, "set", profile];
apply.running = true;
}
Process {
id: query
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : [];
root.active = String(parsed.active ?? "");
root.degraded = String(parsed.degraded ?? "");
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.profiles = [];
root.lastError = "Could not read the power profile helper's output.";
console.warn("PowerProfiles: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: apply
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
// Re-read rather than assuming: the daemon may refuse, or may land on a
// different profile than the one asked for.
onExited: {
root.busy = false;
root.refresh();
}
}
}
@@ -33,6 +33,9 @@ Singleton {
"idle": "power", "idle": "power",
"accessibility": "accessibility", "accessibility": "accessibility",
"input": "shortcuts", "input": "shortcuts",
"pointer": "mouse",
"touchpad": "mouse",
"multitasking": "desktop",
"weather": "appearance", "weather": "appearance",
"notifications": "notifications", "notifications": "notifications",
"capture": "screen-intelligence" "capture": "screen-intelligence"
@@ -92,7 +92,7 @@ Singleton {
} }
function openSettings(page: string): void { function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"]; const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage); DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true; root.settingsOpen = true;
@@ -0,0 +1,99 @@
pragma Singleton
// The system locale.
//
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
// singleton of that name is silently shadowed by it. Every binding then reads
// properties off the wrong thing and the page renders empty with only
// "Cannot read property of undefined" to show for it.
//
// Changing it is privileged: localectl goes through polkit, which prompts
// (hyprpolkitagent serves that in this session). It also only applies to
// programs started afterwards, so `pendingRestart` goes true once a change is
// accepted and the page says a sign-out is needed. Reporting the new locale as
// simply "in effect" would be wrong -- almost nothing on screen would be using
// it yet.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
// [{ value, label, detail }]
property var locales: []
property string current: ""
property bool scanning: false
property string lastError: ""
// True once a change has been accepted but the session has not restarted,
// so the UI can stop claiming the new locale is already in use.
property bool pendingRestart: false
readonly property string currentLabel: {
const match = root.locales.find(locale => locale.value === root.current);
return match ? match.label : root.current;
}
function refresh(): void {
if (root.scanning)
return;
root.scanning = true;
readCurrent.running = true;
list.running = true;
}
function set(value: string): void {
if (value === root.current)
return;
apply.command = [root.helperPath, "set", value];
apply.pendingValue = value;
apply.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.locales = Array.isArray(parsed) ? parsed : [];
} catch (error) {
root.locales = [];
console.warn("SystemLocale: could not parse the locale list:", error);
}
root.scanning = false;
}
}
}
Process {
id: readCurrent
command: [root.helperPath, "get"]
stdout: StdioCollector {
onStreamFinished: root.current = this.text.trim()
}
}
Process {
id: apply
property string pendingValue: ""
// A refused change -- polkit dismissed, or an unknown locale -- must not
// move the UI. The value is only adopted on a zero exit.
onExited: code => {
if (code === 0) {
root.current = apply.pendingValue;
root.pendingRestart = true;
root.lastError = "";
} else {
root.lastError = "The system did not accept that language. It may have needed a password.";
}
}
}
}
@@ -497,18 +497,35 @@ Singleton {
].indexOf(panel) >= 0; ].indexOf(panel) >= 0;
} }
function openGnomePanel(panel: string): bool { // `subpage` reaches the panels GNOME 50 nests under System -- users,
// about, datetime, region -- which its own desktop entries open as
// `gnome-control-center system users`. Without it, a row labelled "Users"
// lands on System's front page and leaves the user to navigate, which is
// most of the way to a broken button.
function openGnomePanel(panel: string, subpage: string): bool {
if (!root.isGnomePanelAllowed(panel)) { if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available."; root.lastError = "That GNOME Settings panel is not available.";
return false; return false;
} }
const command = ["gnome-control-center", panel];
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
command.push(subpage);
Quickshell.execDetached({ Quickshell.execDetached({
command: ["gnome-control-center", panel], command: command,
environment: { "XDG_CURRENT_DESKTOP": "GNOME" } environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
}); });
return true; return true;
} }
// Only System nests panels, and only these. Read off the Exec lines of the
// gnome-*-panel desktop entries rather than guessed, for the same reason
// the panel list above was.
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
if (panel !== "system")
return false;
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
}
function openApplication(id: string): bool { function openApplication(id: string): bool {
const commands = { const commands = {
"nextcloud": ["nextcloud"], "nextcloud": ["nextcloud"],
@@ -0,0 +1,99 @@
pragma Singleton
// A QR code for a saved Wi-Fi network, so a guest can join by pointing a phone
// at the screen. GNOME's Wi-Fi panel has this and it is the most-used thing in
// it; reading a passphrase aloud is the alternative.
//
// The generated image contains the network password in machine-readable form,
// so the helper writes it under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at
// logout -- rather than anywhere persistent. Nothing here ever holds the
// passphrase itself; this service only ever sees a file path.
//
// Generated on demand. Producing a QR for every saved network up front would
// mean writing images of passwords nobody asked to see.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-wifi-qr"
// [{ name, ssid, shareable }]
property var networks: []
property bool scanned: false
property string lastError: ""
// The network whose code is on screen, and where its image is. Empty when
// nothing is being shared.
property string sharing: ""
property string imagePath: ""
readonly property var shareable: root.networks.filter(n => n.shareable)
function refresh(): void {
if (!list.running)
list.running = true;
}
function share(name: string): void {
if (generate.running)
return;
// Cache-bust: the helper reuses one file per network, so a QML Image
// pointed at the same path would keep showing the previous render.
root.imagePath = "";
root.sharing = name;
generate.command = [root.helperPath, "qr", name];
generate.running = true;
}
function stopSharing(): void {
root.sharing = "";
root.imagePath = "";
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.networks = Array.isArray(parsed.networks) ? parsed.networks : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.networks = [];
root.lastError = "Could not read the Wi-Fi helper's output.";
console.warn("WifiShare: could not parse helper output:", error);
}
root.scanned = true;
}
}
}
Process {
id: generate
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
const path = String(parsed.path ?? "");
const error = String(parsed.error ?? "");
if (error !== "" || path === "") {
root.lastError = error !== "" ? error : "No QR code was produced.";
root.sharing = "";
return;
}
root.lastError = "";
root.imagePath = path;
} catch (error) {
root.lastError = "Could not read the generated QR code's path.";
root.sharing = "";
console.warn("WifiShare: could not parse helper output:", error);
}
}
}
}
}
@@ -0,0 +1,30 @@
# Tokyo Night Day for tmux -- the light counterpart to tokyonight-moon.conf.
#
# Same structure, same roles, Day's palette. Mapped role for role rather than by
# hue: #1b1d2b was text drawn ON an accent block, so it becomes the LIGHT ground
# rather than a dark colour, or the status bar would be dark text on a dark
# accent in light mode.
#
# The accents are Day's DARKER variants, not its standard ones. Measured against
# the panel, the standard #2e7de9 and #9854f1 give 2.74:1 and 2.94:1 -- under
# the 3:1 floor for text. These give 4.42:1 and 4.17:1, and 5.01:1 / 4.73:1 for
# the light text drawn on top of them in the inverted blocks.
set -g mode-style "fg=#1c5bb8,bg=#d0d5e3"
set -g message-style "fg=#1c5bb8,bg=#d0d5e3"
set -g message-command-style "fg=#1c5bb8,bg=#d0d5e3"
set -g pane-border-style "fg=#1c5bb8"
set -g pane-active-border-style "fg=#7847bd"
set -g status-style "fg=#7847bd,bg=#d0d5e3"
set -g status-bg "#e1e2e7"
set -g status-left "#[fg=#e1e2e7,bg=#7847bd,bold] #S #[fg=#e1e2e7,bg=#7847bd,nobold,nounderscore,noitalics]"
set -g status-right "#[fg=#e1e2e7,bg=#1c5bb8,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3] #{prefix_highlight} #[fg=#d0d5e3,bg=#d0d5e3]#[fg=#7847bd,bg=#d0d5e3] %Y/%m/%d %I:%M %p #[fg=#1c5bb8,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#e1e2e7,bg=#7847bd,bold,italics] #h "
setw -g window-status-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#1c5bb8,bg=#d0d5e3] #I #W #F #[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]"
setw -g window-status-current-format "#[fg=#e1e2e7,bg=#d0d5e3,nobold,nounderscore,noitalics]#[fg=#7847bd,bg=#d0d5e3,bold] #I #W #F #[fg=#d0d5e3,bg=#d0d5e3,nobold,nounderscore,noitalics]"
setw -g window-status-separator ""
@@ -0,0 +1,25 @@
# Tokyo Night Moon for tmux -- the palette Panama ships.
#
# Extracted from tmux.conf so the two schemes can be swapped. tmux.conf sources
# current-theme.conf, which panama-theme-apps generates from one of these.
#
# Every colour here was already in tmux.conf; this file is that block verbatim.
set -g mode-style "fg=#82aaff,bg=#3b4261"
set -g message-style "fg=#82aaff,bg=#3b4261"
set -g message-command-style "fg=#82aaff,bg=#3b4261"
set -g pane-border-style "fg=#82aaff"
set -g pane-active-border-style "fg=#b172b0"
set -g status-style "fg=#b172b0,bg=#3b4261"
set -g status-bg "#222436"
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-separator ""
+4 -18
View File
@@ -19,24 +19,10 @@ bind C-Space send-prefix
# Set status bar # Set status bar
#set -g status-bg pink #set -g status-bg pink
# Tokyo Night Moon color palette # Tokyo Night Moon color palette
set -g mode-style "fg=#82aaff,bg=#3b4261" # Colours live in themes/ and are generated into current-theme.conf by
# panama-theme-apps, which regenerates it whenever the desktop colour scheme
set -g message-style "fg=#82aaff,bg=#3b4261" # changes. -q so a fresh checkout without the generated file still starts.
set -g message-command-style "fg=#82aaff,bg=#3b4261" source-file -q "~/.config/tmux/current-theme.conf"
set -g pane-border-style "fg=#82aaff"
set -g pane-active-border-style "fg=#b172b0"
set -g status-style "fg=#b172b0,bg=#3b4261"
set -g status-bg "#222436"
set -g status-left "#[fg=#1b1d2b,bg=#b172b0,bold] #S #[fg=#1b1d2b,bg=#b172b0,nobold,nounderscore,noitalics]"
set -g status-right "#[fg=#1b1d2b,bg=#82aaff,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261] #{prefix_highlight} #[fg=#3b4261,bg=#3b4261]#[fg=#b172b0,bg=#3b4261] %Y/%m/%d %I:%M %p #[fg=#82aaff,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#1b1d2b,bg=#b172b0,bold,italics] #h "
setw -g window-status-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#82aaff,bg=#3b4261] #I #W #F #[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-current-format "#[fg=#1b1d2b,bg=#3b4261,nobold,nounderscore,noitalics]#[fg=#b172b0,bg=#3b4261,bold] #I #W #F #[fg=#3b4261,bg=#3b4261,nobold,nounderscore,noitalics]"
setw -g window-status-separator ""
# Increase scrollback buffer # Increase scrollback buffer
set -g history-limit 50000 set -g history-limit 50000
@@ -0,0 +1,87 @@
# Tokyo Night Day — the light counterpart to tokyonight-moon.toml.
#
# Same schema and the same role for every colour; only the palette differs, so
# the launcher keeps its identity when the desktop switches to light rather
# than becoming a different-looking application.
#
# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf
# so the launcher and the terminal cannot drift apart.
[meta]
version = 1
name = "Tokyo Night Day"
description = "Tokyo Night Day, matched to the Panama Hyprland desktop."
variant = "light"
[colors.core]
background = "#e1e2e7" # bg
foreground = "#3760bf" # fg
secondary_background = "#d0d5e3" # bg_dark, a shade below the ground
border = "#a8aecb" # a visible hairline on a light ground
accent = "#2e7de9" # blue
accent_foreground = "#e1e2e7"
[colors.accents]
blue = "#2e7de9"
green = "#587539"
magenta = "#9854f1"
orange = "#b15c00"
purple = "#7847bd"
red = "#f52a65"
yellow = "#8c6c3e"
cyan = "#007197"
[colors.main_window]
border = "#a8aecb"
footer = { background = "colors.core.secondary_background" }
[colors.settings_window]
border = "#c4c8da"
[colors.shortcut]
border = "colors.core.border"
[colors.text]
default = "colors.core.foreground"
muted = "#6172b0" # fg_dark, 4.0:1 on the background
danger = "#f52a65"
success = "#587539"
placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read
selection = { background = "#2e7de9", foreground = "#e1e2e7" }
[colors.text.links]
default = "#2e7de9"
visited = "#9854f1"
[colors.input]
border = "#c4c8da"
border_focus = "#2e7de9"
border_error = "#f52a65"
[colors.button.primary]
background = "#d4d6e4" # bg_highlight
foreground = "#3760bf"
hover = { background = "#c4c8da" }
focus = { outline = "colors.core.accent" }
[colors.list.item.hover]
foreground = "#3760bf"
secondary_foreground = "#6172b0"
[colors.list.item.selection]
background = "#c3cdf0" # blue side of the Prism selection, lightened
foreground = "#3760bf"
secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened
secondary_foreground = "#3760bf"
[colors.grid.item]
background = "#d7d9e5"
hover = { outline = "#2e7de9" }
selection = { outline = "#9854f1" }
[colors.scrollbars]
background = "#c4c8da"
[colors.loading]
bar = "#2e7de9"
spinner = "#3760bf"
+6 -4
View File
@@ -6,12 +6,14 @@
// and emoji-copy extensions, so its clipboard and emoji views are bound // and emoji-copy extensions, so its clipboard and emoji views are bound
// directly in hypr/keybinds.lua. // directly in hypr/keybinds.lua.
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so // Vicinae 0.26 selects a theme per system appearance, and now there is a
// the launcher never flashes or falls back to its stock palette while the // theme for each: the launcher follows Panama's light/dark setting instead of
// desktop appearance is being initialized. // staying dark on a light desktop. Both are authored here rather than using
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
// and Day variants the rest of this desktop uses.
"theme": { "theme": {
"light": { "light": {
"name": "tokyonight-moon", "name": "tokyonight-day",
"icon_theme": "auto" "icon_theme": "auto"
}, },
"dark": { "dark": {
@@ -1,11 +1,11 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Panama Settings Name=Settings
GenericName=System Settings GenericName=System Settings
Comment=Configure the Panama Hyprland desktop Comment=Configure this desktop
Exec=qs ipc call settings open Exec=qs ipc call settings open
Icon=panama-settings Icon=panama-settings
Terminal=false Terminal=false
StartupNotify=false StartupNotify=false
Categories=Settings; Categories=Settings;
Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services; Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts;
+84 -10
View File
@@ -70,6 +70,73 @@ for dir in "${dirs[@]}"; do
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir" log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
done done
# tmux.conf ends with a source-file of current-theme.conf, generated from the
# colour scheme rather than committed. Seed it so a fresh checkout starts themed
# -- the source-file is -q, so a missing file is silent, which would leave tmux
# unstyled with nothing to explain it.
TMUX_THEME="$PANAMA_DOT/tmux/current-theme.conf"
if [ -e "$TMUX_THEME" ]; then
log "Keeping existing tmux theme at $TMUX_THEME"
elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
tmux_scheme="dark"
tmux_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$tmux_prefs" ]; then
tmux_stored="$(jq -r '.colorScheme // "dark"' "$tmux_prefs" 2>/dev/null || echo dark)"
[ "$tmux_stored" = "light" ] && tmux_scheme="light"
fi
[ "$tmux_scheme" = "light" ] && tmux_name="tokyonight-day" || tmux_name="tokyonight-moon"
cp "$PANAMA_DOT/tmux/themes/$tmux_name.conf" "$TMUX_THEME"
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
# color_theme line in place.
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
mkdir -p "$BTOP_THEME_DIR"
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
[ -e "$btop_theme_src" ] || continue
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
if [ -L "$btop_theme_dst" ]; then
rm "$btop_theme_dst"
fi
if [ -e "$btop_theme_dst" ]; then
log "Keeping existing btop theme at $btop_theme_dst"
else
ln -s "$btop_theme_src" "$btop_theme_dst"
log "Linked btop theme → $btop_theme_dst"
fi
done
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
# template rather than layered. Without this, a fresh checkout has a template
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
# panama-theme-apps rewrites both files on every scheme change after this.
for gtk_version in 3.0 4.0; do
gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template"
gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini"
[ -r "$gtk_template" ] || continue
if [ -e "$gtk_settings" ]; then
log "Keeping existing GTK settings at $gtk_settings"
else
gtk_scheme="dark"
gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
if [ -r "$gtk_prefs" ]; then
gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)"
[ "$gtk_stored" = "light" ] && gtk_scheme="light"
fi
if [ "$gtk_scheme" = "light" ]; then
gtk_name="adw-gtk3"; gtk_dark=0
else
gtk_name="adw-gtk3-dark"; gtk_dark=1
fi
sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \
"$gtk_template" > "$gtk_settings"
log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings"
fi
done
# kitty.conf ends with `include current-theme.conf`, and that file is generated # kitty.conf ends with `include current-theme.conf`, and that file is generated
# from the desktop colour scheme rather than committed -- it is machine state. # from the desktop colour scheme rather than committed -- it is machine state.
# A fresh checkout therefore has no such file, and kitty starts by complaining # A fresh checkout therefore has no such file, and kitty starts by complaining
@@ -95,19 +162,26 @@ fi
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of # from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
# the launcher config and expose only that file at Vicinae's runtime path. # the launcher config and expose only that file at Vicinae's runtime path.
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes" VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
mkdir -p "$VICINAE_THEME_DIR" mkdir -p "$VICINAE_THEME_DIR"
if [ -L "$VICINAE_THEME" ]; then # Every authored theme, not just the dark one: vicinae.json selects a theme per
rm "$VICINAE_THEME" # system appearance, so linking only Moon left the launcher falling back to its
fi # stock palette whenever Panama was in light mode.
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
[ -e "$theme_src" ] || continue
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
if [ -e "$VICINAE_THEME" ]; then if [ -L "$theme_dst" ]; then
log "Keeping existing Vicinae theme at $VICINAE_THEME" rm "$theme_dst"
else fi
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
log "Linked Tokyo Night Moon theme → $VICINAE_THEME" if [ -e "$theme_dst" ]; then
fi log "Keeping existing Vicinae theme at $theme_dst"
else
ln -s "$theme_src" "$theme_dst"
log "Linked Vicinae theme → $theme_dst"
fi
done
# Panama-native applications live in the user data directory so launchers can # Panama-native applications live in the user data directory so launchers can
# discover them alongside system desktop entries. Keep each authored file in # discover them alongside system desktop entries. Keep each authored file in
+34 -13
View File
@@ -34,15 +34,35 @@ mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is # Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and # connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
# must never be probed at all. # must never be probed at all.
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
# exists, and the `ddc` line answers nothing on a DP connector even though it
# still resolves -- so the aux argument here is what a real DisplayPort monitor
# looks like, and omitting it is what HDMI and DVI look like.
make_connector() { make_connector() {
local name="$1" bus="$2" status="$3" local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
mkdir -p "$fixture/drm/$name" local device="$fixture/devices/$name"
printf '%s\n' "$status" >"$fixture/drm/$name/status"
mkdir -p "$fixture/i2c/i2c-$bus" # /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc" # this fixture mirrors that rather than using a plain directory. It matters:
# `find` does not follow the path it is given, so code that searches the
# unresolved path finds nothing while appearing to work anywhere the entry
# happens to be a real directory.
mkdir -p "$device"
printf '%s\n' "$status" >"$device/status"
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
mkdir -p "$fixture/drm"
ln -sfn "$device" "$fixture/drm/$name"
return 0
} }
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
# at all, which is exactly the bug this pins down.
make_connector card1-DP-1 4 disconnected make_connector card1-DP-1 4 disconnected
make_connector card1-DP-2 5 connected make_connector card1-DP-2 5 connected 9
make_connector card1-DP-3 6 connected make_connector card1-DP-3 6 connected
make_connector card1-HDMI-A-1 7 disconnected make_connector card1-HDMI-A-1 7 disconnected
@@ -75,7 +95,7 @@ for arg in "$@"; do
done done
case "$bus" in case "$bus" in
5) printf 'VCP 10 C 120 200\n'; exit 0 ;; 9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
*) exit 1 ;; *) exit 1 ;;
esac esac
STUB STUB
@@ -103,8 +123,9 @@ jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \ [[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|| fail "the connector name must match Hyprland's output name: $listing" || fail "the connector name must match Hyprland's output name: $listing"
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \ # The AUX bus, not the EDID bus its `ddc` symlink points at.
|| fail "the display was mapped to the wrong I2C bus: $listing" [[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
# 120 of a maximum of 200 is 60%. # 120 of a maximum of 200 is 60%.
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \ [[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
@@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
fi fi
# ── Writes scale to the reported maximum ───────────────────────────────────── # ── Writes scale to the reported maximum ─────────────────────────────────────
run_helper set 5 40 run_helper set 9 40
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \ [[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")" || fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
run_helper set 5 500 run_helper set 9 500
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \ [[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")" || fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
# ── No I2C access explains itself ──────────────────────────────────────────── # ── No I2C access explains itself ────────────────────────────────────────────
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Every enum backed by a Hyprland option must offer values that option accepts.
#
# This exists because of a bug that shipped: followMouse offered 0/1/2 labelled
# "Never" / "Click to focus" / "Sloppy focus", while Hyprland's actual mapping
# is disabled=0, follow=1, detached=2, separate=3. The desktop was labelled
# "Click to focus" and was in fact following the pointer, the way to GET click
# to focus was to choose "Never", and value 3 did not exist in the UI at all.
#
# Nothing detects that. The compositor accepts 1, reads back 1, and verification
# passes -- the value is valid, it just means something else entirely. The only
# authority on what each number MEANS is the compositor, which publishes it:
#
# hyprctl descriptions -> { "name": "input:follow_mouse",
# "map": [{"separate":3},{"detached":2},...] }
#
# So this checks the schema's enum values against that map, and against the
# min/max range for mapped options that have no named map.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'enum hypr map contract: %s\n' "$1" >&2
exit 1
}
command -v hyprctl >/dev/null 2>&1 || { printf 'enum hypr map contract: SKIP (no compositor)\n'; exit 0; }
descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprctl descriptions'
jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \
|| fail 'hyprctl descriptions did not return a list'
# Pull every enum entry that carries a hypr option, as: key<TAB>option<TAB>values
entries="$(python3 - "$schema" <<'PY'
import re, sys
text = open(sys.argv[1]).read()
# Each schema entry is a brace-delimited block starting with `key:`.
for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, re.S):
name, body = block
if 'type: "enum"' not in body:
continue
option = re.search(r'option:\s*"([^"]+)"', body)
if not option:
continue
values = re.findall(r'value:\s*(-?\d+)', body)
if not values:
continue
print(f"{name}\t{option.group(1)}\t{','.join(values)}")
PY
)"
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
checked=0
while IFS=$'\t' read -r key option values; do
[[ -n "$key" ]] || continue
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
[[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish"
map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")"
IFS=',' read -ra wanted <<<"$values"
for value in "${wanted[@]}"; do
if [[ -n "$map_values" ]]; then
grep -qx "$value" <<<"$(tr ',' '\n' <<<"$map_values")" \
|| fail "$key offers $value for $option, which the compositor's map does not contain (it publishes: $map_values). A value outside the map is accepted and read back unchanged, so nothing else notices -- it simply means something other than the label says."
else
min="$(jq -r '.min // empty' <<<"$entry")"
max="$(jq -r '.max // empty' <<<"$entry")"
if [[ -n "$min" && -n "$max" ]]; then
(( value >= min && value <= max )) \
|| fail "$key offers $value for $option, outside the compositor's range $min..$max"
fi
fi
done
# Every value the compositor names should be offered. A missing one is a
# capability the user simply cannot reach -- value 3 was missing here.
if [[ -n "$map_values" ]]; then
while read -r published; do
[[ -n "$published" ]] || continue
grep -qx "$published" <<<"$(tr ',' '\n' <<<"$values")" \
|| fail "$option publishes value $published but $key does not offer it, so that behaviour is unreachable from Settings"
done <<<"$(tr ',' '\n' <<<"$map_values")"
fi
checked=$((checked + 1))
done <<<"$entries"
printf 'enum hypr map contract: PASS (%d mapped enums)\n' "$checked"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Every GTK theme name Panama sets must be a theme that is actually installed.
#
# This exists because of a bug that was invisible for weeks. ColorScheme set
# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is
# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK
# responds to an unknown theme name by silently falling back to its built-in
# default, which is LIGHT.
#
# So light mode appeared to work, dark mode produced light windows, and nothing
# anywhere reported an error. Applications that take their cue from the GTK
# theme rather than the portal -- Chromium and Electron among them -- were stuck
# light with no way to diagnose it from inside the application.
#
# The failure is silent by construction, so it needs a test rather than a
# comment. Checks the compositor-facing setting and the generated GTK config
# agree, and that both name something real.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fail() {
printf 'gtk theme contract: %s\n' "$1" >&2
exit 1
}
theme_installed() {
local name="$1" dir
for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do
[[ -d "$dir/$name/gtk-3.0" ]] && return 0
done
return 1
}
# ── The names ColorScheme sets must exist ────────────────────────────────────
names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \
| grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)"
[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly'
while read -r name; do
[[ -n "$name" ]] || continue
theme_installed "$name" \
|| fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere."
done <<<"$names"
# ── The generated GTK config must agree, in both directions ──────────────────
# Generated into a fixture rather than the live config, so running this cannot
# retheme the desktop it is running on.
fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
for version in 3.0 4.0; do
mkdir -p "$fixture/gtk-$version"
cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \
|| fail "gtk-$version has no settings.ini.template -- the generated file would never be produced"
done
for scheme in dark light; do
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
for version in 3.0 4.0; do
generated="$fixture/gtk-$version/settings.ini"
[[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme"
grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \
&& fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme"
theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")"
prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")"
theme_installed "$theme" \
|| fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed"
if [[ "$scheme" == "dark" ]]; then
[[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode"
[[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme"
else
[[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode"
[[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme"
fi
done
done
printf 'gtk theme contract: PASS\n'
+14
View File
@@ -60,6 +60,14 @@ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \
|| fail 'opening System Health does not request a fresh scan' || fail 'opening System Health does not request a fresh scan'
rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary does not open GNOME Settings' || fail 'Fedora ownership boundary does not open GNOME Settings'
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Users handoff'
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Sharing handoff'
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Colour profiles handoff'
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \ rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \
|| fail 'Settings repair does not stay inline/non-external' || fail 'Settings repair does not stay inline/non-external'
rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \ rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \
@@ -278,6 +286,12 @@ jq -e '
and (.renderedRows | map(.id) | length) == 6 and (.renderedRows | map(.id) | length) == 6
and (.renderedRows | map(.id) | unique | length) == 6 and (.renderedRows | map(.id) | unique | length) == 6
and .emptyQuietGroups == ["desktop-foundation"] and .emptyQuietGroups == ["desktop-foundation"]
and .fedoraHandoffs == [
{id:"users", label:"Users", action:"Open users"},
{id:"sharing", label:"Sharing", action:"Open sharing"},
{id:"color", label:"Colour profiles", action:"Open colour"},
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
]
and .summaryHeight == 126 and .summaryHeight == 126
and (.rowHeights | length) == 6 and (.rowHeights | length) == 6
and (.rowHeights | all(. >= 62)) and (.rowHeights | all(. >= 62))
@@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' [[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break break
fi fi
sleep 0.1 sleep 0.1
done done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \ '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client' || fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \ geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" '.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry' [[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi fi
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# panama-keyring reports the login keyring's state, and the Settings page reads
# nothing but its JSON.
#
# The state that matters is LOCKED, and it is also the one that cannot be
# rehearsed on a real desktop: locking the login keyring breaks every saved
# password on the machine and can only be undone by typing the password into a
# dialog. So the secret service is stubbed here instead. Nothing touches the
# real keyring -- this contract is safe to run on the daily driver, which is the
# entire reason it is written this way.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
fail() {
printf 'keyring helper contract: %s\n' "$1" >&2
exit 1
}
stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides
# what the fake service reports, so one stub covers every case.
mkdir -p "$stub_dir/gi/repository"
cat >"$stub_dir/gi/__init__.py" <<'STUB'
def require_version(*_args, **_kwargs):
return None
STUB
cat >"$stub_dir/gi/repository/__init__.py" <<'STUB'
import os
class _Collection:
def __init__(self, label, locked):
self._label = label
self._locked = locked
def get_label(self):
return self._label
def get_locked(self):
return self._locked
class _Service:
def get_collections(self):
mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked")
if mode == "nologin":
return [_Collection("Some App", False)]
return [_Collection("Login", mode == "locked"), _Collection("", False)]
class _ServiceFactory:
@staticmethod
def get_sync(_flags, _cancellable):
if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable":
raise RuntimeError("no secret service")
return _Service()
# unlock_sync is what the `unlock` action calls; record that it was reached.
@staticmethod
def _noop(*_args, **_kwargs):
return None
class Secret:
class ServiceFlags:
LOAD_COLLECTIONS = 1
Service = _ServiceFactory
STUB
run() {
PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}"
}
# ── Unlocked: the normal state after any sign-in ─────────────────────────────
out="$(run unlocked)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out"
jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \
|| fail "an unlocked login keyring was misreported: $out"
# ── Locked: the state the whole card exists for ──────────────────────────────
out="$(run locked)"
jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \
|| fail "a locked login keyring was not reported as locked: $out"
# ── No secret service at all is a state, not a crash ─────────────────────────
out="$(run unavailable)"
jq -e . >/dev/null 2>&1 <<<"$out" \
|| fail "a missing secret service produced no JSON, so the page would show nothing: $out"
jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \
|| fail "a missing secret service must be reported with a reason: $out"
# ── No login keyring: not locked, because there is nothing to lock ───────────
out="$(run nologin)"
jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \
|| fail "a machine with no login keyring must not report itself locked: $out"
# ── The daemon origin is reported, since it is the crash diagnostic ──────────
jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \
|| fail "the daemon origin must be one of pam/dbus/none/unknown"
printf 'keyring helper contract: PASS\n'
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Versioned upgrades for settings.json.
#
# The schema says what a setting IS; it cannot say what a setting USED to be.
# Rename a key, change its units, or split one setting into two, and the stored
# value stops being recognised -- and unrecognised keys are deliberately carried
# through untouched, so the user's choice silently stops taking effect with
# nothing to explain it.
#
# The list of migrations is empty today, which is exactly why this is tested
# now: the first time it runs for real will be against somebody's actual
# settings during an upgrade, and that is a poor moment to discover how it
# behaves. The harness supplies fixture steps, including one that throws.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/migrations-harness.qml"
fail() {
printf 'migrations contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$harness" ]] || fail "the harness is missing: $harness"
out="$(timeout 60 qs -p "$harness" 2>&1 | grep -o 'PANAMA-MIGRATIONS .*' | sed 's/^PANAMA-MIGRATIONS //')"
[[ -n "$out" ]] || fail 'the harness produced no result'
jq -e . >/dev/null 2>&1 <<<"$out" || fail "the harness did not emit JSON: $out"
check() {
jq -e "$1" >/dev/null <<<"$out" || fail "$2 -- got $(jq -c "$3" <<<"$out")"
}
# A file written before versioning existed is stamped, NOT migrated. Running
# the list against it would apply upgrades designed for schemas it never had.
check '.unversioned.v == 1 and .unversioned.migrated == false and .unversioned.untouched == true' \
'a file with no schemaVersion must be stamped at the baseline without being migrated' '.unversioned'
# The stamp has to reach disk. Reported as changed-but-not-migrated, it would
# otherwise live only in memory and be redone on every single launch.
check '.unversioned.changed == true' \
'stamping a pre-versioning file must be reported as a change so it gets written' '.unversioned'
# A file from the future must not be rewritten at all.
check '.future.changed == false' \
'a file from a newer version must not be written back' '.future'
# Every step above the stored version runs, in order.
check '.upgrade.v == 3 and .upgrade.b == 2 and .upgrade.c == "three" and .upgrade.count == 2' \
'an older file must run each pending step in order and end at the current version' '.upgrade'
# Already current: nothing runs, nothing is touched.
check '.current.migrated == false and .current.kept == true and .current.b == true' \
'a file already at the current version must be left alone' '.current'
# A file from a NEWER Panama is left completely alone. Downgrading keys is not
# something this can do correctly, and unknown keys are already preserved.
check '.future.v == 9 and .future.migrated == false and .future.kept == true' \
'a file from a newer version must not be modified or downgraded' '.future'
# A failing step stops at the last good version. Skipping past it would lose
# the conversion forever; failing the whole load would cost every setting.
check '.failure.v == 3 and .failure.count == 2 and .failure.kept == true' \
'a failing step must stop at the last good version, keeping the steps that succeeded' '.failure'
# The promise that makes rollback safe.
check '.preserved.kept == true' \
'a migration must not discard keys it does not recognise' '.preserved'
printf 'migrations contract: PASS\n'
+158 -1
View File
@@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG" printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG" printf '\n' >>"$OSD_TEST_LOG"
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}" printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
fi fi
SH SH
cat >"$scratch/bin/panama-brightness" <<'SH'
#!/bin/bash
printf 'panama-brightness' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
case "${1:-}" in
list)
if [[ -n ${DDC_LIST_JSON:-} ]]; then
printf '%s\n' "$DDC_LIST_JSON"
else
printf '%s\n' '{"displays":[],"error":"No displays"}'
fi
;;
get)
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
if [[ -s $OSD_DDC_STATE ]]; then
cat "$OSD_DDC_STATE"
else
printf '%s\n' "${DDC_GET_VALUE:-40}"
fi
;;
set)
if [[ -n ${DDC_SET_DELAY:-} ]]; then
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
fi
sleep "$DDC_SET_DELAY"
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
fi
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
;;
*) exit 2 ;;
esac
SH
cat >"$scratch/bin/hyprctl" <<'SH'
#!/bin/bash
printf 'hyprctl' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
SH
cat >"$scratch/bin/notify-send" <<'SH'
#!/bin/bash
printf 'notify-send' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
cat >"$scratch/bin/playerctl" <<'SH' cat >"$scratch/bin/playerctl" <<'SH'
#!/bin/bash #!/bin/bash
printf 'playerctl' >>"$OSD_TEST_LOG" printf 'playerctl' >>"$OSD_TEST_LOG"
@@ -53,9 +105,21 @@ SH
chmod +x "$scratch/bin/"* chmod +x "$scratch/bin/"*
run_helper() { run_helper() {
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
mkdir -p "$runtime"
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \ PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \ OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \ PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
PANAMA_OSD_RUNTIME_DIR="$runtime" \
OSD_DDC_STATE="$runtime/ddc-state" \
OSD_DDC_PROBE="$runtime/ddc-probe" \
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
"$helper" "$@" "$helper" "$@"
} }
@@ -86,9 +150,102 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
: >"$log" : >"$log"
run_helper brightness up 5 run_helper brightness up 5
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
assert_line 'brightnessctl <-m> <-c> <backlight>' assert_line 'brightnessctl <-m> <-c> <backlight>'
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>' assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
if grep -Fq 'panama-brightness' "$log"; then
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
exit 1
fi
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'hyprctl <-j> <monitors>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
# A cached bus avoids the expensive display scan on subsequent key presses.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
run_helper brightness down 5
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <40>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
if grep -Fq 'panama-brightness <list>' "$log"; then
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
exit 1
fi
# A disconnected cached monitor is discarded and rediscovered once.
mkdir -p "$scratch/runtime-ddc-stale"
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
BACKLIGHT_AVAILABLE=false \
DDC_FAIL_GET_BUS=9 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5
assert_line 'panama-brightness <get> <9>'
assert_line 'panama-brightness <list>'
assert_line 'panama-brightness <get> <5>'
assert_line 'panama-brightness <set> <5> <45>'
# If the focused output is not DDC-capable, use the first discovered display.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
BACKLIGHT_AVAILABLE=false \
FOCUSED_MONITOR='eDP-1' \
DDC_GET_VALUE=35 \
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness down 10
assert_line 'panama-brightness <get> <3>'
assert_line 'panama-brightness <set> <3> <25>'
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
# Permission and discovery errors must be visible, never masquerade as 0%.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
BACKLIGHT_AVAILABLE=false \
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
run_helper brightness up 5
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
exit 1
fi
# Separate key-repeat processes must not overlap their DDC transactions.
: >"$log"
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
first_pid=$!
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
BACKLIGHT_AVAILABLE=false \
DDC_SET_DELAY=0.15 \
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
run_helper brightness up 5 &
second_pid=$!
wait "$first_pid"
wait "$second_pid"
if grep -Fqx 'ddc-overlap' "$log"; then
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
exit 1
fi
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
exit 1
fi
: >"$log" : >"$log"
run_helper media next run_helper media next
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# panama-power-profile reads and sets the system power profile.
#
# Runs against a stubbed busctl. The real daemon is a SYSTEM service shared with
# everything else on the machine, and a test that flipped the daily driver into
# power-saver and crashed before restoring would leave it there.
#
# The parsing is the fragile part. busctl renders the Profiles property flat:
#
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
#
# so profile names and driver names sit in the same stream. A pattern loose
# enough to match both reports the driver as an extra profile -- and on this
# machine the driver is literally called "tuned", which reads exactly like a
# plausible fourth profile.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-power-profile"
fail() {
printf 'power profile contract: %s\n' "$1" >&2
exit 1
}
stub_dir="$(mktemp -d /tmp/panama-power.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
cat >"$stub_dir/busctl" <<'STUB'
#!/usr/bin/env bash
# Records set-property calls so the test can assert what was written.
case "${1:-}" in
status)
[[ "${PANAMA_POWER_FAKE:-up}" == "down" ]] && exit 1
exit 0 ;;
get-property)
case "${5:-}" in
ActiveProfile) printf 's "performance"\n' ;;
PerformanceDegraded) printf 's "%s"\n' "${PANAMA_POWER_DEGRADED:-}" ;;
Profiles)
if [[ "${PANAMA_POWER_FAKE:-up}" == "empty" ]]; then
printf 'v aa{sv} 0\n'
else
printf 'v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" s "balanced" "Driver" s "tuned" 2 "Profile" s "performance" "Driver" s "tuned"\n'
fi ;;
esac
exit 0 ;;
set-property)
printf '%s\n' "${!#}" >>"$PANAMA_POWER_SET_LOG"
exit 0 ;;
esac
exit 0
STUB
chmod +x "$stub_dir/busctl"
export PANAMA_POWER_SET_LOG="$stub_dir/sets.log"
: >"$PANAMA_POWER_SET_LOG"
run() { PATH="$stub_dir:$PATH" "$helper" "$@"; }
# ── Parsing ──────────────────────────────────────────────────────────────────
out="$(run list)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
[[ "$(jq -r '.profiles | length' <<<"$out")" == "3" ]] \
|| fail "expected exactly three profiles; a fourth usually means the Driver value was parsed as one: $out"
jq -e '.profiles == ["power-saver", "balanced", "performance"]' >/dev/null <<<"$out" \
|| fail "profiles were parsed wrongly or reordered: $out"
jq -e '.profiles | index("tuned") == null' >/dev/null <<<"$out" \
|| fail 'the driver name "tuned" was reported as a profile'
[[ "$(jq -r '.active' <<<"$out")" == "performance" ]] \
|| fail "the active profile was not read: $out"
# ── Degradation is surfaced, since it makes the active profile a lie ─────────
out="$(PANAMA_POWER_DEGRADED="lap-detected" run list)"
[[ "$(jq -r '.degraded' <<<"$out")" == "lap-detected" ]] \
|| fail "a degraded performance state was not reported: $out"
# ── Setting ──────────────────────────────────────────────────────────────────
run set balanced
[[ "$(tail -1 "$PANAMA_POWER_SET_LOG")" == "balanced" ]] \
|| fail "set did not write the requested profile: $(cat "$PANAMA_POWER_SET_LOG")"
before="$(wc -l <"$PANAMA_POWER_SET_LOG")"
run set 'evil; rm -rf /' 2>/dev/null
[[ "$(wc -l <"$PANAMA_POWER_SET_LOG")" == "$before" ]] \
|| fail 'a profile name with shell metacharacters reached the system service'
# ── No daemon, and a daemon with nothing to offer, are both states ───────────
out="$(PANAMA_POWER_FAKE=down run list)"
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|| fail "a missing power daemon must be reported with a reason: $out"
out="$(PANAMA_POWER_FAKE=empty run list)"
jq -e '.profiles == [] and .error != ""' >/dev/null <<<"$out" \
|| fail "a daemon offering no profiles must be reported, not shown as an empty card: $out"
printf 'power profile contract: PASS\n'
@@ -61,6 +61,26 @@ restore() {
} }
trap restore EXIT trap restore EXIT
# This contract shares its harness file with settings-hyprland-write-contract,
# and Quickshell identifies an instance by config path -- so if that run's
# instance is still alive, the IPC wait below is satisfied by ITS target. That
# direction is the dangerous one: this contract believes the compositor seam is
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
# reporting isolation. Refuse to start rather than find out.
harness_instances() {
# rg -c prints nothing when there are no matches, so an unguarded
# substitution yields "" rather than "0".
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor'
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \ XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
@@ -77,6 +77,32 @@ target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3) target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2) target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
# settings-commit-reset-contract drives the SAME harness file with
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
# stubbed out. Quickshell identifies an instance by its config path, so if that
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
# ITS target -- and every write in this contract lands on the isolated instance
# and never reaches the compositor. That is exactly what "a typed batch did not
# reach the compositor" looks like when this fails in a full suite run but
# passes on its own.
#
# So wait for the harness to be clear first, and say so plainly if it is not,
# rather than silently talking to the wrong shell.
harness_instances() {
# rg -c prints nothing at all when there are no matches, so an unguarded
# substitution yields "" rather than "0" and every comparison against a
# count fails.
local count
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
printf '%s' "${count:-0}"
}
for _ in $(seq 1 50); do
[[ "$(harness_instances)" == "0" ]] && break
sleep 0.1
done
[[ "$(harness_instances)" == "0" ]] \
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
@@ -123,8 +149,23 @@ sleep 0.3
qs_for_harness ipc call settings-system-test applyJson \ qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
# 10 seconds, not 4. The write path verifies each option by reading it back off
# the compositor and retries a refused batch, so a busy machine legitimately
# takes longer than a quick apply -- and this contract runs in a suite alongside
# other tests driving the same compositor. Failing at 4 seconds reported a
# product bug ("did not reach the compositor") for what was queueing.
# Re-issued periodically, because this contract and the LIVE shell both write to
# the same compositor. When the running Panama re-applies its own preferences --
# which it does on any store change -- it overwrites the values this test just
# set, and the read-back below then sees Panama's shipped defaults with the
# writer reporting no error at all. That combination is the signature: a
# rejected write leaves an error, a clobbered one does not.
typed=false typed=false
for _ in $(seq 1 40); do for attempt in $(seq 1 100); do
if (( attempt % 30 == 0 )); then
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
fi
if [[ "$(read_option decoration:rounding)" == "7" \ if [[ "$(read_option decoration:rounding)" == "7" \
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \ && "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \ && "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
@@ -135,10 +176,14 @@ for _ in $(seq 1 40); do
sleep 0.1 sleep 0.1
done done
if [[ "$typed" != true ]]; then if [[ "$typed" != true ]]; then
# Report what the writer thinks as well as what the compositor holds. Those
# two disagreeing is a rejected write; both showing defaults is a write that
# never happened, and the messages should not look identical.
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \ fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \ gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \ blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)" opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
fi fi
# Verification must recognise those shapes as success, not report them rejected. # Verification must recognise those shapes as success, not report them rejected.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Adding a settings page means editing four separate files, and missing one
# fails quietly rather than loudly:
#
# SettingsSidebar.qml the row you click
# SettingsShell.qml the case that maps that row to a component, AND the
# Component declaration itself
# ShellState.qml the allow-list openSettings() checks -- a page missing
# here silently redirects to Home, so a deep link or a
# search result lands on the wrong page with no error
# modules/settings/qmldir the component registration -- without it the page
# is "not a type" and the whole settings window fails
# to load, taking every other page with it
#
# Nothing at runtime cross-checks the four. This does, statically.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
sidebar="$settings_dir/SettingsSidebar.qml"
shell_file="$settings_dir/SettingsShell.qml"
qmldir="$settings_dir/qmldir"
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
fail() {
printf 'settings nav contract: %s\n' "$1" >&2
exit 1
}
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
[[ -r "$required" ]] || fail "cannot read $required"
done
# ── Every sidebar row resolves everywhere ────────────────────────────────────
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|| fail 'could not find the allow-list in ShellState'
while read -r page; do
[[ -n "$page" ]] || continue
# Home is the switch's default arm rather than a case, since it is also
# where an unknown page falls back to.
if [[ "$page" != "home" ]]; then
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
fi
grep -qF "\"$page\"" <<<"$allowed_line" \
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
done <<<"$pages"
# ── Every routed component is declared and registered ────────────────────────
# The case arms name a Component id; each must have a declaration, and the type
# it instantiates must appear in qmldir.
while read -r component; do
[[ -n "$component" ]] || continue
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|| fail "SettingsShell routes to \"$component\" but never declares it"
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
[[ -r "$settings_dir/$type_name.qml" ]] \
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
done < <({
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
} | sort -u)
# ── Every page file is reachable ─────────────────────────────────────────────
# A page nobody can navigate to is dead code that still has to compile. The
# scaffold SettingsPage.qml is the one file here that is a base class rather
# than a page.
while read -r page_file; do
type_name="$(basename "$page_file" .qml)"
[[ "$type_name" == "SettingsPage" ]] && continue
grep -qE "; $type_name \{\} \}" "$shell_file" \
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS\n'
+2 -2
View File
@@ -195,14 +195,14 @@ for page in "${pages[@]}"; do
done done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route" [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \ '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail "$page created a missing, floating, or duplicate Settings window" || fail "$page created a missing, floating, or duplicate Settings window"
done done
qs_for_test ipc call settings page '__unsupported__' >/dev/null qs_for_test ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home' [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \ /usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings' || fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \ /usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|| fail 'Super+Shift+S is not registered as Screen Intelligence' || fail 'Super+Shift+S is not registered as Screen Intelligence'
+6 -6
View File
@@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing'
qs ipc call settings open >/dev/null qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then
break break
fi fi
sleep 0.1 sleep 0.1
done done
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \ hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \
|| fail 'exactly one Settings window did not map' || fail 'exactly one Settings window did not map'
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \ hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \
|| fail 'Settings window is not tiled' || fail 'Settings window is not tiled'
qs ipc call settings page displays >/dev/null qs ipc call settings page displays >/dev/null
@@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null
qs ipc call settings page desktop >/dev/null qs ipc call settings page desktop >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route' [[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')" address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break [[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
@@ -42,13 +42,13 @@ done
qs ipc call settings open >/dev/null qs ipc call settings open >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break
sleep 0.1 sleep 0.1
done done
qs ipc call settings close >/dev/null qs ipc call settings close >/dev/null
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then
trap - EXIT trap - EXIT
printf 'settings window contract: PASS\n' printf 'settings window contract: PASS\n'
exit 0 exit 0
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# panama-wifi-qr renders a saved network as a QR code a phone can scan.
#
# The QR contains the network PASSWORD in machine-readable form, so most of what
# is worth testing here is about handling that safely rather than about QR
# codes. Both nmcli and qrencode are stubbed: the real ones would read this
# machine's actual passphrases, and a test that writes the daily driver's Wi-Fi
# password into a fixture directory is not one worth having.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-wifi-qr"
fail() {
printf 'wifi qr contract: %s\n' "$1" >&2
exit 1
}
work="$(mktemp -d /tmp/panama-wifiqr.XXXXXX)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/bin" "$work/run"
readonly SECRET='hunter2-secret'
cat >"$work/bin/nmcli" <<STUB
#!/usr/bin/env bash
# -t -f NAME,TYPE connection show
if [[ "\$*" == *"-f NAME,TYPE"* ]]; then
printf 'home net:802-11-wireless\n'
printf 'work-eap:802-11-wireless\n'
printf 'Wired connection 1:802-3-ethernet\n'
exit 0
fi
name="\${@: -1}"
case "\$*" in
*802-11-wireless.ssid*)
# An SSID containing reserved characters, to prove they are escaped.
case "\$name" in
"home net") printf 'home;net\n' ;;
"work-eap") printf 'work-eap\n' ;;
esac ;;
*802-11-wireless.hidden*) printf 'no\n' ;;
*802-11-wireless-security.psk*)
# work-eap is enterprise: no passphrase exists to share.
[[ "\$name" == "home net" ]] && printf '%s\n' "$SECRET" ;;
esac
exit 0
STUB
chmod +x "$work/bin/nmcli"
# Records its argv and its stdin separately, so the test can prove the secret
# arrived on stdin and never on the command line -- argv is world-readable
# through /proc while a process runs.
cat >"$work/bin/qrencode" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$QRENCODE_ARGV_LOG"
out=""
prev=""
for arg in "$@"; do
[[ "$prev" == "-o" ]] && out="$arg"
prev="$arg"
done
cat >"$QRENCODE_STDIN_LOG"
printf 'fake-png' >"$out"
exit 0
STUB
chmod +x "$work/bin/qrencode"
export QRENCODE_ARGV_LOG="$work/argv.log"
export QRENCODE_STDIN_LOG="$work/stdin.log"
: >"$QRENCODE_ARGV_LOG"
: >"$QRENCODE_STDIN_LOG"
run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; }
# ── Listing distinguishes shareable from not ────────────────────────────────
out="$(run list)"
jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out"
[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \
|| fail "only wireless connections belong in the list: $out"
jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \
|| fail "a network with a passphrase must be shareable: $out"
jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \
|| fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out"
# ── The payload ─────────────────────────────────────────────────────────────
path="$(run qr 'home net' | jq -r .path)"
[[ -n "$path" && -e "$path" ]] || fail 'no image was produced'
payload="$(cat "$QRENCODE_STDIN_LOG")"
grep -q "P:$SECRET;" <<<"$payload" \
|| fail 'the passphrase did not reach the payload intact'
# The SSID is "home;net": unescaped, the semicolon ends the S: field early and
# the code describes a different network.
grep -qF 'S:home\;net;' <<<"$payload" \
|| fail "a reserved character in the SSID was not escaped: $payload"
[[ "$(wc -l <"$QRENCODE_STDIN_LOG")" == "0" ]] \
|| fail "the payload contains a newline; nmcli's trailing newline must be stripped: $(cat -A "$QRENCODE_STDIN_LOG")"
grep -q ';;$' <<<"$payload" || fail "the WIFI: URI must be terminated with ;;: $payload"
# ── The secret must never appear in argv ────────────────────────────────────
grep -q "$SECRET" "$QRENCODE_ARGV_LOG" \
&& fail 'the passphrase was passed as a command-line argument, where /proc exposes it to every process on the machine'
# ── The image and its directory must not be readable by others ──────────────
[[ "$(stat -c '%a' "$path")" == "600" ]] \
|| fail "the QR image is mode $(stat -c '%a' "$path"); it contains a password"
[[ "$(stat -c '%a' "$(dirname "$path")")" == "700" ]] \
|| fail "the directory holding QR images is mode $(stat -c '%a' "$(dirname "$path")")"
# ── No temporary payload files may survive ──────────────────────────────────
leftovers="$(find "$work/run" -name 'payload.*' | wc -l)"
[[ "$leftovers" == "0" ]] \
|| fail "$leftovers temporary payload file(s) containing the passphrase were left behind"
# ── An unknown network is an error, not an empty image ──────────────────────
out="$(run qr 'no-such-network')"
jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \
|| fail "an unknown network must be reported: $out"
printf 'wifi qr contract: PASS\n'