diff --git a/.gitignore b/.gitignore index 860119d..a041459 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ __pycache__/ # Generated from the colour scheme; machine state, not configuration. /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 diff --git a/README.md b/README.md index 041aa0b..120e01c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ same Tokyo Night Moon palette. | Piece | What it is | |---|---| | `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/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | diff --git a/config/dot/btop/themes/tokyonight-day.theme b/config/dot/btop/themes/tokyonight-day.theme new file mode 100644 index 0000000..6932b74 --- /dev/null +++ b/config/dot/btop/themes/tokyonight-day.theme @@ -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" diff --git a/config/dot/btop/themes/tokyonight-moon.theme b/config/dot/btop/themes/tokyonight-moon.theme new file mode 100644 index 0000000..6e26075 --- /dev/null +++ b/config/dot/btop/themes/tokyonight-moon.theme @@ -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" diff --git a/config/dot/gtk-3.0/settings.ini b/config/dot/gtk-3.0/settings.ini.template similarity index 60% rename from config/dot/gtk-3.0/settings.ini rename to config/dot/gtk-3.0/settings.ini.template index 02eb639..e4ebce2 100644 --- a/config/dot/gtk-3.0/settings.ini +++ b/config/dot/gtk-3.0/settings.ini.template @@ -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] # 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 # themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything # appearing broken. -gtk-theme-name=adw-gtk3-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 -gtk-application-prefer-dark-theme=1 +gtk-application-prefer-dark-theme=@PREFER_DARK@ gtk-toolbar-style=GTK_TOOLBAR_ICONS gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR diff --git a/config/dot/gtk-4.0/settings.ini b/config/dot/gtk-4.0/settings.ini deleted file mode 100644 index bb748bb..0000000 --- a/config/dot/gtk-4.0/settings.ini +++ /dev/null @@ -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 diff --git a/config/dot/gtk-4.0/settings.ini.template b/config/dot/gtk-4.0/settings.ini.template new file mode 100644 index 0000000..973a818 --- /dev/null +++ b/config/dot/gtk-4.0/settings.ini.template @@ -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 diff --git a/config/dot/hypr/DESKTOP-PARITY.md b/config/dot/hypr/DESKTOP-PARITY.md index 1a3bd56..6d22840 100644 --- a/config/dot/hypr/DESKTOP-PARITY.md +++ b/config/dot/hypr/DESKTOP-PARITY.md @@ -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 | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | 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 diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index 1c259e0..b147c90 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -27,7 +27,7 @@ Don't "fix" them. | File | Contents | |---|---| | `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 | | `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `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 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; -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. Remove the drop-in and go back to the shipped config with: @@ -77,7 +77,7 @@ relationship is: `prefs.get("key", )`, so the config still works standalone with no settings file at all. - **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. 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 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 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 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. `tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so this cannot regress silently. @@ -275,7 +275,7 @@ The mental model is unchanged from Forge: | `SUPER + V` | Clipboard history | | `SUPER + .` | Emoji picker | | `SUPER + S` | Quick settings | -| `SUPER + I` | Panama Settings | +| `SUPER + I` | Settings | | `SUPER + SHIFT + F` | Start or reveal focus session | | `SUPER + B` | Notification centre | | `SUPER + grave` | Workspace overview | @@ -288,7 +288,7 @@ The mental model is unchanged from Forge: ### Apps `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. ## Calendar and notifications @@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra dashboard windows. 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 only previews the value; releasing it sends one brightness request. A normal power toggle leaves Home Assistant responsible for restoring its previous diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index 2b2b33c..9fcbe58 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -21,10 +21,18 @@ hl.config({ repeat_delay = prefs.get("keyRepeatDelay", 500), 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), - -- 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, -- Flat pointer response, no acceleration. Matters for gaming. diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index bbf74d3..35e2abb 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -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 .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) 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" }) -- ── Launcher ──────────────────────────────────────────────────────────────── diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml index 089c962..528a698 100644 --- a/config/dot/quickshell/config/DesktopPreferences.qml +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -98,7 +98,26 @@ Singleton { // back to shipped defaults and let the next write replace it. 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.loaded = true; } diff --git a/config/dot/quickshell/config/Migrations.qml b/config/dot/quickshell/config/Migrations.qml new file mode 100644 index 0000000..05df23f --- /dev/null +++ b/config/dot/quickshell/config/Migrations.qml @@ -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 }; + } +} diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c5d89ed..a60ade8 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -128,10 +128,21 @@ Singleton { { key: "vrrPolicy", type: "enum", def: 3, group: "display", 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: [ - { value: 0, label: "Off" }, - { value: 3, label: "Content-aware" } + { value: 0, label: "Off", + 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" } }, @@ -140,8 +151,12 @@ Singleton { label: "Direct scanout", detail: "Lets fullscreen content bypass compositing", options: [ - { value: 0, label: "Off" }, - { value: 2, label: "Automatic" } + { value: 0, label: "Off", + 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" } }, @@ -257,6 +272,24 @@ Singleton { detail: "XKB layout name, or a comma-separated list to switch between", 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", label: "Num Lock on login", @@ -281,12 +314,28 @@ Singleton { }, { key: "followMouse", type: "enum", def: 1, group: "input", - label: "Focus follows pointer", - detail: "Click to focus matches GNOME; sloppy focus follows the pointer", + label: "Pointer focus", + 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: [ - { value: 0, label: "Never" }, - { value: 1, label: "Click to focus" }, - { value: 2, label: "Sloppy focus" } + { value: 0, label: "Click to focus", + detail: "Moving the pointer never changes 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" } }, @@ -310,6 +359,203 @@ Singleton { 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 ───────────────────────────────────────────────────── { key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", diff --git a/config/dot/quickshell/config/qmldir b/config/dot/quickshell/config/qmldir index c792bf9..c138bb4 100644 --- a/config/dot/quickshell/config/qmldir +++ b/config/dot/quickshell/config/qmldir @@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml singleton HomePreferences 1.0 HomePreferences.qml singleton Settings 1.0 Settings.qml singleton Theme 1.0 Theme.qml +singleton Migrations 1.0 Migrations.qml diff --git a/config/dot/quickshell/migrations-harness.qml b/config/dot/quickshell/migrations-harness.qml new file mode 100644 index 0000000..ae65a11 --- /dev/null +++ b/config/dot/quickshell/migrations-harness.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()); + } +} diff --git a/config/dot/quickshell/modules/settings/AboutPage.qml b/config/dot/quickshell/modules/settings/AboutPage.qml index d648c56..dc5015f 100644 --- a/config/dot/quickshell/modules/settings/AboutPage.qml +++ b/config/dot/quickshell/modules/settings/AboutPage.qml @@ -4,11 +4,20 @@ import qs.config import qs.services SettingsPage { - title: "About Panama" + id: root + + title: "About" 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 { - title: "Panama Desktop" + title: "Desktop" subtitle: "Tokyo Night Moon · Prism glass · native tiling" 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 { title: "Design principles" diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml index 62665c2..2add626 100644 --- a/config/dot/quickshell/modules/settings/AccessibilityPage.qml +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -25,32 +25,61 @@ SettingsPage { SettingsCard { 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 } } SettingsCard { 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 } } + // 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 { - title: "Contrast" - subtitle: "Unfocused windows can be faded to make the focused one obvious, or left at full strength if that is harder to read." + title: "Magnifier" + 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 { - title: "System accessibility" - subtitle: "Screen reader, zoom, and on-screen keyboard are provided by GNOME's accessibility stack." + title: "Contrast" + 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 { label: "GNOME accessibility settings" - detail: "Opens in GNOME Settings" + detail: "For the parts GNOME's own stack still owns" action: "Open" divider: false onTriggered: SystemSettings.openGnomePanel("universal-access") diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 879caef..c2b7a1c 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -46,7 +46,7 @@ SettingsPage { title: "Background" subtitle: 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 { id: wallpapers @@ -184,7 +184,7 @@ SettingsPage { SettingsCard { 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: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false } diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index 0dea3cb..06c4a6b 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -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 { title: "User autostart" subtitle: "These desktop entries live in your user configuration. Select a row to toggle it." @@ -175,7 +209,7 @@ SettingsPage { SettingsCard { 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 { visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0 diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 766d379..f387e07 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -19,13 +19,18 @@ import qs.services SettingsPage { id: root + title: "Network & Devices" lede: Connectivity.activeNetwork ? "Connected to " + Connectivity.activeNetwork.name : "Wi-Fi, Bluetooth, and the things Fedora owns." // 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 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 { title: "Bluetooth" visible: Connectivity.adapter !== null diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index dafdf01..972e083 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -47,18 +47,16 @@ SettingsPage { SettingsCard { 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 { - label: "Layout" - detail: "Dwindle with preserved split direction" - value: "Tiling" - } - TextRow { - label: "Workspace movement" - detail: "Alt+H / Alt+L, add Shift to move a window" - value: "Dynamic" - } + // These were two read-only rows reporting "Tiling" and "Dynamic", which + // described settings rather than facts -- both are ordinary Hyprland + // options that simply had no controls. TextRow's own documentation says + // a setting the user could reasonably change does not belong in it. + ChoiceRow { setting: "windowLayout" } + ToggleRow { setting: "preserveSplit" } + ChoiceRow { setting: "forceSplit" } + ToggleRow { setting: "windowSnapping" } ActionRow { label: "Gaps, corners, and effects" 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 { title: "Focus" @@ -111,10 +121,10 @@ SettingsPage { SettingsCard { 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 { - label: "Restore Panama defaults" + label: "Restore defaults" detail: "Applies immediately, including to the compositor" action: "Restore defaults" divider: false diff --git a/config/dot/quickshell/modules/settings/DesktopPreview.qml b/config/dot/quickshell/modules/settings/DesktopPreview.qml index 67acc0a..a248ffe 100644 --- a/config/dot/quickshell/modules/settings/DesktopPreview.qml +++ b/config/dot/quickshell/modules/settings/DesktopPreview.qml @@ -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 { anchors.centerIn: parent - text: "Panama" + text: Qt.formatTime(new Date(), "h:mm") color: Theme.alpha(Theme.fg, 0.45) font.family: Theme.fontFamily font.pixelSize: 9 diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 638eb7b..eff3b72 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -149,7 +149,7 @@ SettingsPage { ActionRow { visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") 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" divider: false onTriggered: Displays.forget(root.monitor.name) @@ -248,7 +248,7 @@ SettingsPage { SettingsCard { title: "Gaming display policy" - subtitle: "Applied immediately and restored when Panama starts." + subtitle: "Applied immediately and restored when the session starts." ToggleRow { setting: "autoHdr" } ChoiceRow { setting: "vrrPolicy" } diff --git a/config/dot/quickshell/modules/settings/HealthPage.qml b/config/dot/quickshell/modules/settings/HealthPage.qml index 36ccdcd..cfbf323 100644 --- a/config/dot/quickshell/modules/settings/HealthPage.qml +++ b/config/dot/quickshell/modules/settings/HealthPage.qml @@ -143,6 +143,7 @@ SettingsPage { 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 emptyGroups = root.descendants(root, "health-empty-group:").filter(label => label.visible); + const fedoraHandoffs = root.descendants(root, "health-fedora-handoff:").filter(row => row.visible); return { renderedRows: rows.map(row => { const objectName = String(row.objectName); @@ -162,6 +163,11 @@ SettingsPage { focusChain: root.renderedFocusChain(), activatedRows: rows.filter(row => row.actionActivationCount > 0).map(row => String(row.objectName)), 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, confirmationId: confirmationSheets.length === 1 ? String(confirmationSheets[0].objectName).slice("health-confirmation-sheet:".length) @@ -384,7 +390,7 @@ SettingsPage { SettingsCard { 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 { width: parent.width @@ -415,5 +421,38 @@ SettingsPage { 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") + } } } diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml index c5ec8d4..83afe61 100644 --- a/config/dot/quickshell/modules/settings/HomePage.qml +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -9,7 +9,7 @@ SettingsPage { readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") title: `${root.greeting}, Gabriel` - lede: "Your Panama desktop is configured and ready." + lede: "Your desktop is configured and ready." SettingsCard { title: SystemSettings.monitorDescription || "Active display" diff --git a/config/dot/quickshell/modules/settings/HomePhonePage.qml b/config/dot/quickshell/modules/settings/HomePhonePage.qml index 39fab71..c97641c 100644 --- a/config/dot/quickshell/modules/settings/HomePhonePage.qml +++ b/config/dot/quickshell/modules/settings/HomePhonePage.qml @@ -202,7 +202,7 @@ SettingsPage { SettingRow { 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 Row { @@ -239,7 +239,7 @@ SettingsPage { SettingRow { 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 controlWidth: 176 diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml new file mode 100644 index 0000000..c6a3fcc --- /dev/null +++ b/config/dot/quickshell/modules/settings/MousePage.qml @@ -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 } + } +} diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index d3b9cc3..2c3873e 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -24,7 +24,7 @@ SettingsPage { TextRow { label: "Notification history" - detail: "Live notifications retained by Panama" + detail: "Live notifications retained by the shell" value: `${Notifs.history.length} items` } diff --git a/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml new file mode 100644 index 0000000..43d7594 --- /dev/null +++ b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml @@ -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") + } + } +} diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index a4727b1..2591709 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -14,14 +14,48 @@ import qs.services SettingsPage { 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" 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 { title: "Idle behaviour" subtitle: IdleLock.managed - ? "Panama is managing hypridle. Changes take effect immediately." - : "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable." + ? "Idle timings are managed here. Changes take effect immediately." + : "hypridle is running its shipped configuration. Turn on management below to make these adjustable." SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" } SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } @@ -41,10 +75,10 @@ SettingsPage { SettingsCard { 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 { - label: "Let Panama manage idle timings" + label: "Manage idle timings here" detail: IdleLock.serviceState === "active" ? "hypridle is running" : "hypridle is " + IdleLock.serviceState diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml new file mode 100644 index 0000000..59392c7 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -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") + } + } +} diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 0b63665..31dc5ea 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -1,4 +1,4 @@ -# Panama Settings +# Settings The control centre for everything Panama owns. Anything the system owns — hardware, accounts, printers — is delegated to GNOME Settings and labelled as diff --git a/config/dot/quickshell/modules/settings/RegionPage.qml b/config/dot/quickshell/modules/settings/RegionPage.qml new file mode 100644 index 0000000..35093e8 --- /dev/null +++ b/config/dot/quickshell/modules/settings/RegionPage.qml @@ -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") + } + } +} diff --git a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml index 82a7785..4af33c3 100644 --- a/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml +++ b/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml @@ -18,7 +18,7 @@ SettingsPage { SettingsCard { 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 { icon: "󰗊" @@ -52,7 +52,7 @@ SettingsPage { SettingsCard { 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 { label: "Text recognition" diff --git a/config/dot/quickshell/modules/settings/SearchPicker.qml b/config/dot/quickshell/modules/settings/SearchPicker.qml new file mode 100644 index 0000000..6fa4922 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SearchPicker.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 9e41d79..0a1e57e 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -53,7 +53,7 @@ Rectangle { anchors.left: parent.left anchors.leftMargin: 18 anchors.verticalCenter: parent.verticalCenter - text: "Panama Settings" + text: "Settings" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize @@ -112,6 +112,10 @@ Rectangle { case "notifications": return notificationsPage; case "screen-intelligence": return screenIntelligencePage; 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 "power": return powerPage; case "datetime": return dateTimePage; @@ -166,6 +170,10 @@ Rectangle { Component { id: notificationsPage; NotificationsPage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} } 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: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index eb2b502..e8bd2c6 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -31,13 +31,17 @@ Rectangle { { page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { 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: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "applications", label: "Applications", icon: "\u{F003B}" }, { 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 @@ -56,15 +60,6 @@ Rectangle { height: implicitHeight 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 { width: parent.width height: 36 diff --git a/config/dot/quickshell/modules/settings/SettingsWindow.qml b/config/dot/quickshell/modules/settings/SettingsWindow.qml index d10b50f..1c58eac 100644 --- a/config/dot/quickshell/modules/settings/SettingsWindow.qml +++ b/config/dot/quickshell/modules/settings/SettingsWindow.qml @@ -8,7 +8,7 @@ FloatingWindow { readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics - title: "Panama Settings" + title: "Settings" visible: ShellState.settingsOpen implicitWidth: 1120 implicitHeight: 760 diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 6460815..38453d7 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -28,24 +28,19 @@ SettingsPage { SettingsCard { title: "Keyboard" - TextRow { - label: "Keyboard layout" - 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." - value: Settings ? DesktopPreferences.get("keyboardLayout") : "us" - } + // These were read-only text, on the grounds that a layout change needed + // a compositor reload. It does not: setting input:kb_variant through + // hl.config re-keymaps attached keyboards immediately -- verified by + // 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: "keyRepeatRate" } ToggleRow { setting: "numlockByDefault"; divider: false } } - SettingsCard { - title: "Pointer" - - ChoiceRow { setting: "followMouse" } - SliderRow { setting: "pointerSensitivity" } - SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } - } - SettingsCard { title: "Hardware input" @@ -155,7 +150,7 @@ SettingsPage { SettingsCard { visible: Object.keys(Keybinds.overrides).length > 0 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 { label: "Restore every shipped shortcut" diff --git a/config/dot/quickshell/modules/settings/TextEntryRow.qml b/config/dot/quickshell/modules/settings/TextEntryRow.qml new file mode 100644 index 0000000..e402cab --- /dev/null +++ b/config/dot/quickshell/modules/settings/TextEntryRow.qml @@ -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 + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/WallpaperPicker.qml b/config/dot/quickshell/modules/settings/WallpaperPicker.qml index ad0d39b..4ec2a2f 100644 --- a/config/dot/quickshell/modules/settings/WallpaperPicker.qml +++ b/config/dot/quickshell/modules/settings/WallpaperPicker.qml @@ -156,7 +156,7 @@ Item { width: parent.width - 40 horizontalAlignment: Text.AlignHCenter 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 font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 1b2873f..bb1d693 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -48,3 +48,9 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml LocationPicker 1.0 LocationPicker.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 diff --git a/config/dot/quickshell/scripts/panama-about b/config/dot/quickshell/scripts/panama-about new file mode 100755 index 0000000..ca9918f --- /dev/null +++ b/config/dot/quickshell/scripts/panama-about @@ -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[*]}")" diff --git a/config/dot/quickshell/scripts/panama-accounts b/config/dot/quickshell/scripts/panama-accounts new file mode 100755 index 0000000..9a3be6e --- /dev/null +++ b/config/dot/quickshell/scripts/panama-accounts @@ -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 + panama-accounts remove +""" + +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 ", 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 ", 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()) diff --git a/config/dot/quickshell/scripts/panama-brightness b/config/dot/quickshell/scripts/panama-brightness index 5e3af27..11a5f72 100755 --- a/config/dot/quickshell/scripts/panama-brightness +++ b/config/dot/quickshell/scripts/panama-brightness @@ -57,6 +57,39 @@ has_accessible_bus() { 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() { 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="${connector#card*-}" - bus="$(basename "$(readlink -f "$path/ddc")")" - bus="${bus#i2c-}" + bus="$(bus_for_connector "$path")" [[ "$bus" =~ ^[0-9]+$ ]] || continue # A monitor that does not implement 0x10 is not an error; it simply diff --git a/config/dot/quickshell/scripts/panama-keyring b/config/dot/quickshell/scripts/panama-keyring new file mode 100755 index 0000000..c7feef8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-keyring @@ -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()) diff --git a/config/dot/quickshell/scripts/panama-locale b/config/dot/quickshell/scripts/panama-locale new file mode 100755 index 0000000..cd8c8ee --- /dev/null +++ b/config/dot/quickshell/scripts/panama-locale @@ -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 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" =~ ^[a-zA-Z0-9_@.-]+$ ]] || { + 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 ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index 001aead..916c8db 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -2,6 +2,8 @@ set -u +readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + strict_delivery() { [[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]] } @@ -67,18 +69,151 @@ adjust_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() { 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 - up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;; - down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;; + up|down) ;; *) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;; esac - output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 - percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" - [[ $percent =~ ^[0-9]+$ ]] || return 0 - show_progress brightness "$percent" "${percent}%" + # Laptop panels expose a kernel backlight class and remain the fastest, + # most reliable path. Desktops fall through to DDC/CI monitor control. + output="$(brightnessctl -m -c backlight 2>/dev/null)" || output="" + 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() { diff --git a/config/dot/quickshell/scripts/panama-power-profile b/config/dot/quickshell/scripts/panama-power-profile new file mode 100755 index 0000000..529ee23 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-power-profile @@ -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 +# +# 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 ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/scripts/panama-security b/config/dot/quickshell/scripts/panama-security new file mode 100755 index 0000000..6dcfcb8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-security @@ -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[*]}")" diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps index 8b2708e..23be064 100755 --- a/config/dot/quickshell/scripts/panama-theme-apps +++ b/config/dot/quickshell/scripts/panama-theme-apps @@ -11,6 +11,11 @@ # Terminals are the notable exception -- they predate the standard and carry # 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 # # 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 ;; 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" theme_file="$kitty_dir/themes/tokyonight-moon.conf" [[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf" @@ -54,4 +137,13 @@ if [[ -r "$theme_file" ]]; then 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}' diff --git a/config/dot/quickshell/scripts/panama-wifi-qr b/config/dot/quickshell/scripts/panama-wifi-qr new file mode 100755 index 0000000..6532bb5 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-wifi-qr @@ -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:;P:;H:;; +# +# 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 -> {"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 ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index 4f09838..2de1c7b 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -78,9 +78,21 @@ Singleton { root.lastError = ""; 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. - const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita"; + + // adw-gtk3, not Adwaita. This is the bug that made dark mode look + // 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 = [ ["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme], diff --git a/config/dot/quickshell/services/DeviceSecurity.qml b/config/dot/quickshell/services/DeviceSecurity.qml new file mode 100644 index 0000000..500d9ea --- /dev/null +++ b/config/dot/quickshell/services/DeviceSecurity.qml @@ -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; + } + } + } +} diff --git a/config/dot/quickshell/services/InputDevices.qml b/config/dot/quickshell/services/InputDevices.qml new file mode 100644 index 0000000..b71c39e --- /dev/null +++ b/config/dot/quickshell/services/InputDevices.qml @@ -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(); + } + } +} diff --git a/config/dot/quickshell/services/Keyring.qml b/config/dot/quickshell/services/Keyring.qml new file mode 100644 index 0000000..9865976 --- /dev/null +++ b/config/dot/quickshell/services/Keyring.qml @@ -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 + } +} diff --git a/config/dot/quickshell/services/MachineInfo.qml b/config/dot/quickshell/services/MachineInfo.qml new file mode 100644 index 0000000..4c2f736 --- /dev/null +++ b/config/dot/quickshell/services/MachineInfo.qml @@ -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; + } + } + } +} diff --git a/config/dot/quickshell/services/OnlineAccounts.qml b/config/dot/quickshell/services/OnlineAccounts.qml new file mode 100644 index 0000000..cb4757e --- /dev/null +++ b/config/dot/quickshell/services/OnlineAccounts.qml @@ -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(); + } + } +} diff --git a/config/dot/quickshell/services/PowerProfiles.qml b/config/dot/quickshell/services/PowerProfiles.qml new file mode 100644 index 0000000..670a06b --- /dev/null +++ b/config/dot/quickshell/services/PowerProfiles.qml @@ -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(); + } + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index de1f26f..c6fb8d4 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -33,6 +33,9 @@ Singleton { "idle": "power", "accessibility": "accessibility", "input": "shortcuts", + "pointer": "mouse", + "touchpad": "mouse", + "multitasking": "desktop", "weather": "appearance", "notifications": "notifications", "capture": "screen-intelligence" diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 28bb534..6a102ed 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } 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"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/dot/quickshell/services/SystemLocale.qml b/config/dot/quickshell/services/SystemLocale.qml new file mode 100644 index 0000000..6a5b7b7 --- /dev/null +++ b/config/dot/quickshell/services/SystemLocale.qml @@ -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."; + } + } + } +} diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 67261fd..1f4c803 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -497,18 +497,35 @@ Singleton { ].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)) { root.lastError = "That GNOME Settings panel is not available."; return false; } + const command = ["gnome-control-center", panel]; + if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage)) + command.push(subpage); Quickshell.execDetached({ - command: ["gnome-control-center", panel], + command: command, environment: { "XDG_CURRENT_DESKTOP": "GNOME" } }); 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 { const commands = { "nextcloud": ["nextcloud"], diff --git a/config/dot/quickshell/services/WifiShare.qml b/config/dot/quickshell/services/WifiShare.qml new file mode 100644 index 0000000..edb7aea --- /dev/null +++ b/config/dot/quickshell/services/WifiShare.qml @@ -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); + } + } + } + } +} diff --git a/config/dot/tmux/themes/tokyonight-day.conf b/config/dot/tmux/themes/tokyonight-day.conf new file mode 100644 index 0000000..ca7338f --- /dev/null +++ b/config/dot/tmux/themes/tokyonight-day.conf @@ -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 "" diff --git a/config/dot/tmux/themes/tokyonight-moon.conf b/config/dot/tmux/themes/tokyonight-moon.conf new file mode 100644 index 0000000..51cf3d2 --- /dev/null +++ b/config/dot/tmux/themes/tokyonight-moon.conf @@ -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 "" diff --git a/config/dot/tmux/tmux.conf b/config/dot/tmux/tmux.conf index 47c686e..636c7ba 100644 --- a/config/dot/tmux/tmux.conf +++ b/config/dot/tmux/tmux.conf @@ -19,24 +19,10 @@ bind C-Space send-prefix # Set status bar #set -g status-bg pink # Tokyo Night Moon color palette -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 "" +# Colours live in themes/ and are generated into current-theme.conf by +# panama-theme-apps, which regenerates it whenever the desktop colour scheme +# changes. -q so a fresh checkout without the generated file still starts. +source-file -q "~/.config/tmux/current-theme.conf" # Increase scrollback buffer set -g history-limit 50000 diff --git a/config/dot/vicinae/themes/tokyonight-day.toml b/config/dot/vicinae/themes/tokyonight-day.toml new file mode 100644 index 0000000..e00de6b --- /dev/null +++ b/config/dot/vicinae/themes/tokyonight-day.toml @@ -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" diff --git a/config/dot/vicinae/vicinae.json b/config/dot/vicinae/vicinae.json index 0795bfe..202f057 100644 --- a/config/dot/vicinae/vicinae.json +++ b/config/dot/vicinae/vicinae.json @@ -6,12 +6,14 @@ // and emoji-copy extensions, so its clipboard and emoji views are bound // directly in hypr/keybinds.lua. - // Vicinae 0.26 selects a theme per system appearance. Both point to Moon so - // the launcher never flashes or falls back to its stock palette while the - // desktop appearance is being initialized. + // Vicinae 0.26 selects a theme per system appearance, and now there is a + // theme for each: the launcher follows Panama's light/dark setting instead of + // 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": { "light": { - "name": "tokyonight-moon", + "name": "tokyonight-day", "icon_theme": "auto" }, "dark": { diff --git a/config/local/share/applications/panama-settings.desktop b/config/local/share/applications/panama-settings.desktop index f4c2713..d3864df 100644 --- a/config/local/share/applications/panama-settings.desktop +++ b/config/local/share/applications/panama-settings.desktop @@ -1,11 +1,11 @@ [Desktop Entry] Type=Application -Name=Panama Settings +Name=Settings GenericName=System Settings -Comment=Configure the Panama Hyprland desktop +Comment=Configure this desktop Exec=qs ipc call settings open Icon=panama-settings Terminal=false StartupNotify=false Categories=Settings; -Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services; +Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts; diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 3ab8fd6..ad29e48 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -70,6 +70,73 @@ for dir in "${dirs[@]}"; do log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir" 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 # 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 @@ -95,19 +162,26 @@ fi # 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. VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes" -VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml" mkdir -p "$VICINAE_THEME_DIR" -if [ -L "$VICINAE_THEME" ]; then - rm "$VICINAE_THEME" -fi +# Every authored theme, not just the dark one: vicinae.json selects a theme per +# system appearance, so linking only Moon left the launcher falling back to its +# 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 - log "Keeping existing Vicinae theme at $VICINAE_THEME" -else - ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME" - log "Linked Tokyo Night Moon theme → $VICINAE_THEME" -fi + if [ -L "$theme_dst" ]; then + rm "$theme_dst" + fi + + if [ -e "$theme_dst" ]; then + 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 # discover them alongside system desktop entries. Keep each authored file in diff --git a/tests/quickshell/brightness-helper-contract.sh b/tests/quickshell/brightness-helper-contract.sh index 085ae65..62203d4 100755 --- a/tests/quickshell/brightness-helper-contract.sh +++ b/tests/quickshell/brightness-helper-contract.sh @@ -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 # connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and # 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() { - local name="$1" bus="$2" status="$3" - mkdir -p "$fixture/drm/$name" - printf '%s\n' "$status" >"$fixture/drm/$name/status" - mkdir -p "$fixture/i2c/i2c-$bus" - ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc" + local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}" + local device="$fixture/devices/$name" + + # /sys/class/drm/ is a SYMLINK to the real device directory, and + # 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-2 5 connected +make_connector card1-DP-2 5 connected 9 make_connector card1-DP-3 6 connected make_connector card1-HDMI-A-1 7 disconnected @@ -75,7 +95,7 @@ for arg in "$@"; do done 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 ;; esac 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" ]] \ || fail "the connector name must match Hyprland's output name: $listing" -[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \ - || fail "the display was mapped to the wrong I2C bus: $listing" +# The AUX bus, not the EDID bus its `ddc` symlink points at. +[[ "$(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%. [[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \ @@ -123,12 +144,12 @@ if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then fi # ── Writes scale to the reported maximum ───────────────────────────────────── -run_helper set 5 40 -[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \ +run_helper set 9 40 +[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \ || fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")" -run_helper set 5 500 -[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \ +run_helper set 9 500 +[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \ || fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")" # ── No I2C access explains itself ──────────────────────────────────────────── diff --git a/tests/quickshell/enum-hypr-map-contract.sh b/tests/quickshell/enum-hypr-map-contract.sh new file mode 100755 index 0000000..0973621 --- /dev/null +++ b/tests/quickshell/enum-hypr-map-contract.sh @@ -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: keyoptionvalues +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" diff --git a/tests/quickshell/gtk-theme-contract.sh b/tests/quickshell/gtk-theme-contract.sh new file mode 100755 index 0000000..07e0b26 --- /dev/null +++ b/tests/quickshell/gtk-theme-contract.sh @@ -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' diff --git a/tests/quickshell/health-ui-contract.sh b/tests/quickshell/health-ui-contract.sh index 7e0cbad..e52671e 100755 --- a/tests/quickshell/health-ui-contract.sh +++ b/tests/quickshell/health-ui-contract.sh @@ -60,6 +60,14 @@ rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ || fail 'opening System Health does not request a fresh scan' rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ || 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" \ || fail 'Settings repair does not stay inline/non-external' 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) | unique | length) == 6 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 (.rowHeights | length) == 6 and (.rowHeights | all(. >= 62)) diff --git a/tests/quickshell/home-phone-settings-contract.sh b/tests/quickshell/home-phone-settings-contract.sh index 352b66d..01f756b 100755 --- a/tests/quickshell/home-phone-settings-contract.sh +++ b/tests/quickshell/home-phone-settings-contract.sh @@ -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' for _ in $(seq 1 40); do 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 fi sleep 0.1 done /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' if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then 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' grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" fi diff --git a/tests/quickshell/keyring-helper-contract.sh b/tests/quickshell/keyring-helper-contract.sh new file mode 100755 index 0000000..12de8a9 --- /dev/null +++ b/tests/quickshell/keyring-helper-contract.sh @@ -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' diff --git a/tests/quickshell/migrations-contract.sh b/tests/quickshell/migrations-contract.sh new file mode 100755 index 0000000..6c663c7 --- /dev/null +++ b/tests/quickshell/migrations-contract.sh @@ -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' diff --git a/tests/quickshell/osd-helper-contract.sh b/tests/quickshell/osd-helper-contract.sh index 328a610..752cf94 100755 --- a/tests/quickshell/osd-helper-contract.sh +++ b/tests/quickshell/osd-helper-contract.sh @@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG" printf ' <%s>' "$@" >>"$OSD_TEST_LOG" printf '\n' >>"$OSD_TEST_LOG" if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then + [[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1 printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}" fi 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' #!/bin/bash printf 'playerctl' >>"$OSD_TEST_LOG" @@ -53,9 +105,21 @@ SH chmod +x "$scratch/bin/"* run_helper() { + local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}" + mkdir -p "$runtime" PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \ OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-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" "$@" } @@ -86,9 +150,102 @@ assert_line 'qs <72> <100> "$log" run_helper brightness up 5 -assert_line 'brightnessctl <-e4> <-n2> <5%+>' assert_line 'brightnessctl <-m> <-c> ' +assert_line 'brightnessctl <-e4> <-n2> <-c> <5%+>' assert_line 'qs <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> ' +assert_line 'panama-brightness ' +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <5> <45>' +assert_line 'qs <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 <5>' +assert_line 'panama-brightness <5> <40>' +assert_line 'qs <40> <100> <40%>' +if grep -Fq 'panama-brightness ' "$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 <9>' +assert_line 'panama-brightness ' +assert_line 'panama-brightness <5>' +assert_line 'panama-brightness <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 <3>' +assert_line 'panama-brightness <3> <25>' +assert_line 'qs <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 ' +assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> ' +if grep -Fq 'osd> ' "$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" run_helper media next diff --git a/tests/quickshell/power-profile-contract.sh b/tests/quickshell/power-profile-contract.sh new file mode 100755 index 0000000..abfd77b --- /dev/null +++ b/tests/quickshell/power-profile-contract.sh @@ -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' diff --git a/tests/quickshell/settings-commit-reset-contract.sh b/tests/quickshell/settings-commit-reset-contract.sh index a1e4352..21bf264 100755 --- a/tests/quickshell/settings-commit-reset-contract.sh +++ b/tests/quickshell/settings-commit-reset-contract.sh @@ -61,6 +61,26 @@ restore() { } 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" \ PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null for _ in $(seq 1 40); do diff --git a/tests/quickshell/settings-hyprland-write-contract.sh b/tests/quickshell/settings-hyprland-write-contract.sh index 9fa9ad0..f56e002 100755 --- a/tests/quickshell/settings-hyprland-write-contract.sh +++ b/tests/quickshell/settings-hyprland-write-contract.sh @@ -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_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 for _ in $(seq 1 40); do 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 \ '{"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 -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" \ && "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \ && "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \ @@ -135,10 +176,14 @@ for _ in $(seq 1 40); do sleep 0.1 done 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), \ gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \ 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 # Verification must recognise those shapes as success, not report them rejected. diff --git a/tests/quickshell/settings-nav-contract.sh b/tests/quickshell/settings-nav-contract.sh new file mode 100755 index 0000000..1eaff61 --- /dev/null +++ b/tests/quickshell/settings-nav-contract.sh @@ -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' diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh index 23f2b01..77bd238 100755 --- a/tests/quickshell/settings-pages-contract.sh +++ b/tests/quickshell/settings-pages-contract.sh @@ -195,14 +195,14 @@ for page in "${pages[@]}"; do done [[ "$(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" \ - '[.[] | 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" done 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' -/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' /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' diff --git a/tests/quickshell/settings-window-contract.sh b/tests/quickshell/settings-window-contract.sh index 4afad11..3cccf6c 100755 --- a/tests/quickshell/settings-window-contract.sh +++ b/tests/quickshell/settings-window-contract.sh @@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing' qs ipc call settings open >/dev/null 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 fi sleep 0.1 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' -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' 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 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 for _ in $(seq 1 40); do [[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break @@ -42,13 +42,13 @@ done qs ipc call settings open >/dev/null 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 done qs ipc call settings close >/dev/null 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 printf 'settings window contract: PASS\n' exit 0 diff --git a/tests/quickshell/wifi-qr-contract.sh b/tests/quickshell/wifi-qr-contract.sh new file mode 100755 index 0000000..66b866f --- /dev/null +++ b/tests/quickshell/wifi-qr-contract.sh @@ -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" <"$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'